|
|
@@ -46,10 +46,18 @@ beforeEach(() => {
|
|
|
global.uni._storage = {}
|
|
|
global.uni.request.mockReset()
|
|
|
global.uni.showToast.mockReset()
|
|
|
+ global.uni.reLaunch.mockReset()
|
|
|
+ global.uni.login.mockReset()
|
|
|
+ global.getCurrentPages.mockReset()
|
|
|
+ global.getCurrentPages.mockReturnValue([{ route: 'pages/index-home/index' }])
|
|
|
global.uni.getAccountInfoSync.mockReset()
|
|
|
global.uni.getAccountInfoSync.mockReturnValue({
|
|
|
miniProgram: { envVersion: 'develop' }
|
|
|
})
|
|
|
+ // api.js 的 3 秒请求去重缓存是模块级单例,跨用例残留会让后续用例拿到上一个用例缓存的
|
|
|
+ // Promise(同 URL+method+body),表现为「本该 401 却是 200」「lastRequest() 为 undefined」。
|
|
|
+ // 每个用例前显式清空,保证用例间隔离。
|
|
|
+ api.clearApiDedup()
|
|
|
})
|
|
|
|
|
|
/** Capture the last uni.request call options */
|
|
|
@@ -99,10 +107,16 @@ describe('auth API', () => {
|
|
|
expect(lastRequest().data.code).toBe('wxcode')
|
|
|
})
|
|
|
|
|
|
- test('autoLogin sends POST with openid', async () => {
|
|
|
- mockSuccess({ code: 200 })
|
|
|
- await api.autoLogin('openid_xxx')
|
|
|
- expect(lastRequest().data.openid).toBe('openid_xxx')
|
|
|
+ // 已废弃:后端 /api/auth/auto-login 已改为 410 Gone(裸 openid 换 token 属越权漏洞)。
|
|
|
+ // 导出仅为排查历史调用保留,不得在新代码中使用;续期请走 silentRelogin()。
|
|
|
+ test('autoLogin (deprecated) still targets /api/auth/auto-login', async () => {
|
|
|
+ mockSuccess({ code: 410, message: '该接口已废弃,请改用 /api/auth/silent-login' })
|
|
|
+ await expect(api.autoLogin('openid_xxx')).rejects.toEqual(
|
|
|
+ { code: 410, message: '该接口已废弃,请改用 /api/auth/silent-login' }
|
|
|
+ )
|
|
|
+ const opts = lastRequest()
|
|
|
+ expect(opts.url).toMatch(/\/api\/auth\/auto-login$/)
|
|
|
+ expect(opts.data.openid).toBe('openid_xxx')
|
|
|
})
|
|
|
|
|
|
test('setPassword and verifyPassword send correct endpoints', async () => {
|
|
|
@@ -166,11 +180,22 @@ describe('auth API', () => {
|
|
|
jest.useRealTimers()
|
|
|
})
|
|
|
|
|
|
- test('401 with cached openid silently re-logins and retries original request', async () => {
|
|
|
+ // 静默续期 = uni.login() 取微信签发的一次性 code → /api/auth/silent-login(服务端 code2Session)。
|
|
|
+ // 本地 openid 只作为「本设备曾登录过」的触发条件,不作为凭证上送。
|
|
|
+ function mockUniLoginCode(code) {
|
|
|
+ global.uni.login.mockImplementation((opts) => {
|
|
|
+ process.nextTick(() => {
|
|
|
+ if (opts.success) opts.success({ code: code || 'wxcode' })
|
|
|
+ })
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ test('401 silently re-logins via wx.login + code2Session and retries original request', async () => {
|
|
|
global.uni.reLaunch.mockReset()
|
|
|
global.uni.showToast.mockReset()
|
|
|
global.uni._storage.openid = 'openid_abc'
|
|
|
global.uni._storage.token = 'expired-token'
|
|
|
+ mockUniLoginCode('fresh-wxcode')
|
|
|
mockSequence([
|
|
|
{ data: { code: 401, message: 'Token无效或已过期', data: null } },
|
|
|
{ data: { code: 200, data: { token: 'new-jwt', userId: 42, role: 'parent', familyId: 1, openid: 'openid_abc' } } },
|
|
|
@@ -179,17 +204,86 @@ describe('auth API', () => {
|
|
|
const res = await api.verifyToken()
|
|
|
expect(res.data.userId).toBe(42)
|
|
|
expect(global.uni._storage.token).toBe('new-jwt')
|
|
|
+ // 续期必须走 code2Session,且不得把 openid 当凭证发送
|
|
|
+ const calls = global.uni.request.mock.calls.map((c) => c[0])
|
|
|
+ const renewReq = calls.find((c) => /\/api\/auth\/silent-login/.test(c.url))
|
|
|
+ expect(renewReq).toBeDefined()
|
|
|
+ expect(renewReq.data.code).toBe('fresh-wxcode')
|
|
|
+ expect(renewReq.data.openid).toBeUndefined()
|
|
|
+ // 已废弃的裸 openid 换 token 接口不得被调用
|
|
|
+ expect(calls.find((c) => /\/api\/auth\/auto-login/.test(c.url))).toBeUndefined()
|
|
|
expect(global.uni.reLaunch).not.toHaveBeenCalled()
|
|
|
expect(global.uni.showToast).not.toHaveBeenCalled()
|
|
|
})
|
|
|
|
|
|
- test('401 with cached openid falls back to login when auto-login fails', async () => {
|
|
|
+ test('401 retry does not duplicate query params in the replayed request URL', async () => {
|
|
|
+ global.uni._storage.openid = 'openid_abc'
|
|
|
+ global.uni._storage.token = 'expired-token'
|
|
|
+ mockUniLoginCode('fresh-wxcode')
|
|
|
+ mockSequence([
|
|
|
+ { data: { code: 401, message: 'Token无效或已过期', data: null } },
|
|
|
+ { data: { code: 200, data: { token: 'new-jwt', userId: 42, role: 'parent', familyId: 1, openid: 'openid_abc' } } },
|
|
|
+ { data: { code: 200, data: { ok: true } } }
|
|
|
+ ])
|
|
|
+ // publishPackage 通过 options 传 query(?publish=true),是最容易暴露重复拼接的调用形态
|
|
|
+ await api.publishPackage(7, true)
|
|
|
+ const calls = global.uni.request.mock.calls.map((c) => c[0])
|
|
|
+ const hits = calls.filter((c) => /\/api\/packages\/7\/publish/.test(c.url))
|
|
|
+ expect(hits.length).toBe(2) // 原始请求 1 次 + 401 续期后重放 1 次
|
|
|
+ // 修复前:_handle401 收到的是已拼过 query 的 url,重放时 options 又拼一次 → ?publish=true&publish=true
|
|
|
+ hits.forEach((c) => {
|
|
|
+ expect(c.url).toBe(c.url.split('?')[0] + '?publish=true')
|
|
|
+ expect((c.url.match(/publish=/g) || []).length).toBe(1)
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ test('401 during app launch window clears auth but does NOT navigate to login subpackage', async () => {
|
|
|
+ jest.useFakeTimers()
|
|
|
+ global.uni._storage.openid = 'openid_abc'
|
|
|
+ global.uni._storage.token = 'expired-token'
|
|
|
+ // 仍停在 splash:pages/login 是分包,此刻尚未下载,
|
|
|
+ // reLaunch 过去会报 'Page "pages/login/login" has not been registered yet'
|
|
|
+ global.getCurrentPages.mockReturnValue([{ route: 'pages/splash/index' }])
|
|
|
+ mockUniLoginCode()
|
|
|
+ mockSequence([
|
|
|
+ { data: { code: 401, message: 'Token无效或已过期', data: null } },
|
|
|
+ { data: { code: 500, message: '用户不存在,请先登录', data: null } }
|
|
|
+ ])
|
|
|
+ await expect(api.verifyToken()).rejects.toEqual({ code: 401, message: 'Token无效或已过期', data: null })
|
|
|
+ jest.runAllTimers()
|
|
|
+ // 登录态必须清掉
|
|
|
+ expect(global.uni._storage.token).toBeUndefined()
|
|
|
+ // 但启动窗口内绝不跳登录页,交由 splash 落地首页(主包)
|
|
|
+ expect(global.uni.reLaunch).not.toHaveBeenCalled()
|
|
|
+ jest.useRealTimers()
|
|
|
+ })
|
|
|
+
|
|
|
+ test('401 during app launch window (no page registered yet) also skips navigation', async () => {
|
|
|
jest.useFakeTimers()
|
|
|
global.uni._storage.openid = 'openid_abc'
|
|
|
global.uni._storage.token = 'expired-token'
|
|
|
+ // 首屏尚未注册,getCurrentPages() 为空
|
|
|
+ global.getCurrentPages.mockReturnValue([])
|
|
|
+ mockUniLoginCode()
|
|
|
mockSequence([
|
|
|
{ data: { code: 401, message: 'Token无效或已过期', data: null } },
|
|
|
- { data: { code: 401, message: '用户不存在', data: null } }
|
|
|
+ { data: { code: 500, message: '用户不存在,请先登录', data: null } }
|
|
|
+ ])
|
|
|
+ await expect(api.verifyToken()).rejects.toEqual({ code: 401, message: 'Token无效或已过期', data: null })
|
|
|
+ jest.runAllTimers()
|
|
|
+ expect(global.uni._storage.token).toBeUndefined()
|
|
|
+ expect(global.uni.reLaunch).not.toHaveBeenCalled()
|
|
|
+ jest.useRealTimers()
|
|
|
+ })
|
|
|
+
|
|
|
+ test('401 falls back to login page when silent re-login is refused', async () => {
|
|
|
+ jest.useFakeTimers()
|
|
|
+ global.uni._storage.openid = 'openid_abc'
|
|
|
+ global.uni._storage.token = 'expired-token'
|
|
|
+ mockUniLoginCode()
|
|
|
+ mockSequence([
|
|
|
+ { data: { code: 401, message: 'Token无效或已过期', data: null } },
|
|
|
+ { data: { code: 500, message: '用户不存在,请先登录', data: null } }
|
|
|
])
|
|
|
await expect(api.verifyToken()).rejects.toEqual({ code: 401, message: 'Token无效或已过期', data: null })
|
|
|
jest.runAllTimers()
|
|
|
@@ -198,6 +292,51 @@ describe('auth API', () => {
|
|
|
jest.useRealTimers()
|
|
|
})
|
|
|
|
|
|
+ test('401 with no cached openid (never logged in on this device) goes straight to login', async () => {
|
|
|
+ jest.useFakeTimers()
|
|
|
+ global.uni._storage.token = 'expired-token'
|
|
|
+ mockSequence([
|
|
|
+ { data: { code: 401, message: 'Token无效或已过期', data: null } }
|
|
|
+ ])
|
|
|
+ await expect(api.verifyToken()).rejects.toEqual({ code: 401, message: 'Token无效或已过期', data: null })
|
|
|
+ // 本设备从未登录过 → 不应调用 uni.login 浪费一次 code2Session
|
|
|
+ expect(global.uni.login).not.toHaveBeenCalled()
|
|
|
+ jest.runAllTimers()
|
|
|
+ expect(global.uni.reLaunch).toHaveBeenCalledWith({ url: '/pages/login/login' })
|
|
|
+ jest.useRealTimers()
|
|
|
+ })
|
|
|
+
|
|
|
+ test('silentRelogin returns null when wx.login yields no code', async () => {
|
|
|
+ global.uni._storage.openid = 'openid_abc'
|
|
|
+ global.uni.login.mockImplementation((opts) => {
|
|
|
+ process.nextTick(() => { if (opts.success) opts.success({}) })
|
|
|
+ })
|
|
|
+ await expect(api.silentRelogin()).resolves.toBeNull()
|
|
|
+ expect(global.uni.request).not.toHaveBeenCalled()
|
|
|
+ })
|
|
|
+
|
|
|
+ test('silentRelogin returns null when wx.login fails', async () => {
|
|
|
+ global.uni._storage.openid = 'openid_abc'
|
|
|
+ global.uni.login.mockImplementation((opts) => {
|
|
|
+ process.nextTick(() => { if (opts.fail) opts.fail({ errMsg: 'login:fail' }) })
|
|
|
+ })
|
|
|
+ await expect(api.silentRelogin()).resolves.toBeNull()
|
|
|
+ expect(global.uni.request).not.toHaveBeenCalled()
|
|
|
+ })
|
|
|
+
|
|
|
+ test('silentRelogin does not issue a renew request on 401 (no recursive renewal)', async () => {
|
|
|
+ global.uni._storage.openid = 'openid_abc'
|
|
|
+ global.uni._storage.token = 'expired-token'
|
|
|
+ mockUniLoginCode()
|
|
|
+ // 续期接口自身返回 401:必须直接失败,不能再触发一次 silentRelogin,否则会递归续期/误清登录态
|
|
|
+ mockSuccess({ code: 401, message: 'Token无效或已过期', data: null })
|
|
|
+ await expect(api.silentRelogin()).resolves.toBeNull()
|
|
|
+ expect(global.uni.request.mock.calls.length).toBe(1)
|
|
|
+ // 续期失败不得清空登录态(那是「主动登出」才做的事)
|
|
|
+ expect(global.uni._storage.token).toBe('expired-token')
|
|
|
+ expect(global.uni.reLaunch).not.toHaveBeenCalled()
|
|
|
+ })
|
|
|
+
|
|
|
test('Authorization header includes Bearer token', async () => {
|
|
|
mockSuccess({ code: 200 })
|
|
|
global.uni._storage.token = 'my-jwt-token'
|