补强静态发布安全边界
This commit is contained in:
@@ -46,6 +46,11 @@
|
||||
- 内置 skill 清单把 `deploy-publish-check` 标记为 retired,客户端启动时会清理已安装副本;课程、开发和营销提示统一改为“用户一键提交、平台安全打包与云端验收”,不再要求 Dockerfile、Compose、ZIP 或服务器信息。
|
||||
- 项目进度同步改用已提交版本绑定识别服务端项目,不再依赖 AI 生成的 `works-publish.json`。README 已同步静态 Release、`play_url` 和兼容边界。
|
||||
- 保留一般 OpenCode/Monaco 的 Dockerfile 语法支持、AI 设计能力、会话运行时清理和打包器对历史控制文件的安全排除;这些能力不属于旧 Compose 发布协调链。
|
||||
- 补强 Main 边界:`/api/works/projects/publish-source` 必须携带 Renderer capability,缺失或错误时在读取发布凭据、查询项目和打包前直接返回 403,OpenCode 子进程不能只凭 Host token/base 绕过 UI 发起发布。
|
||||
- 抽取共享播放 URL 校验器供 ProjectPublic 投影和设备预览复用:只接受与 Works Square base 同源的 HTTPS、禁止 userinfo/loopback、限制长度,公共作品必须精确落在 `/apps/{encodeURIComponent(app_id)}/` 且无 query/fragment;只有 `playable === true`、版本名非空且首选 `play_url`(字段缺失时才兼容 `runtime_url`)可信时才对 Renderer 标记可播放。
|
||||
- 上传云端成功但本机版本绑定落盘失败时,Main 返回固定、无磁盘路径的结构化 `binding_warning`;Renderer 显示“已提交云端,但本机预览绑定保存失败;可重新打开项目/重新提交”,同时继续轮询云构建,避免把本地辅助状态失败误报为上传失败。
|
||||
- schema v2 读取改为逐字段验证,约束 submitted/legacy_retired 的状态专属字段,并在按项目读取时校验绑定内 `project_id` 与当前项目一致,拒绝复制或串项目的绑定文件。
|
||||
- Electron E2E 增加用户可见入口断言:新建小游戏后只展示一个“一键提交审核”按钮,旧“自动部署/部署发布检查”入口不可见。
|
||||
|
||||
## Verification
|
||||
|
||||
@@ -58,6 +63,11 @@
|
||||
- `pnpm run build:vite`:Renderer、Electron Main、Preload 均构建成功;仅有仓库既有 dynamic-import/chunk-size 提示。
|
||||
- `pnpm exec playwright test --list --reporter=line`:成功收集 13 个 Electron E2E 文件、21 条用例,当前仓库不存在“缺少 Electron E2E fixture”问题。
|
||||
- `git diff --check`:通过;仅有 Git 的 LF/CRLF 工作区提示。
|
||||
- 安全补强聚焦回归:`pnpm exec vitest run tests/unit/works-play-url.test.ts tests/unit/works-submission-binding.test.ts tests/unit/device-preview-routes.test.ts tests/unit/project-progress-sync.test.ts tests/unit/works-routes.test.ts tests/unit/works-square.test.ts tests/unit/project-publish-action.test.tsx tests/unit/works-project-publish.test.ts tests/unit/project-packager.test.ts tests/unit/opencode-routes.test.ts`:10 files / 227 tests passed;覆盖 capability 先行拒绝、公共 URL fail-closed 投影、绑定告警继续轮询、schema v2 状态字段与 project_id 一致性。
|
||||
- 对本次补强涉及的全部 TS/TSX 文件执行 scoped ESLint:0 errors / 0 warnings。
|
||||
- 安全补强后再次执行 `pnpm run typecheck` 与 `pnpm run build:vite`:均通过;构建仅有既有 dynamic-import/chunk-size 提示。
|
||||
- `pnpm exec playwright test tests/e2e/project-superpowers-toggle.spec.ts --reporter=line`:1 passed;实际 Electron 启动、创建小游戏、一键提交入口和旧自动部署入口消失均通过。
|
||||
- 安全补强后再次执行 `pnpm exec playwright test --list --reporter=line`:成功收集 13 个 Electron E2E 文件、21 条用例。
|
||||
|
||||
## Follow-ups
|
||||
|
||||
@@ -69,7 +79,7 @@
|
||||
|
||||
- Target: `.project-docs/20-architecture/system-overview.md`、`.project-docs/40-domain/business-rules.md` 与 README 当前产品状态。
|
||||
- Proposal: 客户端发布唯一链路是 Main 安全生成静态包并提交服务端 Builder;客户端只持久化成功提交的精确版本绑定,不再拥有 Compose runner、自动 watcher/arm/upload 或手工 ZIP 入口。
|
||||
- Evidence: 本任务删除 3,000 余行旧协调代码与测试,新增 schema v2 迁移及 216 项聚焦回归,typecheck、Vite 构建和 Electron E2E 收集通过。
|
||||
- Evidence: 本任务删除 3,000 余行旧协调代码与测试,新增 schema v2 迁移、安全播放 URL 投影和 Renderer capability 门禁;补强后的 227 项聚焦回归、typecheck、Vite 构建、实际 Electron E2E 与 E2E 收集均通过。
|
||||
- Future impact: 后续发布功能必须建立在 immutable static Release、`play_url` 和服务端审核发布状态之上;不得重新要求非专业用户准备 Docker/Compose/ZIP/服务器凭证。
|
||||
- Semantic conflicts: 落盘文件名 `works-cloud-deploy.json` 仅为已安装数据兼容,不代表仍存在 cloud deployment coordinator;`runtime_url` 仅为一个客户端版本的临时兼容字段。
|
||||
- Human confirmation required: 是;用户已明确要求一步到位移除旧 runtime-cleaner/runner 方向并统一到 Builder -> OSS -> CDN -> App,canonical promotion 仍由 Integration Gate 执行。
|
||||
|
||||
@@ -3,6 +3,10 @@ import type { HostApiContext } from '../context';
|
||||
import { sendJson } from '../route-utils';
|
||||
import { hasRendererCapability } from '../renderer-capability';
|
||||
import { WORKS_SQUARE_CONFIG } from '../works-config';
|
||||
import {
|
||||
trustedWorksProjectPlayUrl,
|
||||
trustedWorksReleasePreviewUrl,
|
||||
} from '../works-play-url';
|
||||
import { getValidWorksSquareAccessToken } from '../../services/works-square-session';
|
||||
import { proxyAwareFetch } from '../../utils/proxy-fetch';
|
||||
import type { WorksSubmissionBindingRecord } from '../../../shared/works-submission-binding';
|
||||
@@ -80,46 +84,6 @@ function normalizedWorksBaseUrl(): URL {
|
||||
return new URL(`${base}/`);
|
||||
}
|
||||
|
||||
function isLoopbackHostname(hostname: string): boolean {
|
||||
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
||||
return normalized === 'localhost'
|
||||
|| normalized === '::1'
|
||||
|| normalized === '0.0.0.0'
|
||||
|| normalized.startsWith('127.');
|
||||
}
|
||||
|
||||
function trustedLaunchUrl(value: string, worksBase: URL): string | null {
|
||||
try {
|
||||
const target = new URL(value, worksBase);
|
||||
const absoluteUrl = target.toString();
|
||||
if (
|
||||
absoluteUrl.length > 1_024
|
||||
|| worksBase.protocol !== 'https:'
|
||||
|| target.protocol !== 'https:'
|
||||
|| target.origin !== worksBase.origin
|
||||
|| Boolean(target.username || target.password)
|
||||
|| isLoopbackHostname(target.hostname)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return absoluteUrl;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function trustedReleasePreviewUrl(
|
||||
value: string,
|
||||
worksBase: URL,
|
||||
releaseId: string,
|
||||
): string | null {
|
||||
const trusted = trustedLaunchUrl(value, worksBase);
|
||||
if (!trusted) return null;
|
||||
const target = new URL(trusted);
|
||||
const expectedPrefix = `/previews/${encodeURIComponent(releaseId)}/`;
|
||||
return target.pathname.startsWith(expectedPrefix) ? trusted : null;
|
||||
}
|
||||
|
||||
async function requireManagedAccessToken(): Promise<string> {
|
||||
const accessToken = await getValidWorksSquareAccessToken();
|
||||
if (!accessToken) {
|
||||
@@ -294,7 +258,11 @@ async function resolveDevicePreview(
|
||||
const targetVersionMatches = remote.latestVersionId === deployment.version_id
|
||||
&& remote.latestVersionName === deployment.version_name;
|
||||
const launchUrl = remote.runtimeUrl
|
||||
? trustedLaunchUrl(remote.runtimeUrl, normalizedWorksBaseUrl())
|
||||
? trustedWorksProjectPlayUrl(
|
||||
remote.runtimeUrl,
|
||||
normalizedWorksBaseUrl(),
|
||||
deployment.app_id,
|
||||
)
|
||||
: null;
|
||||
|
||||
if (!targetVersionMatches) {
|
||||
@@ -334,7 +302,7 @@ async function resolveDevicePreview(
|
||||
remote.latestReleaseId,
|
||||
accessToken,
|
||||
);
|
||||
const previewUrl = trustedReleasePreviewUrl(
|
||||
const previewUrl = trustedWorksReleasePreviewUrl(
|
||||
rawPreviewUrl,
|
||||
normalizedWorksBaseUrl(),
|
||||
remote.latestReleaseId,
|
||||
|
||||
@@ -5,6 +5,8 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
import { hasRendererCapability } from '../renderer-capability';
|
||||
import { trustedWorksProjectPlayUrl } from '../works-play-url';
|
||||
import { proxyAwareFetch } from '../../utils/proxy-fetch';
|
||||
import { WORKS_SQUARE_CONFIG } from '../works-config';
|
||||
import {
|
||||
@@ -201,7 +203,6 @@ function projectSafeProject(value: unknown): Record<string, unknown> | null {
|
||||
'buddy_name',
|
||||
'buddy_sprite_url',
|
||||
'buddy_pose_url',
|
||||
'version_name',
|
||||
'testing_ask',
|
||||
'update_note',
|
||||
'remix_note',
|
||||
@@ -212,10 +213,45 @@ function projectSafeProject(value: unknown): Record<string, unknown> | null {
|
||||
const fieldValue = value[field];
|
||||
if (fieldValue === null || typeof fieldValue === 'string') projected[field] = fieldValue;
|
||||
}
|
||||
if (typeof value.playable === 'boolean') projected.playable = value.playable;
|
||||
|
||||
const versionName = readOptionalString(value.version_name);
|
||||
if (value.version_name !== undefined) projected.version_name = versionName ?? null;
|
||||
projected.playable = false;
|
||||
projected.play_url = null;
|
||||
projected.runtime_url = null;
|
||||
|
||||
if (value.playable === true && versionName) {
|
||||
const playUrlPresent = value.play_url !== undefined && value.play_url !== null;
|
||||
const primaryPlayUrl = readOptionalString(value.play_url);
|
||||
const fallbackRuntimeUrl = readOptionalString(value.runtime_url);
|
||||
const candidate = playUrlPresent ? primaryPlayUrl : fallbackRuntimeUrl;
|
||||
const trusted = candidate
|
||||
? trustedWorksProjectPlayUrl(
|
||||
candidate,
|
||||
new URL(`${normalizeWorksBase()}/`),
|
||||
appId,
|
||||
)
|
||||
: null;
|
||||
if (trusted) {
|
||||
projected.playable = true;
|
||||
if (playUrlPresent) projected.play_url = trusted;
|
||||
else projected.runtime_url = trusted;
|
||||
}
|
||||
}
|
||||
return projected;
|
||||
}
|
||||
|
||||
function projectSafeProjectPage(value: unknown): Record<string, unknown> | null {
|
||||
if (!isRecord(value) || !Array.isArray(value.items)) return null;
|
||||
const items = value.items.map(projectSafeProject);
|
||||
if (items.some((item) => item === null)) return null;
|
||||
const nextCursor = readNullableStringField(value, 'next_cursor');
|
||||
if (nextCursor === undefined || typeof value.limit !== 'number' || !Number.isFinite(value.limit)) {
|
||||
return null;
|
||||
}
|
||||
return { items, next_cursor: nextCursor, limit: value.limit };
|
||||
}
|
||||
|
||||
function projectSafeVersion(value: unknown): Record<string, unknown> | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const id = readOptionalString(value.id);
|
||||
@@ -296,7 +332,12 @@ async function handleListProjects(res: ServerResponse, url: URL): Promise<void>
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(res, 200, { success: true, page: await readResponsePayload(response) });
|
||||
const page = projectSafeProjectPage(await readResponsePayload(response));
|
||||
if (!page) {
|
||||
sendJson(res, 502, { success: false, error: 'Works Square returned an invalid project list' });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 200, { success: true, page });
|
||||
}
|
||||
|
||||
async function handleGetProject(res: ServerResponse, appId: string): Promise<void> {
|
||||
@@ -307,7 +348,12 @@ async function handleGetProject(res: ServerResponse, appId: string): Promise<voi
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(res, 200, { success: true, project: await readResponsePayload(response) });
|
||||
const project = projectSafeProject(await readResponsePayload(response));
|
||||
if (!project) {
|
||||
sendJson(res, 502, { success: false, error: 'Works Square returned an invalid project' });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 200, { success: true, project });
|
||||
}
|
||||
|
||||
async function handleListAssets(res: ServerResponse, url: URL): Promise<void> {
|
||||
@@ -433,7 +479,12 @@ async function handleCreateProject(req: IncomingMessage, res: ServerResponse): P
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(res, response.status, { success: true, project: await readResponsePayload(response) });
|
||||
const project = projectSafeProject(await readResponsePayload(response));
|
||||
if (!project) {
|
||||
sendJson(res, 502, { success: false, error: 'Works Square returned an invalid project' });
|
||||
return;
|
||||
}
|
||||
sendJson(res, response.status, { success: true, project });
|
||||
}
|
||||
|
||||
async function handleListMyProjects(
|
||||
@@ -457,7 +508,12 @@ async function handleListMyProjects(
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(res, response.status, { success: true, page: await readResponsePayload(response) });
|
||||
const page = projectSafeProjectPage(await readResponsePayload(response));
|
||||
if (!page) {
|
||||
sendJson(res, 502, { success: false, error: 'Works Square returned an invalid project list' });
|
||||
return;
|
||||
}
|
||||
sendJson(res, response.status, { success: true, page });
|
||||
}
|
||||
|
||||
async function handleGetBillingTokenUsage(
|
||||
@@ -619,6 +675,10 @@ async function handleGetMyProjectStatus(
|
||||
const SOURCE_PUBLISH_CHANGE_LOG = '通过 Makelore 一键提交';
|
||||
const RETRYABLE_SOURCE_UPLOAD_STATUSES = new Set([408, 502, 503, 504]);
|
||||
const VERSION_FILE_MAX_BYTES = 64 * 1024;
|
||||
const LOCAL_PREVIEW_BINDING_WARNING = {
|
||||
code: 'LOCAL_PREVIEW_BINDING_SAVE_FAILED',
|
||||
message: '已提交云端,但本机预览绑定保存失败;可重新打开项目/重新提交。',
|
||||
} as const;
|
||||
|
||||
function createFallbackVersionName(now = new Date()): string {
|
||||
return `v${now.toISOString().replace(/\D/g, '').slice(0, 14)}`;
|
||||
@@ -841,6 +901,7 @@ async function handlePublishProjectSource(
|
||||
);
|
||||
return;
|
||||
}
|
||||
let bindingWarning: typeof LOCAL_PREVIEW_BINDING_WARNING | undefined;
|
||||
if (ctx.worksSubmissionBinding) {
|
||||
try {
|
||||
await ctx.worksSubmissionBinding.recordSubmitted(projectId, {
|
||||
@@ -852,13 +913,17 @@ async function handlePublishProjectSource(
|
||||
});
|
||||
} catch {
|
||||
logger.warn('[works] One-click submission succeeded, but local preview mapping could not be saved');
|
||||
bindingWarning = LOCAL_PREVIEW_BINDING_WARNING;
|
||||
}
|
||||
} else {
|
||||
bindingWarning = LOCAL_PREVIEW_BINDING_WARNING;
|
||||
}
|
||||
const { archivePath: _archivePath, ...rendererPackageSummary } = packageSummary;
|
||||
sendJson(res, uploadResponse.status, {
|
||||
success: true,
|
||||
package: rendererPackageSummary,
|
||||
upload: uploadPayload,
|
||||
...(bindingWarning ? { binding_warning: bindingWarning } : {}),
|
||||
});
|
||||
} finally {
|
||||
await rm(temporaryDirectory, { recursive: true, force: true }).catch(() => undefined);
|
||||
@@ -1005,6 +1070,15 @@ export async function handleWorksRoutes(
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/works/projects/publish-source' && req.method === 'POST') {
|
||||
if (!hasRendererCapability(req)) {
|
||||
sendJson(res, 403, {
|
||||
success: false,
|
||||
status: 403,
|
||||
code: 'RENDERER_CAPABILITY_REQUIRED',
|
||||
error: 'Renderer capability required',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
await handlePublishProjectSource(req, res, ctx);
|
||||
return true;
|
||||
}
|
||||
|
||||
62
electron/api/works-play-url.ts
Normal file
62
electron/api/works-play-url.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
const MAX_WORKS_PLAY_URL_LENGTH = 1_024;
|
||||
|
||||
function isLoopbackHostname(hostname: string): boolean {
|
||||
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
||||
return normalized === 'localhost'
|
||||
|| normalized.endsWith('.localhost')
|
||||
|| normalized === '::'
|
||||
|| normalized === '::1'
|
||||
|| normalized.startsWith('::ffff:127.')
|
||||
|| normalized.startsWith('::ffff:7f00:')
|
||||
|| normalized === '0.0.0.0'
|
||||
|| normalized.startsWith('127.');
|
||||
}
|
||||
|
||||
function trustedWorksHttpsUrl(value: string, worksBase: URL): URL | null {
|
||||
try {
|
||||
const target = new URL(value, worksBase);
|
||||
if (
|
||||
target.toString().length > MAX_WORKS_PLAY_URL_LENGTH
|
||||
|| worksBase.protocol !== 'https:'
|
||||
|| target.protocol !== 'https:'
|
||||
|| target.origin !== worksBase.origin
|
||||
|| Boolean(target.username || target.password)
|
||||
|| isLoopbackHostname(target.hostname)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return target;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function trustedWorksProjectPlayUrl(
|
||||
value: string,
|
||||
worksBase: URL,
|
||||
appId: string,
|
||||
): string | null {
|
||||
const normalizedAppId = appId.trim();
|
||||
if (!normalizedAppId) return null;
|
||||
const target = trustedWorksHttpsUrl(value, worksBase);
|
||||
if (
|
||||
!target
|
||||
|| target.pathname !== `/apps/${encodeURIComponent(normalizedAppId)}/`
|
||||
|| target.search
|
||||
|| target.hash
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return target.toString();
|
||||
}
|
||||
|
||||
export function trustedWorksReleasePreviewUrl(
|
||||
value: string,
|
||||
worksBase: URL,
|
||||
releaseId: string,
|
||||
): string | null {
|
||||
const target = trustedWorksHttpsUrl(value, worksBase);
|
||||
if (!target) return null;
|
||||
const expectedPrefix = `/previews/${encodeURIComponent(releaseId)}/`;
|
||||
return target.pathname.startsWith(expectedPrefix) ? target.toString() : null;
|
||||
}
|
||||
@@ -139,7 +139,7 @@ async function readProductOverview(projectPath: string): Promise<string> {
|
||||
async function resolveProjectProgressIdentity(
|
||||
project: OpencodeProject,
|
||||
): Promise<ProjectProgressIdentity> {
|
||||
const binding = await readWorksSubmissionBinding(project.path);
|
||||
const binding = await readWorksSubmissionBinding(project.path, project.id);
|
||||
if (binding?.status === 'submitted' && binding.app_id) {
|
||||
return { projectType: 'server', projectKey: binding.app_id };
|
||||
}
|
||||
|
||||
@@ -47,15 +47,58 @@ function readString(value: unknown): string | null {
|
||||
function parseCurrentRecord(value: unknown): WorksSubmissionBindingRecord | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (
|
||||
record.schema_version !== WORKS_SUBMISSION_BINDING_SCHEMA_VERSION
|
||||
|| (record.status !== 'submitted' && record.status !== 'legacy_retired')
|
||||
) return null;
|
||||
if (record.schema_version !== WORKS_SUBMISSION_BINDING_SCHEMA_VERSION) return null;
|
||||
const projectId = readString(record.project_id);
|
||||
const requestedAt = readString(record.requested_at);
|
||||
const updatedAt = readString(record.updated_at);
|
||||
if (!projectId || !requestedAt || !updatedAt) return null;
|
||||
return record as WorksSubmissionBindingRecord;
|
||||
|
||||
if (record.status === 'submitted') {
|
||||
const appId = readString(record.app_id);
|
||||
const versionId = readString(record.version_id);
|
||||
const versionName = readString(record.version_name);
|
||||
const reviewStatus = record.review_status === undefined
|
||||
? null
|
||||
: readString(record.review_status);
|
||||
const zipSha256 = record.zip_sha256 === undefined
|
||||
? null
|
||||
: readString(record.zip_sha256);
|
||||
if (
|
||||
!appId
|
||||
|| !versionId
|
||||
|| !versionName
|
||||
|| (record.review_status !== undefined && !reviewStatus)
|
||||
|| (record.zip_sha256 !== undefined && (!zipSha256 || !/^[a-f0-9]{64}$/i.test(zipSha256)))
|
||||
) return null;
|
||||
return {
|
||||
schema_version: WORKS_SUBMISSION_BINDING_SCHEMA_VERSION,
|
||||
project_id: projectId,
|
||||
status: 'submitted',
|
||||
requested_at: requestedAt,
|
||||
updated_at: updatedAt,
|
||||
app_id: appId,
|
||||
version_id: versionId,
|
||||
version_name: versionName,
|
||||
...(reviewStatus ? { review_status: reviewStatus } : {}),
|
||||
...(zipSha256 ? { zip_sha256: zipSha256.toLowerCase() } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const message = readString(record.message);
|
||||
if (
|
||||
record.status !== 'legacy_retired'
|
||||
|| record.error_code !== 'LEGACY_AUTO_DEPLOY_RETIRED'
|
||||
|| !message
|
||||
) return null;
|
||||
return {
|
||||
schema_version: WORKS_SUBMISSION_BINDING_SCHEMA_VERSION,
|
||||
project_id: projectId,
|
||||
status: 'legacy_retired',
|
||||
requested_at: requestedAt,
|
||||
updated_at: updatedAt,
|
||||
error_code: 'LEGACY_AUTO_DEPLOY_RETIRED',
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
function parseLegacyRecord(value: unknown): LegacyBindingRecord | null {
|
||||
@@ -73,23 +116,25 @@ function parseLegacyRecord(value: unknown): LegacyBindingRecord | null {
|
||||
|
||||
function migrateLegacyRecord(record: LegacyBindingRecord): WorksSubmissionBindingRecord {
|
||||
const now = new Date().toISOString();
|
||||
if (
|
||||
record.status === 'submitted'
|
||||
&& readString(record.app_id)
|
||||
&& readString(record.version_id)
|
||||
&& readString(record.version_name)
|
||||
) {
|
||||
const appId = readString(record.app_id);
|
||||
const versionId = readString(record.version_id);
|
||||
const versionName = readString(record.version_name);
|
||||
if (record.status === 'submitted' && appId && versionId && versionName) {
|
||||
const reviewStatus = readString(record.review_status);
|
||||
const zipSha256 = readString(record.zip_sha256);
|
||||
return {
|
||||
schema_version: WORKS_SUBMISSION_BINDING_SCHEMA_VERSION,
|
||||
project_id: record.project_id,
|
||||
status: 'submitted',
|
||||
requested_at: record.requested_at,
|
||||
updated_at: now,
|
||||
app_id: record.app_id,
|
||||
version_id: record.version_id,
|
||||
version_name: record.version_name,
|
||||
...(readString(record.review_status) ? { review_status: record.review_status } : {}),
|
||||
...(readString(record.zip_sha256) ? { zip_sha256: record.zip_sha256 } : {}),
|
||||
app_id: appId,
|
||||
version_id: versionId,
|
||||
version_name: versionName,
|
||||
...(reviewStatus ? { review_status: reviewStatus } : {}),
|
||||
...(zipSha256 && /^[a-f0-9]{64}$/i.test(zipSha256)
|
||||
? { zip_sha256: zipSha256.toLowerCase() }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -120,6 +165,7 @@ async function persistRecord(
|
||||
|
||||
export async function readWorksSubmissionBinding(
|
||||
projectPath: string,
|
||||
expectedProjectId?: string,
|
||||
): Promise<WorksSubmissionBindingRecord | null> {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
@@ -132,9 +178,9 @@ export async function readWorksSubmissionBinding(
|
||||
}
|
||||
|
||||
const current = parseCurrentRecord(parsed);
|
||||
if (current) return current;
|
||||
if (current) return !expectedProjectId || current.project_id === expectedProjectId ? current : null;
|
||||
const legacy = parseLegacyRecord(parsed);
|
||||
if (!legacy) return null;
|
||||
if (!legacy || (expectedProjectId && legacy.project_id !== expectedProjectId)) return null;
|
||||
return persistRecord(projectPath, migrateLegacyRecord(legacy));
|
||||
}
|
||||
|
||||
@@ -162,13 +208,13 @@ export function createWorksSubmissionBindingStore(projectStore: ProjectStore) {
|
||||
|
||||
async function get(projectId: string): Promise<WorksSubmissionBindingRecord | null> {
|
||||
const project = (await projectStore.listProjects()).find((item) => item.id === projectId);
|
||||
return project ? readWorksSubmissionBinding(project.path) : null;
|
||||
return project ? readWorksSubmissionBinding(project.path, project.id) : null;
|
||||
}
|
||||
|
||||
async function start(): Promise<void> {
|
||||
for (const project of await projectStore.listProjects()) {
|
||||
try {
|
||||
await readWorksSubmissionBinding(project.path);
|
||||
await readWorksSubmissionBinding(project.path, project.id);
|
||||
} catch (error) {
|
||||
logger.warn(`[works-submission-binding] Failed to migrate ${project.id}`, error);
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ function buttonLabel(phase: PublishPhase): string {
|
||||
export function ProjectPublishAction({ project, projectType }: ProjectPublishActionProps) {
|
||||
const [phase, setPhase] = useState<PublishPhase>('idle');
|
||||
const [failure, setFailure] = useState<WorksPublishFailure | null>(null);
|
||||
const [bindingWarning, setBindingWarning] = useState<string | null>(null);
|
||||
const pollGeneration = useRef(0);
|
||||
|
||||
useEffect(() => () => {
|
||||
@@ -130,6 +131,7 @@ export function ProjectPublishAction({ project, projectType }: ProjectPublishAct
|
||||
pollGeneration.current += 1;
|
||||
const generation = pollGeneration.current;
|
||||
setFailure(null);
|
||||
setBindingWarning(null);
|
||||
setPhase('submitting');
|
||||
|
||||
try {
|
||||
@@ -138,6 +140,7 @@ export function ProjectPublishAction({ project, projectType }: ProjectPublishAct
|
||||
project: metadata,
|
||||
});
|
||||
if (pollGeneration.current !== generation) return;
|
||||
setBindingWarning(result.bindingWarning?.message ?? null);
|
||||
setPhase('polling');
|
||||
void pollBuildStatus(result.upload.version_id, generation);
|
||||
} catch (error) {
|
||||
@@ -185,6 +188,16 @@ export function ProjectPublishAction({ project, projectType }: ProjectPublishAct
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{bindingWarning ? (
|
||||
<p
|
||||
data-testid="project-publish-binding-warning"
|
||||
aria-live="polite"
|
||||
className="max-w-md rounded-xl border border-amber-500/30 bg-amber-50 px-3 py-2 text-left text-xs leading-5 text-amber-900"
|
||||
>
|
||||
{bindingWarning}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{failure ? (
|
||||
<div
|
||||
data-testid="project-publish-failure"
|
||||
|
||||
@@ -135,9 +135,15 @@ export type WorksProjectSourcePublishInput = {
|
||||
project: WorksProjectMetadataInput;
|
||||
};
|
||||
|
||||
export type WorksSubmissionBindingWarning = {
|
||||
code: 'LOCAL_PREVIEW_BINDING_SAVE_FAILED';
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type WorksProjectSourcePublishResult = {
|
||||
package: WorksStaticPackageSummary;
|
||||
upload: WorksProjectVersionUpload;
|
||||
bindingWarning?: WorksSubmissionBindingWarning;
|
||||
};
|
||||
|
||||
export type WorksSpeechTranscription = {
|
||||
@@ -416,6 +422,7 @@ export async function publishWorksProjectSource(
|
||||
error?: string;
|
||||
package?: WorksStaticPackageSummary;
|
||||
upload?: WorksProjectVersionUpload;
|
||||
binding_warning?: unknown;
|
||||
}>('/api/works/projects/publish-source', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
@@ -430,7 +437,18 @@ export async function publishWorksProjectSource(
|
||||
response.code,
|
||||
);
|
||||
}
|
||||
return { package: response.package, upload: response.upload };
|
||||
const bindingWarning = isRecord(response.binding_warning)
|
||||
&& response.binding_warning.code === 'LOCAL_PREVIEW_BINDING_SAVE_FAILED'
|
||||
? {
|
||||
code: 'LOCAL_PREVIEW_BINDING_SAVE_FAILED' as const,
|
||||
message: '已提交云端,但本机预览绑定保存失败;可重新打开项目/重新提交。',
|
||||
}
|
||||
: undefined;
|
||||
return {
|
||||
package: response.package,
|
||||
upload: response.upload,
|
||||
...(bindingWarning ? { bindingWarning } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchWorksProjectVersions(
|
||||
|
||||
@@ -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