Files
danyang-match/ui-tests/miniapp-auth-contracts.mjs
T
WorkBuddy 825fd41ee4 fix(auth): 修复刷新令牌链路三层断点,并把 121 处裸异常收敛为携带 HTTP 语义的异常
用户在微信开发者工具里看到首页全站 401,追下去发现「刷新」这条路整条是断的,
且断在三个互不相干的地方 —— 每修好一层,下一层才显形:

1) 前端漏传 userId → /auth/refresh 直接 400(上一笔 63c18d8 已修)
2) 后端把 refresh token 交给 validateToken 校验,而那是用 access 密钥验签的,
   refresh token 由 refresh 密钥签发 → 必然失败。这是本次的核心修复。
3) 失败被抛成裸 RuntimeException → HTTP 500,客户端当作服务端故障重试。
4) 换发出来的新 refresh token 与旧 token **逐字节相同** —— JWT 的 iat 只有秒级精度、
   且未设 jti,同一用户同一秒内签发的令牌完全一致,「刷新即轮换、旧令牌作废」
   这条安全属性形同不存在(Redis 比对旧值自然相等)。三处签发补 UUID jti。

第 2 层此前被两层掩盖:前端漏传 userId 使请求先 400 走不到验签;且全仓
没有任何 /auth/refresh 用例。第 4 层则只在「同一秒内签发两次」时才暴露,
是新增 E2E 用例(换发后立刻用旧令牌再刷一次)把它逼出来的。

同时修复审计中发现的同族问题:
- AnalyticsService 用的是 shared.exception.BusinessException,而全局处理器只映射
  嵌套类 GlobalExceptionHandler.BusinessException —— 该异常无任何 handler,直落 500。
  现让旧类继承被映射的类,从结构上杜绝「未映射的业务异常静默 500」。
- 小程序 authStore 读 result.needPhone,后端返回 isNewUser → `!undefined === true`,
  每个用户一登录就被标记成已绑手机号。同族的第三个字段名不一致缺陷。
- 小程序 api.js 的 401 分支会无条件触发 onSessionExpired→refresh;后端把刷新失败
  语义修正为 401 后,这条路径会自我递归。补 /auth/* 守卫。
- 登录页只认 instanceof Error,把服务端文案(如「账号已被封禁」)丢了。

后端异常收敛(121 处 → 0):
- 业务拒绝改抛 BusinessException + ApiResponseCode(404 不存在 / 403 无权与状态不允许 /
  409 冲突重复 / 400 参数非法),文案一字未改,避免测试与前端文案契约漂移
- 真正的服务端故障(加解密、文件头、上传、签名、实名 Provider)改为显式
  ServerErrorException:仍是 500,但意图明确、ERROR 记全栈、不向客户端泄露内部细节
- 用带显式映射表的脚本执行(fail-closed,遇未知文案即中止),不用启发式规则

守卫(先有判别力再谈全绿):
- 新增 JwtTokenServiceRefreshTokenTest:钉住「refresh 用 refresh 密钥验签」与
  「两类令牌不得互相顶替」(含两把密钥被配成同值时的 type 声明兜底),
  以及「同一秒内签发的两个令牌必须逐字节不同」
- 新增 RuntimeExceptionThrowGuardTest:扫源码断言 main 零裸抛(无白名单副本)
- GlobalExceptionHandlerTest 补三条:别名的实例必须按其业务码回 403 而非 500、
  无法识别的字符串码 fail-closed 到 500(不得退化成无信息的 400)、
  以及继承关系守卫(把别名类改回 RuntimeException 会静默恢复该缺陷,只有它能咬住)
- 新增 tests/e2e/auth_refresh_test.py:唯一覆盖 /auth/refresh 的用例,
  断言换发的 accessToken 真能过鉴权、旧令牌轮换失效、无效凭据 401 而非 500
- http_semantics_test.py 补「Service 层拒绝必须是 4xx 而非 5xx」
- miniapp-auth-contracts.mjs 补 isNewUser 映射(双向)与 /auth/* 401 不递归

验证:
- 定向后端测试 9/9
- 全量后端测试 113 个测试类、0 失败、BUILD SUCCESS
- 本机真实栈 E2E 72 passed(含 7 个新增刷新用例)
- miniapp tsc 0 错误、UI 契约门控 18/18
- 负向注入两处精准变红(RuntimeExceptionThrowGuardTest 与
  JwtTokenServiceRefreshTokenTest 各 1 例),BUILD FAILURE,
  注入前后工作区 SHA256 逐字节一致(零污染)
2026-09-22 21:41:30 +08:00

156 lines
5.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
/**
* 小程序鉴权契约守卫
*
* 覆盖此前只在真机/开发者工具上才会暴露的缺陷(2026-09-22 首页 401 事故及其延伸):
* 1. hydrate:没有 access token 时不得恢复登录态。
* 2. 登录:后端返回字段是 accessToken(不是 token),写错则 token 永远进不了
* storage → 每个请求都不带 Authorization → 全站 401。
* 3. 登录:后端返回 isNewUser(不是 needPhone),写错则 `!undefined === true`
* → 每个用户一登录就被标记成「已绑手机号」。
* 4. 刷新:/auth/refresh 需要 refreshToken(userId 可选,由令牌 subject 自证)。
* 5. 递归:/auth/* 自身返回 401 时不得触发 onSessionExpired —— 否则
* onSessionExpired → /auth/refresh → 401 → onSessionExpired 自我递归。
*
* 这些都是 `.js` 层缺陷,tsc 管不到(类型声明写的是 any),只有行为断言能兜住。
*/
const removed = []
const storage = {}
globalThis.wx = {
getStorageSync(key) {
if (key in storage) return storage[key]
if (key === 'isLoggedIn') return { isLoggedIn: true, userId: 'stale-user' }
return ''
},
setStorageSync(key, value) {
storage[key] = value
},
removeStorageSync(key) {
removed.push(key)
delete storage[key]
},
}
const require = createRequire(import.meta.url)
// ---- 1. hydrate:无 token 不得恢复登录态 ----
const authStore = require('../miniapp/stores/authStore.js')
authStore.hydrate()
assert.equal(authStore.state.isLoggedIn, false, '没有 access token 时不得恢复登录态')
assert.ok(removed.includes('isLoggedIn'), '应清理失真的登录快照')
const api = require('../miniapp/utils/api.js')
// ---- 2. 登录:必须持久化 accessToken,并按 isNewUser 判定手机号绑定 ----
api.post = async () => ({
accessToken: 'ACC-1',
refreshToken: 'REF-1',
userId: 'u-1',
expiresIn: 7200,
isNewUser: true, // 后端语义:true = 还没有手机号
})
assert.ok(authStore.loginWithWechat, '登录方法必须存在')
await authStore.loginWithWechat('wx-code')
assert.equal(
storage.accessToken,
'ACC-1',
'登录后必须把后端返回的 accessToken 写入 storage(字段名写成 token 会落空 → 全站 401)',
)
assert.equal(storage.refreshToken, 'REF-1', '登录后必须写入 refreshToken')
assert.equal(
authStore.state.phoneBound,
false,
'isNewUser=true 表示尚未绑定手机号,phoneBound 必须为 false(读 needPhone 会得到 !undefined === true)',
)
// 反向:已绑号用户必须被标成已绑定,防止把 phoneBound 写死成 false 也能过
const boundStore = require('../miniapp/stores/authStore.js')
api.post = async () => ({
accessToken: 'ACC-9',
refreshToken: 'REF-9',
userId: 'u-9',
expiresIn: 7200,
isNewUser: false,
})
await boundStore.loginWithWechat('wx-code-2')
assert.equal(
boundStore.state.phoneBound,
true,
'isNewUser=false 表示已绑手机号,phoneBound 必须为 true(映射方向不得反)',
)
// ---- 3. 刷新:必须带 refreshToken + userId,并持久化新的 accessToken ----
// 期望值从当前登录态动态取(上面做过两次登录),避免硬编码与执行顺序耦合。
// 前置断言保证动态期望不是「两边都是 undefined」的假通过。
const expectedRefreshToken = storage.refreshToken
const expectedUserId = authStore.state.userId
assert.ok(expectedRefreshToken, '前置:storage 中必须有 refreshToken')
assert.ok(expectedUserId, '前置:登录态中必须有 userId')
let refreshBody = null
api.post = async (url, body) => {
if (url === '/auth/refresh') {
refreshBody = body
return { accessToken: 'ACC-2', refreshToken: 'REF-2', expiresIn: 7200 }
}
return {}
}
await authStore.refresh()
assert.ok(refreshBody, '刷新必须发起 /auth/refresh 请求')
assert.equal(refreshBody.refreshToken, expectedRefreshToken, '刷新请求必须携带 refreshToken')
assert.equal(
refreshBody.userId,
expectedUserId,
'刷新请求必须携带 userId(后端会校验它与令牌主体一致,串号需被拦下)',
)
assert.equal(refreshBody.userId, authStore.state.userId, 'userId 必须取自当前登录态')
assert.equal(storage.accessToken, 'ACC-2', '刷新成功后必须持久化新的 accessToken')
// ---- 4. /auth/* 返回 401 不得递归触发 Session 失效 ----
delete api.post // 解除上面的桩,回到真实的 request 路径
delete api.get
let sessionExpiredCalls = 0
api.onSessionExpired = () => {
sessionExpiredCalls += 1
}
globalThis.wx.request = (opts) => {
opts.success({
statusCode: 401,
data: { success: false, code: 'UNAUTHORIZED', message: 'Refresh Token 无效或已过期' },
})
}
await api.post('/auth/refresh', { refreshToken: 'dead' }).then(
() => {
throw new Error('401 必须 reject')
},
(err) => {
assert.equal(err.code, 'UNAUTHORIZED', '应把服务端业务码透传给调用方')
},
)
assert.equal(
sessionExpiredCalls,
0,
'/auth/* 返回 401 不得触发 onSessionExpired —— 否则刷新与失效互相递归,页面会无限打 /auth/refresh',
)
assert.equal(storage.accessToken, 'ACC-2', '/auth/* 的 401 不得擅自清 token,清理由 AuthStore 判定')
// 对照:业务端点的 401 仍必须触发 Session 失效流程(守卫不得把正常路径一起挡掉)
await api.get('/home').then(
() => {
throw new Error('401 必须 reject')
},
() => {},
)
assert.equal(
sessionExpiredCalls,
1,
'业务端点 401 必须触发 onSessionExpired(否则不会尝试刷新,用户被直接踢出)',
)
assert.equal(storage.accessToken, undefined, '业务端点 401 应清理本地 token')
console.log('Miniapp auth contracts passed')