From 9d1bb470414f07b53d38ba3c09413b463d198f8c Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 1 Jul 2026 08:21:33 +0800 Subject: [PATCH] test: require launch persona evidence --- README.md | 1 + .../production-launch-evidence.template.json | 45 +++++++++++++++++++ .../web-launch-acceptance-checklist.md | 2 + scripts/production-launch-gate-test.js | 24 ++++++++++ scripts/production-launch-gate.js | 27 ++++++++++- 5 files changed, 98 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d4cec37a..2276ff97 100644 --- a/README.md +++ b/README.md @@ -470,6 +470,7 @@ npm run launch:gate -- --evidence docs/refactor/production-launch-evidence.json - 真实 `smoke:auth:remote`、`test:rls`。 - PocketBase production dry-run、`pb:import:validate`、`pb:import:sample`。 - 真实数据 API 读路径压测、API/worker/Taro 构建。 +- `smoke:launch-persona` 真实 API 角色旅程:普通学生 SVIP 后刷题、收藏、错题/收藏复习入口,租户管理员看板/主题/学生/销售转化入口,平台管理员租户/套餐/审计入口,以及越权拒绝。 - `smoke:taro:h5`、`smoke:taro:h5:interaction`、严格 `taro-h5-release-guardrails-test --require-runtime-config`。 - `audit:runtime`、`security:repo`、真实 `@codex-security` 扫描。 - 备份、回滚、真实数据抽样、生产 provider、对象存储控制、支付对账和三套 H5 `runtime-config.json` 人工确认。 diff --git a/docs/refactor/production-launch-evidence.template.json b/docs/refactor/production-launch-evidence.template.json index 908a5d41..3614c022 100644 --- a/docs/refactor/production-launch-evidence.template.json +++ b/docs/refactor/production-launch-evidence.template.json @@ -133,6 +133,51 @@ "includeWrites": true } }, + { + "id": "api.launch-persona-smoke", + "status": "pass", + "command": "npm run smoke:launch-persona > docs/refactor/launch-artifacts/launch-persona-smoke.log", + "completedAt": "2026-06-30T10:49:30+08:00", + "artifact": "launch-artifacts/launch-persona-smoke.json", + "summary": { + "status": "pass", + "student": { + "status": "pass", + "result": { + "entitlement": { + "isSvip": true + }, + "practice": { + "questionCount": 3 + }, + "favorite": { + "favoriteCount": 1, + "reviewSessionId": "replace-with-favorite-review-session-id" + }, + "wrongReview": { + "reviewPlanCount": 0 + } + } + }, + "tenantAdmin": { + "status": "pass", + "result": { + "guards": { + "studentDashboardDenied": "TENANT_MEMBER_REQUIRED", + "crossTenantDenied": "TENANT_MEMBER_REQUIRED" + } + } + }, + "platformAdmin": { + "status": "pass", + "result": { + "guards": { + "studentPlatformDenied": "PLATFORM_ADMIN_REQUIRED" + } + } + } + } + }, { "id": "api.integration", "status": "pass", diff --git a/docs/refactor/web-launch-acceptance-checklist.md b/docs/refactor/web-launch-acceptance-checklist.md index 5dbd37d4..d21f1f9d 100644 --- a/docs/refactor/web-launch-acceptance-checklist.md +++ b/docs/refactor/web-launch-acceptance-checklist.md @@ -90,6 +90,8 @@ node scripts/taro-h5-release-guardrails-test.js --require-dist npm run smoke:launch-persona ``` +`smoke:launch-persona` 是真实 API 角色旅程烟测,必须进入生产上线证据:普通学生要能在 SVIP 权益下创建练习、答题、收藏题目、进入收藏复习和错题复习入口;租户管理员要能读取看板/主题/学生/销售转化并拒绝学生或跨租户访问;平台管理员要能读取租户/套餐/审计入口并拒绝学生访问平台后台。 + `smoke:taro:h5` 会启动临时静态服务器和 mock API,验证三套 H5 的 `index.html`、JS/CSS 资源、history fallback、公开 runtime config 和 `/api/tenant/resolve` 契约。`smoke:taro:h5:interaction` 会在真实 Chrome/Edge 中点击学生、租户后台、平台后台关键路径,覆盖静态烟测发现不了的 JS 运行时、直接 history 路由刷新和 Taro 点击事件问题;当前脚本覆盖 32 项检查,包括学生首页、题库、答题、收藏、错题/收藏复习、背单词、知识手册、资料短签名和水印、视频播放授权、分数线、AI 择校、消息中心、会员收银台下单/支付参数/订单状态,租户后台内容导入、公共题库采纳/同步/冲突处理、学生运营、营销/CRM/分佣、主题/角色/成员写操作,以及平台后台租户、账务、公共题库授权和员工写操作。`taro-h5-release-guardrails-test` 会扫描源码、三套 H5 产物和 runtime-config 边界,防止旧 PocketBase、`x-user-id`、`x-platform-admin-key`、数据库连接串和服务端密钥形态进入前端发布目录。若刚构建完但未放入真实 `runtime-config.json`,脚本允许 warning;正式部署目录必须补齐。 写入生产上线证据时,三套正式发布目录必须先放入真实公开 `runtime-config.json`,再运行严格模式: diff --git a/scripts/production-launch-gate-test.js b/scripts/production-launch-gate-test.js index 1ac03fab..c210b4cd 100644 --- a/scripts/production-launch-gate-test.js +++ b/scripts/production-launch-gate-test.js @@ -16,6 +16,7 @@ function sampleSummaryValue(expectedValue) { if (!expectedValue || typeof expectedValue !== 'object' || Array.isArray(expectedValue)) return expectedValue; if (Object.prototype.hasOwnProperty.call(expectedValue, 'eq')) return expectedValue.eq; if (Object.prototype.hasOwnProperty.call(expectedValue, 'oneOf')) return expectedValue.oneOf?.[0]; + if (Object.prototype.hasOwnProperty.call(expectedValue, 'truthy')) return expectedValue.truthy ? 'sample-truthy-value' : ''; if (Object.prototype.hasOwnProperty.call(expectedValue, 'lte')) return expectedValue.lte; if (Object.prototype.hasOwnProperty.call(expectedValue, 'lt')) return Number(expectedValue.lt) - 1; if (Object.prototype.hasOwnProperty.call(expectedValue, 'gte')) return expectedValue.gte; @@ -176,6 +177,29 @@ assert.ok( 'slow mixed benchmark should be reported as a blocker', ); +const missingLaunchPersonaSmoke = runGate(tempDir => { + const evidence = createEvidence(tempDir); + evidence.checks = evidence.checks.filter(item => item.id !== 'api.launch-persona-smoke'); + return evidence; +}); +assert.notEqual(missingLaunchPersonaSmoke.status, 0, 'missing launch persona smoke should fail launch gate'); +assert.ok( + missingLaunchPersonaSmoke.payload.checks?.some(item => item.id === 'check.api.launch-persona-smoke' && item.status === 'blocker'), + 'missing launch persona smoke should be reported as a blocker', +); + +const launchPersonaWithoutSvip = runGate(tempDir => { + const evidence = createEvidence(tempDir); + const item = evidence.checks.find(check => check.id === 'api.launch-persona-smoke'); + item.summary.student.result.entitlement.isSvip = false; + return evidence; +}); +assert.notEqual(launchPersonaWithoutSvip.status, 0, 'launch persona smoke without SVIP should fail launch gate'); +assert.ok( + launchPersonaWithoutSvip.payload.checks?.some(item => item.id === 'check.api.launch-persona-smoke.summary' && item.status === 'blocker'), + 'launch persona SVIP failure should be reported as a blocker', +); + const missingBusinessSampling = runGate(tempDir => { const evidence = createEvidence(tempDir); evidence.checks = evidence.checks.filter(item => item.id !== 'migration.pb-import-sample'); diff --git a/scripts/production-launch-gate.js b/scripts/production-launch-gate.js index 72561c9e..52cf2ac5 100644 --- a/scripts/production-launch-gate.js +++ b/scripts/production-launch-gate.js @@ -96,6 +96,25 @@ const gateChecks = [ includeWrites: true, }, }, + { + id: 'api.launch-persona-smoke', + label: 'Real API launch persona journey smoke', + commandIncludes: 'smoke:launch-persona', + summary: { + status: 'pass', + 'student.status': 'pass', + 'student.result.entitlement.isSvip': true, + 'student.result.practice.questionCount': { gte: 1 }, + 'student.result.favorite.favoriteCount': { gte: 1 }, + 'student.result.favorite.reviewSessionId': { truthy: true }, + 'student.result.wrongReview.reviewPlanCount': { gte: 0 }, + 'tenantAdmin.status': 'pass', + 'tenantAdmin.result.guards.studentDashboardDenied': { oneOf: ['TENANT_MEMBER_REQUIRED', 'TENANT_PERMISSION_DENIED', 'FORBIDDEN'] }, + 'tenantAdmin.result.guards.crossTenantDenied': { oneOf: ['TENANT_MEMBER_REQUIRED', 'TENANT_PERMISSION_DENIED', 'FORBIDDEN'] }, + 'platformAdmin.status': 'pass', + 'platformAdmin.result.guards.studentPlatformDenied': { oneOf: ['PLATFORM_ADMIN_REQUIRED', 'FORBIDDEN'] }, + }, + }, { id: 'api.integration', label: 'API integration regression', @@ -342,7 +361,7 @@ function compareSummary(actualSummary, expectedSummary) { function isComparatorSpec(value) { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; - return ['eq', 'lt', 'lte', 'gt', 'gte', 'oneOf'].some(key => Object.prototype.hasOwnProperty.call(value, key)); + return ['eq', 'lt', 'lte', 'gt', 'gte', 'oneOf', 'truthy'].some(key => Object.prototype.hasOwnProperty.call(value, key)); } function compareExpectedValue(actualValue, expectedValue) { @@ -362,6 +381,12 @@ function compareExpectedValue(actualValue, expectedValue) { return `expected one of ${JSON.stringify(choices)} but got ${JSON.stringify(actualValue)}`; } } + if (Object.prototype.hasOwnProperty.call(expectedValue, 'truthy')) { + const shouldBeTruthy = Boolean(expectedValue.truthy); + if (Boolean(actualValue) !== shouldBeTruthy) { + return `expected ${shouldBeTruthy ? 'truthy' : 'falsy'} but got ${JSON.stringify(actualValue)}`; + } + } const numericChecks = [ ['lt', (actual, expected) => actual < expected, '<'],