test: require launch persona evidence

This commit is contained in:
Codex
2026-07-01 08:21:33 +08:00
parent 1c94e6f966
commit 9d1bb47041
5 changed files with 98 additions and 1 deletions

View File

@@ -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` 人工确认。

View File

@@ -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",

View File

@@ -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`,再运行严格模式:

View File

@@ -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');

View File

@@ -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, '<'],