From cf1ae6026b19c46476449de6ad2a6a05c0440a99 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 1 Jul 2026 08:28:17 +0800 Subject: [PATCH] test: run launch persona smoke with app session --- README.md | 2 +- .../production-launch-evidence.template.json | 7 +- .../web-launch-acceptance-checklist.md | 2 +- scripts/launch-persona-smoke.js | 84 ++++++++++++++++++- scripts/production-launch-gate-test.js | 12 +++ scripts/production-launch-gate.js | 5 +- 6 files changed, 101 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 2276ff97..2b51e7a7 100644 --- a/README.md +++ b/README.md @@ -607,7 +607,7 @@ $env:DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres" npm run smoke:launch-persona ``` -`smoke:launch-persona` 会从普通学生、租户管理员、平台管理员三个视角调用真实 API,覆盖 SVIP 后刷题、收藏、错题复习入口、租户数据看板/主题/学生/销售转化、平台租户/套餐/审计入口和越权拒绝。它会写入少量 `launch_persona_smoke` 测试记录,生产只建议在灰度或演练租户运行。 +`smoke:launch-persona` 会从普通学生、租户管理员、平台管理员三个视角调用真实 API,默认使用 `LAUNCH_SMOKE_AUTH_MODE=app_session` 的 Bearer `tk_` session,不依赖旧 `x-user-id` 或平台本地 key;覆盖 SVIP 后刷题、收藏、错题复习入口、租户数据看板/主题/学生/销售转化、平台租户/套餐/审计入口和越权拒绝。它会写入少量 `launch_persona_smoke` 测试记录,生产只建议在灰度或演练租户运行。 Taro H5 发布产物守卫: diff --git a/docs/refactor/production-launch-evidence.template.json b/docs/refactor/production-launch-evidence.template.json index 3614c022..48516ba7 100644 --- a/docs/refactor/production-launch-evidence.template.json +++ b/docs/refactor/production-launch-evidence.template.json @@ -136,11 +136,12 @@ { "id": "api.launch-persona-smoke", "status": "pass", - "command": "npm run smoke:launch-persona > docs/refactor/launch-artifacts/launch-persona-smoke.log", + "command": "LAUNCH_SMOKE_AUTH_MODE=app_session 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", + "authMode": "app_session", "student": { "status": "pass", "result": { @@ -163,8 +164,8 @@ "status": "pass", "result": { "guards": { - "studentDashboardDenied": "TENANT_MEMBER_REQUIRED", - "crossTenantDenied": "TENANT_MEMBER_REQUIRED" + "studentDashboardDenied": "TENANT_ADMIN_REQUIRED", + "crossTenantDenied": "AUTH_TENANT_MISMATCH" } } }, diff --git a/docs/refactor/web-launch-acceptance-checklist.md b/docs/refactor/web-launch-acceptance-checklist.md index d21f1f9d..87c61dba 100644 --- a/docs/refactor/web-launch-acceptance-checklist.md +++ b/docs/refactor/web-launch-acceptance-checklist.md @@ -90,7 +90,7 @@ node scripts/taro-h5-release-guardrails-test.js --require-dist npm run smoke:launch-persona ``` -`smoke:launch-persona` 是真实 API 角色旅程烟测,必须进入生产上线证据:普通学生要能在 SVIP 权益下创建练习、答题、收藏题目、进入收藏复习和错题复习入口;租户管理员要能读取看板/主题/学生/销售转化并拒绝学生或跨租户访问;平台管理员要能读取租户/套餐/审计入口并拒绝学生访问平台后台。 +`smoke:launch-persona` 是真实 API 角色旅程烟测,必须进入生产上线证据;正式证据必须使用 `LAUNCH_SMOKE_AUTH_MODE=app_session`,通过 Bearer `tk_` session 验证身份,不使用旧 `x-user-id` 或平台本地 key。普通学生要能在 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;正式部署目录必须补齐。 diff --git a/scripts/launch-persona-smoke.js b/scripts/launch-persona-smoke.js index b4cee760..9453515a 100644 --- a/scripts/launch-persona-smoke.js +++ b/scripts/launch-persona-smoke.js @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; import { spawn } from 'node:child_process'; import fs from 'node:fs/promises'; import net from 'node:net'; @@ -10,6 +11,8 @@ const databaseUrl = process.env.DATABASE_URL || DEFAULT_DATABASE_URL; const outputDir = process.env.LAUNCH_SMOKE_OUTPUT_DIR || 'docs/refactor/launch-artifacts'; const startServer = boolEnv('LAUNCH_SMOKE_START_SERVER', !process.env.LAUNCH_SMOKE_API_BASE); const fixedPort = Number(process.env.LAUNCH_SMOKE_API_PORT || 0) || 0; +const authMode = normalizeAuthMode(process.env.LAUNCH_SMOKE_AUTH_MODE || 'app_session'); +const authSessionSecret = process.env.AUTH_SESSION_SECRET || 'development-session-secret-change-me'; const ids = { tenant: '00000000-0000-0000-0000-000000000001', @@ -24,6 +27,7 @@ const ids = { let apiBase = process.env.LAUNCH_SMOKE_API_BASE || process.env.API_BASE || ''; let serverProcess = null; let serverLogs = ''; +let authTokens = {}; function boolEnv(key, fallback) { const value = process.env[key]; @@ -31,6 +35,13 @@ function boolEnv(key, fallback) { return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase()); } +function normalizeAuthMode(value) { + const normalized = String(value || '').toLowerCase().replace(/[-_]/g, ''); + if (normalized === 'legacy' || normalized === 'headers') return 'legacy'; + if (normalized === 'appsession' || normalized === 'session' || normalized === 'tk') return 'app_session'; + return 'app_session'; +} + function nowIso() { return new Date().toISOString(); } @@ -92,6 +103,9 @@ async function startServerIfNeeded() { ...process.env, PORT: String(port), DATABASE_URL: databaseUrl, + AUTH_SESSION_SECRET: authSessionSecret, + ALLOW_LEGACY_AUTH_HEADERS: authMode === 'legacy' ? 'true' : 'false', + ALLOW_PLATFORM_ADMIN_KEY: authMode === 'legacy' ? 'true' : 'false', MAX_JSON_BODY_BYTES: process.env.MAX_JSON_BODY_BYTES || '1048576', MAX_IMPORT_JSON_BODY_BYTES: process.env.MAX_IMPORT_JSON_BODY_BYTES || '10485760', }, @@ -120,12 +134,15 @@ function buildUrl(pathname, query = {}) { } async function request(pathname, options = {}) { + const token = authTokenFor(options.persona || 'student'); + const useSession = authMode === 'app_session' && token; const response = await fetch(buildUrl(pathname, options.query), { method: options.method || 'GET', headers: { 'content-type': 'application/json', ...(options.tenantId === false ? {} : { 'x-tenant-id': options.tenantId || ids.tenant }), - ...(options.userId === false ? {} : { 'x-user-id': options.userId || ids.student }), + ...(useSession ? { authorization: `Bearer ${token}` } : {}), + ...(authMode === 'legacy' && options.userId !== false ? { 'x-user-id': options.userId || ids.student } : {}), ...(options.headers || {}), }, body: options.body ? JSON.stringify(options.body) : undefined, @@ -136,6 +153,55 @@ async function request(pathname, options = {}) { return payload; } +function createSessionToken() { + return `tk_${crypto.randomBytes(32).toString('base64url')}`; +} + +function hashSessionToken(token) { + return crypto.createHmac('sha256', authSessionSecret).update(token).digest('hex'); +} + +function authTokenFor(persona) { + if (authMode !== 'app_session') return ''; + return authTokens[persona] || authTokens.student || ''; +} + +async function createSmokeAuthSession(pool, persona, userId, tenantId = ids.tenant) { + if (authMode !== 'app_session') return ''; + const token = createSessionToken(); + await pool.query( + ` + insert into app_private.auth_sessions ( + tenant_id, user_id, token_hash, provider, expires_at, metadata + ) + values ( + $1, $2, $3, 'launch_persona_smoke', + now() + interval '2 hours', + $4::jsonb + ) + `, + [ + tenantId, + userId, + hashSessionToken(token), + JSON.stringify({ createdBy: 'launch-persona-smoke', persona, authMode }), + ], + ); + return token; +} + +async function createSmokeAuthSessions(pool) { + if (authMode !== 'app_session') { + authTokens = {}; + return; + } + authTokens = { + student: await createSmokeAuthSession(pool, 'student', ids.student, ids.tenant), + tenantAdmin: await createSmokeAuthSession(pool, 'tenantAdmin', ids.tenantAdmin, ids.tenant), + platformAdmin: await createSmokeAuthSession(pool, 'platformAdmin', ids.platformAdmin, ids.tenant), + }; +} + async function one(pool, sql, params = []) { const result = await pool.query(sql, params); return result.rows[0] || null; @@ -316,22 +382,26 @@ async function studentJourney(pool) { } async function tenantAdminJourney() { - const overview = await request('/api/tenant-admin/overview', { userId: ids.tenantAdmin }); + const overview = await request('/api/tenant-admin/overview', { persona: 'tenantAdmin', userId: ids.tenantAdmin }); const dashboard = await request('/api/tenant-admin/dashboard', { + persona: 'tenantAdmin', userId: ids.tenantAdmin, query: { timeRange: '7d', regionId: ids.region }, }); - const theme = await request('/api/tenant-admin/theme', { userId: ids.tenantAdmin }); + const theme = await request('/api/tenant-admin/theme', { persona: 'tenantAdmin', userId: ids.tenantAdmin }); const students = await request('/api/tenant-admin/students', { + persona: 'tenantAdmin', userId: ids.tenantAdmin, query: { limit: 10 }, }); const conversion = await request('/api/referral/conversion-report', { + persona: 'tenantAdmin', userId: ids.tenantAdmin, query: { limit: 10 }, }); const studentDenied = await request('/api/tenant-admin/dashboard', { expectStatus: 403 }); const crossTenantDenied = await request('/api/tenant-admin/dashboard', { + persona: 'tenantAdmin', tenantId: ids.partnerTenant, userId: ids.tenantAdmin, expectStatus: 403, @@ -360,24 +430,28 @@ async function tenantAdminJourney() { } async function platformAdminJourney() { - const headers = { 'x-platform-admin-key': process.env.PLATFORM_ADMIN_API_KEY || 'local-platform-admin-key' }; + const headers = authMode === 'legacy' ? { 'x-platform-admin-key': process.env.PLATFORM_ADMIN_API_KEY || 'local-platform-admin-key' } : {}; const overview = await request('/api/platform-admin/overview', { + persona: 'platformAdmin', tenantId: false, userId: false, headers, }); const tenants = await request('/api/platform-admin/tenants', { + persona: 'platformAdmin', tenantId: false, userId: false, headers, query: { limit: 10 }, }); const plans = await request('/api/platform-admin/plans', { + persona: 'platformAdmin', tenantId: false, userId: false, headers, }); const auditAlerts = await request('/api/platform-admin/audit-alerts', { + persona: 'platformAdmin', tenantId: false, userId: false, headers, @@ -450,12 +524,14 @@ async function runStep(name, fn) { async function main() { const pool = new pg.Pool({ connectionString: databaseUrl, max: 4 }); try { + await createSmokeAuthSessions(pool); await startServerIfNeeded(); if (!apiBase) throw new Error('LAUNCH_SMOKE_API_BASE is required when LAUNCH_SMOKE_START_SERVER=false.'); const report = { startedAt: nowIso(), finishedAt: '', apiBase, + authMode, databaseUrl: databaseUrl.replace(/:\/\/([^:]+):([^@]+)@/, '://$1:[redacted]@'), student: await runStep('student', () => studentJourney(pool)), tenantAdmin: await runStep('tenant-admin', tenantAdminJourney), diff --git a/scripts/production-launch-gate-test.js b/scripts/production-launch-gate-test.js index c210b4cd..3487c2a7 100644 --- a/scripts/production-launch-gate-test.js +++ b/scripts/production-launch-gate-test.js @@ -200,6 +200,18 @@ assert.ok( 'launch persona SVIP failure should be reported as a blocker', ); +const launchPersonaLegacyAuth = runGate(tempDir => { + const evidence = createEvidence(tempDir); + const item = evidence.checks.find(check => check.id === 'api.launch-persona-smoke'); + item.summary.authMode = 'legacy'; + return evidence; +}); +assert.notEqual(launchPersonaLegacyAuth.status, 0, 'launch persona smoke with legacy auth should fail launch gate'); +assert.ok( + launchPersonaLegacyAuth.payload.checks?.some(item => item.id === 'check.api.launch-persona-smoke.summary' && item.status === 'blocker'), + 'launch persona legacy auth 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 52cf2ac5..8c1db992 100644 --- a/scripts/production-launch-gate.js +++ b/scripts/production-launch-gate.js @@ -102,6 +102,7 @@ const gateChecks = [ commandIncludes: 'smoke:launch-persona', summary: { status: 'pass', + authMode: 'app_session', 'student.status': 'pass', 'student.result.entitlement.isSvip': true, 'student.result.practice.questionCount': { gte: 1 }, @@ -109,8 +110,8 @@ const gateChecks = [ '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'] }, + 'tenantAdmin.result.guards.studentDashboardDenied': { oneOf: ['TENANT_ADMIN_REQUIRED', 'TENANT_MEMBER_REQUIRED', 'TENANT_PERMISSION_DENIED', 'FORBIDDEN'] }, + 'tenantAdmin.result.guards.crossTenantDenied': { oneOf: ['AUTH_TENANT_MISMATCH', 'TENANT_MEMBER_REQUIRED', 'TENANT_PERMISSION_DENIED', 'FORBIDDEN'] }, 'platformAdmin.status': 'pass', 'platformAdmin.result.guards.studentPlatformDenied': { oneOf: ['PLATFORM_ADMIN_REQUIRED', 'FORBIDDEN'] }, },