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