flnxi-Wu/lib/__tests__/auth-dingtalk-token.test.ts
2026-06-02 10:16:28 +08:00

231 lines
7.1 KiB
TypeScript

jest.mock('@/lib/prisma', () => ({
prisma: {
systemSettings: {
findFirst: jest.fn(),
},
user: {
findUnique: jest.fn(),
},
},
}))
jest.mock('@/lib/dingtalk-login-token', () => ({
consumeDingtalkLoginToken: jest.fn(),
}))
jest.mock('@/lib/system-init', () => ({
isInitialAdminPasswordChangeRequired: jest.fn(),
}))
jest.mock('bcryptjs', () => ({
compare: jest.fn(),
}))
import { authOptions } from '../auth'
import { resetAuthRateLimitForTests } from '../auth-rate-limit'
import { consumeDingtalkLoginToken } from '../dingtalk-login-token'
import { isInitialAdminPasswordChangeRequired } from '../system-init'
import { prisma } from '../prisma'
import bcrypt from 'bcryptjs'
const mockConsumeDingtalkLoginToken = consumeDingtalkLoginToken as jest.MockedFunction<typeof consumeDingtalkLoginToken>
const mockIsInitialAdminPasswordChangeRequired = isInitialAdminPasswordChangeRequired as jest.MockedFunction<typeof isInitialAdminPasswordChangeRequired>
function getDingtalkProvider() {
const provider = authOptions.providers.find((item: any) => item.id === 'credentials') as any
if (!provider) {
throw new Error('credentials provider missing')
}
return provider
}
describe('auth dingtalk-token provider', () => {
beforeEach(() => {
jest.clearAllMocks()
resetAuthRateLimitForTests()
delete process.env.AUTH_LOGIN_IP_MAX_ATTEMPTS
delete process.env.AUTH_LOGIN_IP_WINDOW_MS
delete process.env.AUTH_LOGIN_ACCOUNT_MAX_FAILURES
delete process.env.AUTH_LOGIN_ACCOUNT_LOCK_MS
;(prisma.systemSettings.findFirst as jest.Mock).mockResolvedValue(null)
mockIsInitialAdminPasswordChangeRequired.mockResolvedValue(false)
})
it('使用一次性钉钉票据登录并返回会话用户', async () => {
mockConsumeDingtalkLoginToken.mockResolvedValue({
id: 'user-1',
email: 'user@example.com',
username: '张三',
role: 'USER',
isSuperAdmin: false,
avatar: null,
isActive: true,
})
const user = await getDingtalkProvider().options.authorize({ dingtalkToken: 'one-time-token' })
expect(mockConsumeDingtalkLoginToken).toHaveBeenCalledWith('one-time-token', 'login')
expect(user).toEqual(expect.objectContaining({
id: 'user-1',
email: 'user@example.com',
name: '张三',
role: 'USER',
}))
expect(user).toEqual(expect.objectContaining({
mustChangePassword: false,
}))
})
it('拒绝无效或已消费的钉钉票据', async () => {
mockConsumeDingtalkLoginToken.mockResolvedValue(null)
await expect(getDingtalkProvider().options.authorize({ dingtalkToken: 'bad-token' }))
.rejects
.toThrow('钉钉登录凭证已失效')
})
it('拒绝已禁用用户使用钉钉票据登录', async () => {
mockConsumeDingtalkLoginToken.mockResolvedValue({
id: 'user-disabled',
email: 'disabled@example.com',
username: '禁用用户',
role: 'USER',
isSuperAdmin: false,
avatar: null,
isActive: false,
})
await expect(getDingtalkProvider().options.authorize({ dingtalkToken: 'disabled-token' }))
.rejects
.toThrow('账号未激活或已被禁用')
})
it('账号密码登录失败时不暴露账号是否存在', async () => {
;(prisma.user.findUnique as jest.Mock).mockResolvedValue(null)
await expect(
getDingtalkProvider().options.authorize({
email: 'missing@example.com',
password: 'plain-password',
}, {
headers: new Headers({ 'x-forwarded-for': '127.0.0.1' }),
})
)
.rejects
.toThrow('邮箱或密码错误')
})
it('同一账号连续失败后会临时锁定', async () => {
process.env.AUTH_LOGIN_ACCOUNT_MAX_FAILURES = '2'
process.env.AUTH_LOGIN_ACCOUNT_LOCK_MS = '60000'
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
id: 'user-locked',
email: 'locked@example.com',
username: '锁定用户',
password: 'hashed-password',
role: 'USER',
isSuperAdmin: false,
avatar: null,
isActive: true,
})
;(bcrypt.compare as jest.Mock).mockResolvedValue(false)
await expect(getDingtalkProvider().options.authorize({
email: 'locked@example.com',
password: 'wrong-password',
}, { headers: new Headers({ 'x-forwarded-for': '127.0.0.2' }) }))
.rejects
.toThrow('邮箱或密码错误')
await expect(getDingtalkProvider().options.authorize({
email: 'locked@example.com',
password: 'wrong-password',
}, { headers: new Headers({ 'x-forwarded-for': '127.0.0.2' }) }))
.rejects
.toThrow('邮箱或密码错误')
await expect(getDingtalkProvider().options.authorize({
email: 'locked@example.com',
password: 'right-password',
}, { headers: new Headers({ 'x-forwarded-for': '127.0.0.2' }) }))
.rejects
.toThrow('该账号登录失败次数过多')
})
it('同一 IP 短时间登录尝试过多会被限制', async () => {
process.env.AUTH_LOGIN_IP_MAX_ATTEMPTS = '2'
;(prisma.user.findUnique as jest.Mock).mockResolvedValue(null)
await expect(getDingtalkProvider().options.authorize({
email: 'a@example.com',
password: 'wrong-password',
}, { headers: new Headers({ 'x-forwarded-for': '127.0.0.3' }) }))
.rejects
.toThrow('邮箱或密码错误')
await expect(getDingtalkProvider().options.authorize({
email: 'b@example.com',
password: 'wrong-password',
}, { headers: new Headers({ 'x-forwarded-for': '127.0.0.3' }) }))
.rejects
.toThrow('邮箱或密码错误')
await expect(getDingtalkProvider().options.authorize({
email: 'c@example.com',
password: 'wrong-password',
}, { headers: new Headers({ 'x-forwarded-for': '127.0.0.3' }) }))
.rejects
.toThrow('登录尝试过于频繁')
})
it('拒绝已禁用用户使用账号密码登录,即使该用户是普通管理员', async () => {
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
id: 'admin-disabled',
email: 'disabled-admin@example.com',
username: '禁用管理员',
password: 'hashed-password',
role: 'ADMIN',
isSuperAdmin: false,
avatar: null,
isActive: false,
})
;(bcrypt.compare as jest.Mock).mockResolvedValue(true)
await expect(
getDingtalkProvider().options.authorize({
email: 'disabled-admin@example.com',
password: 'plain-password',
})
)
.rejects
.toThrow('账号未激活或已被禁用')
})
it('旧 JWT 会话遇到已禁用用户时会在下次校验失效', async () => {
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
id: 'user-disabled',
role: 'USER',
isSuperAdmin: false,
isActive: false,
avatar: null,
})
await expect(
(authOptions.callbacks?.jwt as any)({
token: { id: 'user-disabled', avatarUpdatedAt: Date.now() },
})
)
.rejects
.toThrow('账号不存在或已被禁用')
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: 'user-disabled' },
select: {
avatar: true,
role: true,
isSuperAdmin: true,
isActive: true,
},
})
})
})