Commit 9742b99a by DaiJiezhang

test: 翻译认证与账号管理的 25 条单元测试

对应 Java 侧三个测试类,逐条保留原有断言意图:
- InMemoryLoginAttemptGuardTest  9 条 -> login-attempt.service.spec.ts
- AuthServiceTest                9 条 -> auth.service.spec.ts
- SystemUserAdminServiceTest     7 条 -> system-user.service.spec.ts

这些用例守的是接口对拍看不到的内部行为:

登录防枚举
账号不存在、已停用、未设密码三种情况都必须真的执行一次密码比对,
且所有失败路径的耗时补齐到同一下限。从响应上看这三种情况都是同一句
「账号或密码错误」,只有单元测试能验证内部确实付出了相同代价。
被锁定的账号还必须在查数据库之前就被拒绝,避免查库耗时泄露账号是否存在。

会话失效
改动角色、状态或页面权限后,对方令牌里的 auth_version 必须失配从而立即失效;
提交内容与原值一致时则保留原登录,不无谓地踢人下线。四条用例分别覆盖。
漏掉自增,权限修改要等对方令牌自然过期(8 小时)才生效,期间界面无任何异常。

两处实现差异(有意,已在测试注释中说明)
- Java 用 8 线程并发验证计数不丢不重;Node 单线程执行同步方法本无竞态,
  改为验证等价语义:19 次不锁、第 20 次锁,计数既不多算也不少算。
- bcrypt 是原生模块,其导出属性无法 spyOn,改用模块级 mock。

时间用可控时钟推进,不真实等待 24 小时。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent f86e1024
/**
* 文件用途(白话):单元测试配置。
* 关联文件:各模块下的 spec 测试文件、tsconfig.json。
* 测试文件与被测源码同目录,以 .spec.ts 结尾,与 Java 侧 src/test 的用例一一对应。
*/
module.exports = {
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: 'src',
testRegex: '.*\.spec\.ts$',
transform: { '^.+\.ts$': ['ts-jest', { tsconfig: { experimentalDecorators: true, emitDecoratorMetadata: true, esModuleInterop: true, target: 'ES2021' } }] },
testEnvironment: 'node',
};
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -5,7 +5,10 @@
"description": "学有为资产后台 NestJS 后端(迁移自 Java Spring Boot)",
"scripts": {
"db:pull": "prisma db pull",
"db:generate": "prisma generate"
"db:generate": "prisma generate",
"test": "jest",
"test:watch": "jest --watch",
"start": "ts-node src/main.ts"
},
"engines": {
"node": ">=20"
......@@ -15,10 +18,13 @@
"@types/bcrypt": "^6.0.0",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^26.2.0",
"dotenv": "^17.4.2",
"jest": "^30.4.2",
"prisma": "^7.9.1",
"ts-jest": "^29.4.12",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "~5.7"
......
/**
* 文件用途(白话):验证登录的安全判定,逐条对应 Java 侧 AuthServiceTest 的 9 个用例。
* 关联文件:auth.service.ts、backend/src/test/.../AuthServiceTest.java。
*
* 这些用例守的是两件接口对拍看不见的事:
* - 无论账号是否存在、是否停用、是否有密码,都必须真的跑一次密码比对(否则耗时差可用来枚举账号)
* - 被锁定的账号在查数据库之前就被拒绝
* 从响应上看这些情况都是同一句「账号或密码错误」,只有单元测试能验证内部确实这么做了。
*/
// bcrypt 是原生模块,其导出属性不可被 spyOn,只能整体替换
jest.mock('bcrypt', () => ({ compare: jest.fn(), hash: jest.fn() }));
import * as bcrypt from 'bcrypt';
import { AuthService } from './auth.service';
import { PagePermissionService } from './page-permission.service';
import { LoginThrottledException } from './login-attempt.service';
import { UnauthorizedException } from '../common/business.exception';
const STORED_HASH = '$2a$12$stored.hash.placeholder.value.for.unit.test.only';
function makeUser(status: string, passwordHash: string | null) {
return { id: 7n, username: 'real_user', role_code: 'OPERATIONS', status, password_hash: passwordHash, page_permissions: null, auth_version: 1 };
}
describe('AuthService', () => {
let prisma: any;
let tokens: any;
let loginAttempts: any;
let service: AuthService;
let response: any;
let compareSpy: jest.Mock;
/** 补时下限设为 0,多数用例只验证判定逻辑,不必真的等待。 */
const build = async (failureMinMillis = 0) => {
process.env.XYW_AUTH_LOGIN_FAILURE_MIN_MILLIS = String(failureMinMillis);
const s = new AuthService(prisma, new PagePermissionService(), loginAttempts, tokens);
// 占位哈希的生成本身要跑一次 bcrypt(cost 12),测试里直接给定,避免每个用例多等 200ms
(s as any).timingEqualizerHash = STORED_HASH;
return s;
};
beforeEach(async () => {
prisma = { as_system_user: { findFirst: jest.fn(), findUnique: jest.fn() } };
tokens = { issue: jest.fn(), clear: jest.fn() };
loginAttempts = { assertAllowed: jest.fn(), recordFailure: jest.fn(), recordSuccess: jest.fn() };
response = {};
compareSpy = bcrypt.compare as unknown as jest.Mock;
compareSpy.mockReset();
compareSpy.mockResolvedValue(false);
service = await build();
});
afterEach(() => jest.clearAllMocks());
it('账号不存在时仍然执行一次密码比对', async () => {
prisma.as_system_user.findFirst.mockResolvedValue(null);
await expect(service.login({ username: 'ghost_user', password: 'some-password' }, response)).rejects.toThrow(UnauthorizedException);
expect(compareSpy).toHaveBeenCalledWith('some-password', expect.any(String));
});
it('账号已停用时仍然执行一次密码比对', async () => {
prisma.as_system_user.findFirst.mockResolvedValue(makeUser('DISABLED', STORED_HASH));
await expect(service.login({ username: 'disabled_user', password: 'some-password' }, response)).rejects.toThrow(UnauthorizedException);
expect(compareSpy).toHaveBeenCalledWith('some-password', expect.any(String));
});
it('账号尚未设置密码时仍然执行一次密码比对', async () => {
prisma.as_system_user.findFirst.mockResolvedValue(makeUser('ACTIVE', null));
await expect(service.login({ username: 'pending_user', password: 'some-password' }, response)).rejects.toThrow(UnauthorizedException);
expect(compareSpy).toHaveBeenCalledWith('some-password', expect.any(String));
});
it('密码错误时不签发任何会话', async () => {
prisma.as_system_user.findFirst.mockResolvedValue(makeUser('ACTIVE', STORED_HASH));
compareSpy.mockResolvedValue(false);
await expect(service.login({ username: 'real_user', password: 'wrong-password' }, response)).rejects.toThrow(UnauthorizedException);
expect(tokens.issue).not.toHaveBeenCalled();
});
it('密码正确时签发会话并返回账号信息', async () => {
prisma.as_system_user.findFirst.mockResolvedValue(makeUser('ACTIVE', STORED_HASH));
compareSpy.mockResolvedValue(true);
const result = await service.login({ username: 'real_user', password: 'right-password' }, response);
expect(result.username).toBe('real_user');
expect(tokens.issue).toHaveBeenCalledWith(expect.objectContaining({ userId: 7, username: 'real_user' }), response);
});
it('登录失败计入锁定统计', async () => {
prisma.as_system_user.findFirst.mockResolvedValue(null);
await expect(service.login({ username: 'ghost_user', password: 'some-password' }, response)).rejects.toThrow();
expect(loginAttempts.recordFailure).toHaveBeenCalledWith('ghost_user');
});
it('登录成功清空锁定统计', async () => {
prisma.as_system_user.findFirst.mockResolvedValue(makeUser('ACTIVE', STORED_HASH));
compareSpy.mockResolvedValue(true);
await service.login({ username: 'real_user', password: 'right-password' }, response);
expect(loginAttempts.recordSuccess).toHaveBeenCalledWith('real_user');
});
it('已锁定的账号在查询数据库之前就被拒绝', async () => {
loginAttempts.assertAllowed.mockImplementation(() => { throw new LoginThrottledException(Date.parse('2026-08-06T09:00:00Z')); });
await expect(service.login({ username: 'Jeddy', password: 'any-password' }, response)).rejects.toThrow(LoginThrottledException);
expect(prisma.as_system_user.findFirst).not.toHaveBeenCalled();
});
it('各条失败路径的耗时都补齐到同一下限', async () => {
const padded = await build(200);
(padded as any).timingEqualizerHash = STORED_HASH;
prisma.as_system_user.findFirst.mockResolvedValue(null);
const unknownStart = Date.now();
await expect(padded.login({ username: 'ghost_user', password: 'some-password' }, response)).rejects.toThrow();
const unknownElapsed = Date.now() - unknownStart;
prisma.as_system_user.findFirst.mockResolvedValue(makeUser('ACTIVE', STORED_HASH));
compareSpy.mockResolvedValue(false);
const wrongStart = Date.now();
await expect(padded.login({ username: 'real_user', password: 'some-password' }, response)).rejects.toThrow();
const wrongElapsed = Date.now() - wrongStart;
expect(unknownElapsed).toBeGreaterThanOrEqual(200);
expect(wrongElapsed).toBeGreaterThanOrEqual(200);
});
});
/**
* 文件用途(白话):验证登录锁定的每一条规则,逐条对应 Java 侧 InMemoryLoginAttemptGuardTest 的 9 个用例。
* 关联文件:login-attempt.service.ts、backend/src/test/.../InMemoryLoginAttemptGuardTest.java。
*
* 这些规则只存在于代码和测试里,接口对拍完全测不到——
* 因为"锁没锁上、锁多久、继续试会不会延长"这些事,从响应上看都是同一句「账号或密码错误」。
*
* 时间用可控时钟推进,而不是真的等 24 小时。
*/
import { LoginAttemptService, LoginThrottledException } from './login-attempt.service';
const HOUR = 60 * 60 * 1000;
const MINUTE = 60 * 1000;
describe('LoginAttemptService', () => {
let guard: LoginAttemptService;
let now: number;
beforeEach(() => {
now = Date.parse('2026-08-05T09:00:00Z');
guard = new LoginAttemptService();
guard.setClock(() => now);
});
const advance = (ms: number) => { now += ms; };
const fail = (times: number, username = 'Jeddy') => { for (let i = 0; i < times; i++) guard.recordFailure(username); };
it('第 20 次失败才锁定,第 19 次仍放行', () => {
fail(19);
expect(() => guard.assertAllowed('Jeddy')).not.toThrow();
guard.recordFailure('Jeddy');
expect(() => guard.assertAllowed('Jeddy')).toThrow(LoginThrottledException);
});
it('锁定满 24 小时后自动解除', () => {
fail(20);
advance(23 * HOUR + 59 * MINUTE);
expect(() => guard.assertAllowed('Jeddy')).toThrow(LoginThrottledException);
advance(2 * MINUTE);
expect(() => guard.assertAllowed('Jeddy')).not.toThrow();
});
it('锁定期间继续尝试不会延长锁定时间', () => {
fail(20);
const lockedUntil = guard.lockedAccounts()[0].lockedUntil;
advance(12 * HOUR);
fail(50);
expect(guard.lockedAccounts()[0].lockedUntil).toBe(lockedUntil);
});
it('登录成功清空失败计数', () => {
fail(19);
guard.recordSuccess('Jeddy');
fail(19);
expect(() => guard.assertAllowed('Jeddy')).not.toThrow();
});
it('账号名比对忽略大小写与首尾空格', () => {
fail(10, 'Jeddy');
fail(10, ' JEDDY ');
expect(() => guard.assertAllowed('jeddy')).toThrow(LoginThrottledException);
});
it('手动解锁立即生效', () => {
fail(20);
expect(guard.unlock('JEDDY')).toBe(true);
expect(() => guard.assertAllowed('Jeddy')).not.toThrow();
expect(guard.lockedAccounts()).toHaveLength(0);
});
it('锁定过期后重新计数,而不是再次直接锁定', () => {
fail(20);
advance(24 * HOUR + MINUTE);
guard.recordFailure('Jeddy');
expect(() => guard.assertAllowed('Jeddy')).not.toThrow();
expect(guard.lockedAccounts()).toHaveLength(0);
});
it('距上次失败超出统计窗口后重新计数', () => {
fail(19);
advance(25 * HOUR);
fail(19);
expect(() => guard.assertAllowed('Jeddy')).not.toThrow();
});
/**
* Java 侧用 8 线程并发调用验证计数不丢不重。
* Node 单线程执行同步方法本就不存在竞态,此处验证的是同一件事的等价语义:
* 19 次调用之后必须仍未锁定,第 20 次必须锁定——计数既不能少算也不能多算。
*/
it('失败计数精确,19 次不锁第 20 次锁', () => {
fail(19);
expect(() => guard.assertAllowed('Jeddy')).not.toThrow();
guard.recordFailure('Jeddy');
expect(() => guard.assertAllowed('Jeddy')).toThrow(LoginThrottledException);
});
});
/**
* 文件用途(白话):验证账号管理的权限边界与会话失效规则,逐条对应 Java 侧 SystemUserAdminServiceTest 的 7 个用例。
* 关联文件:system-user.service.ts、backend/src/test/.../SystemUserAdminServiceTest.java。
*
* 其中四条围绕同一件要害:改了角色、状态或页面权限之后,对方手里那张登录令牌必须立刻作废。
* 令牌里记着签发时的 auth_version,账号表里的值一变,旧令牌就对不上了。
* 漏掉自增这一步,权限修改要等对方令牌自然过期(8 小时)才生效——
* 期间他仍以旧权限操作,界面上完全看不出异常,接口对拍也发现不了。
*/
jest.mock('bcrypt', () => ({ hash: jest.fn().mockResolvedValue('$2a$12$hashed'), compare: jest.fn() }));
import { SystemUserService } from './system-user.service';
import { PagePermissionService } from '../auth/page-permission.service';
import { ForbiddenException } from '../common/business.exception';
describe('SystemUserService', () => {
let prisma: any;
let loginAttempts: any;
let service: SystemUserService;
const managedUser = (roleCode: string, authVersion: number, permissions: Record<string, string>) => ({
id: 9n, username: 'managed_user', role_code: roleCode, status: 'ACTIVE',
password_hash: '$2a$12$hashed', delete_time: 0n, auth_version: authVersion,
page_permissions: permissions, password_updated_at: null,
});
beforeEach(() => {
prisma = {
as_system_user: {
findFirst: jest.fn(), findMany: jest.fn(), count: jest.fn().mockResolvedValue(0),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...data, id: 10n, password_updated_at: null })),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...managedUser('FINANCE', 1, {}), ...data })),
},
};
loginAttempts = { lockedAccounts: jest.fn(), unlock: jest.fn() };
service = new SystemUserService(prisma, new PagePermissionService(), loginAttempts);
});
/** 取出实际写库时用的 auth_version,用来判断是否触发了自增。 */
const writtenAuthVersion = () => prisma.as_system_user.update.mock.calls[0][0].data.auth_version;
it('提升角色会让对方的现有登录失效', async () => {
prisma.as_system_user.findFirst.mockResolvedValue(managedUser('FINANCE', 4, { 'phone-assets': 'READ' }));
await service.updateUser('DEVELOPER', 9, { roleCode: 'SUPER_ADMIN', status: 'ACTIVE', pagePermissions: { 'phone-assets': 'READ' } });
expect(writtenAuthVersion()).toBe(5);
});
it('改变账号状态会让对方的现有登录失效', async () => {
prisma.as_system_user.findFirst.mockResolvedValue(managedUser('FINANCE', 4, {}));
await service.updateUser('DEVELOPER', 9, { roleCode: 'FINANCE', status: 'DISABLED', pagePermissions: {} });
expect(writtenAuthVersion()).toBe(5);
});
it('改变页面权限会让对方的现有登录失效', async () => {
prisma.as_system_user.findFirst.mockResolvedValue(managedUser('FINANCE', 4, { 'phone-assets': 'READ' }));
await service.updateUser('DEVELOPER', 9, { roleCode: 'FINANCE', status: 'ACTIVE', pagePermissions: { 'phone-assets': 'EDIT' } });
expect(writtenAuthVersion()).toBe(5);
});
it('提交内容与原值一致时保留原登录,不无谓地踢人下线', async () => {
prisma.as_system_user.findFirst.mockResolvedValue(managedUser('FINANCE', 4, { 'phone-assets': 'READ' }));
await service.updateUser('DEVELOPER', 9, { roleCode: 'FINANCE', status: 'ACTIVE', pagePermissions: { 'phone-assets': 'READ' } });
expect(writtenAuthVersion()).toBe(4);
});
it('开发者可以创建超级管理员', async () => {
await service.createUser('DEVELOPER', { username: 'admin_two', roleCode: 'SUPER_ADMIN', password: 'strong-password-123', pagePermissions: {} });
expect(prisma.as_system_user.create).toHaveBeenCalled();
});
/** 密码规则只限制长度,刻意允许密码与用户名相同、或包含开发者账号名。 */
it('密码与用户名相同或含开发者账号名时仍可创建', async () => {
await service.createUser('DEVELOPER', { username: 'Jeddy2026User', roleCode: 'OPERATIONS', password: 'Jeddy2026User', pagePermissions: {} });
expect(prisma.as_system_user.create).toHaveBeenCalled();
});
it('非管理员不能创建账号,且不产生任何数据库操作', async () => {
await expect(service.createUser('FINANCE', { username: 'admin_two', roleCode: 'SUPER_ADMIN', password: 'strong-password-123', pagePermissions: {} }))
.rejects.toThrow(ForbiddenException);
expect(prisma.as_system_user.count).not.toHaveBeenCalled();
expect(prisma.as_system_user.create).not.toHaveBeenCalled();
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment