test(auth,payments,subs): add 58 unit tests for critical auth, payment, and subscription paths

Cover auth handlers (RegisterUser, LoginUser, RefreshToken), TokenService
(token rotation, reuse attack detection), payment callback edge cases
(duplicate/concurrent callbacks, multi-provider), subscription lifecycle
transitions (expire, pastDue, renew), and throttler proxy guard.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 13:49:19 +07:00
parent a590a41e73
commit bac3313873
13 changed files with 1031 additions and 0 deletions

View File

@@ -0,0 +1,52 @@
import { LoginUserCommand } from '../commands/login-user/login-user.command';
import { LoginUserHandler } from '../commands/login-user/login-user.handler';
describe('LoginUserHandler', () => {
let handler: LoginUserHandler;
let mockTokenService: { generateTokenPair: ReturnType<typeof vi.fn> };
const tokenPair = {
accessToken: 'access-jwt',
refreshToken: 'family.refresh-hex',
expiresIn: 900,
};
beforeEach(() => {
mockTokenService = { generateTokenPair: vi.fn().mockResolvedValue(tokenPair) };
handler = new LoginUserHandler(mockTokenService as any);
});
it('generates token pair with correct payload', async () => {
const command = new LoginUserCommand('user-1', '0912345678', 'BUYER');
const result = await handler.execute(command);
expect(result).toEqual(tokenPair);
expect(mockTokenService.generateTokenPair).toHaveBeenCalledWith({
sub: 'user-1',
phone: '0912345678',
role: 'BUYER',
});
});
it('passes AGENT role correctly', async () => {
const command = new LoginUserCommand('user-2', '0987654321', 'AGENT');
await handler.execute(command);
expect(mockTokenService.generateTokenPair).toHaveBeenCalledWith({
sub: 'user-2',
phone: '0987654321',
role: 'AGENT',
});
});
it('passes ADMIN role correctly', async () => {
const command = new LoginUserCommand('admin-1', '0901234567', 'ADMIN');
await handler.execute(command);
expect(mockTokenService.generateTokenPair).toHaveBeenCalledWith({
sub: 'admin-1',
phone: '0901234567',
role: 'ADMIN',
});
});
});

View File

@@ -0,0 +1,122 @@
import { Phone } from '../../domain/value-objects/phone.vo';
import { UserEntity } from '../../domain/entities/user.entity';
import { HashedPassword } from '../../domain/value-objects/hashed-password.vo';
import { type IUserRepository } from '../../domain/repositories/user.repository';
import { RefreshTokenCommand } from '../commands/refresh-token/refresh-token.command';
import { RefreshTokenHandler } from '../commands/refresh-token/refresh-token.handler';
function createActiveUser(): UserEntity {
const phone = Phone.create('0912345678').unwrap();
const pw = { value: 'hashed' } as HashedPassword;
return new UserEntity('user-1', {
email: null,
phone,
passwordHash: pw,
fullName: 'Test User',
avatarUrl: null,
role: 'BUYER',
kycStatus: 'NONE',
kycData: null,
isActive: true,
});
}
function createInactiveUser(): UserEntity {
const phone = Phone.create('0912345678').unwrap();
const pw = { value: 'hashed' } as HashedPassword;
return new UserEntity('user-1', {
email: null,
phone,
passwordHash: pw,
fullName: 'Deactivated User',
avatarUrl: null,
role: 'BUYER',
kycStatus: 'NONE',
kycData: null,
isActive: false,
});
}
describe('RefreshTokenHandler', () => {
let handler: RefreshTokenHandler;
let mockTokenService: {
rotateRefreshToken: ReturnType<typeof vi.fn>;
generateAccessToken: ReturnType<typeof vi.fn>;
};
let mockUserRepo: { [K in keyof IUserRepository]: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockTokenService = {
rotateRefreshToken: vi.fn(),
generateAccessToken: vi.fn().mockReturnValue('new-access-jwt'),
};
mockUserRepo = {
findById: vi.fn(),
findByPhone: vi.fn(),
findByEmail: vi.fn(),
save: vi.fn(),
update: vi.fn(),
};
handler = new RefreshTokenHandler(
mockTokenService as any,
mockUserRepo as any,
);
});
it('rotates refresh token and returns new token pair', async () => {
mockTokenService.rotateRefreshToken.mockResolvedValue({
userId: 'user-1',
refreshToken: 'new-family.new-refresh-hex',
});
mockUserRepo.findById.mockResolvedValue(createActiveUser());
const command = new RefreshTokenCommand('old-family.old-refresh-hex');
const result = await handler.execute(command);
expect(result.accessToken).toBe('new-access-jwt');
expect(result.refreshToken).toBe('new-family.new-refresh-hex');
expect(result.expiresIn).toBe(900);
expect(mockTokenService.generateAccessToken).toHaveBeenCalledWith(
expect.objectContaining({ sub: 'user-1', phone: '+84912345678', role: 'BUYER' }),
);
});
it('throws UnauthorizedException when refresh token is invalid', async () => {
mockTokenService.rotateRefreshToken.mockResolvedValue(null);
const command = new RefreshTokenCommand('invalid-token');
await expect(handler.execute(command)).rejects.toThrow(
'Refresh token không hợp lệ hoặc đã hết hạn',
);
});
it('throws UnauthorizedException when user not found', async () => {
mockTokenService.rotateRefreshToken.mockResolvedValue({
userId: 'deleted-user',
refreshToken: 'new-family.new-hex',
});
mockUserRepo.findById.mockResolvedValue(null);
const command = new RefreshTokenCommand('family.valid-hex');
await expect(handler.execute(command)).rejects.toThrow(
'Tài khoản không tồn tại hoặc đã bị vô hiệu hóa',
);
});
it('throws UnauthorizedException when user is deactivated', async () => {
mockTokenService.rotateRefreshToken.mockResolvedValue({
userId: 'user-1',
refreshToken: 'new-family.new-hex',
});
mockUserRepo.findById.mockResolvedValue(createInactiveUser());
const command = new RefreshTokenCommand('family.valid-hex');
await expect(handler.execute(command)).rejects.toThrow(
'Tài khoản không tồn tại hoặc đã bị vô hiệu hóa',
);
});
});

View File

@@ -0,0 +1,106 @@
import { type IUserRepository } from '../../domain/repositories/user.repository';
import { Phone } from '../../domain/value-objects/phone.vo';
import { RegisterUserCommand } from '../commands/register-user/register-user.command';
import { RegisterUserHandler } from '../commands/register-user/register-user.handler';
describe('RegisterUserHandler', () => {
let handler: RegisterUserHandler;
let mockUserRepo: { [K in keyof IUserRepository]: ReturnType<typeof vi.fn> };
let mockTokenService: { generateTokenPair: ReturnType<typeof vi.fn> };
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
const tokenPair = {
accessToken: 'access-jwt',
refreshToken: 'family.refresh-hex',
expiresIn: 900,
};
beforeEach(() => {
mockUserRepo = {
findById: vi.fn(),
findByPhone: vi.fn().mockResolvedValue(null),
findByEmail: vi.fn().mockResolvedValue(null),
save: vi.fn().mockResolvedValue(undefined),
update: vi.fn(),
};
mockTokenService = { generateTokenPair: vi.fn().mockResolvedValue(tokenPair) };
mockEventBus = { publish: vi.fn() };
handler = new RegisterUserHandler(
mockUserRepo as any,
mockTokenService as any,
mockEventBus as any,
);
});
it('registers a new user and returns token pair', async () => {
const command = new RegisterUserCommand('0912345678', 'StrongP@ss1', 'Nguyen Van A');
const result = await handler.execute(command);
expect(result).toEqual(tokenPair);
expect(mockUserRepo.save).toHaveBeenCalledTimes(1);
expect(mockTokenService.generateTokenPair).toHaveBeenCalledWith(
expect.objectContaining({ phone: '+84912345678', role: 'BUYER' }),
);
expect(mockEventBus.publish).toHaveBeenCalled();
});
it('registers user with optional email', async () => {
const command = new RegisterUserCommand(
'0912345678', 'StrongP@ss1', 'Nguyen Van A', 'test@example.com',
);
const result = await handler.execute(command);
expect(result).toEqual(tokenPair);
expect(mockUserRepo.findByEmail).toHaveBeenCalledWith('test@example.com');
expect(mockUserRepo.save).toHaveBeenCalledTimes(1);
});
it('throws ConflictException when phone already exists', async () => {
mockUserRepo.findByPhone.mockResolvedValue({ id: 'existing-user' });
const command = new RegisterUserCommand('0912345678', 'StrongP@ss1', 'Nguyen Van A');
await expect(handler.execute(command)).rejects.toThrow('Số điện thoại đã được đăng ký');
});
it('throws ConflictException when email already exists', async () => {
mockUserRepo.findByEmail.mockResolvedValue({ id: 'existing-user' });
const command = new RegisterUserCommand(
'0912345678', 'StrongP@ss1', 'Nguyen Van A', 'taken@example.com',
);
await expect(handler.execute(command)).rejects.toThrow('Email đã được đăng ký');
});
it('throws ValidationException for invalid phone number', async () => {
const command = new RegisterUserCommand('12345', 'StrongP@ss1', 'Nguyen Van A');
await expect(handler.execute(command)).rejects.toThrow();
});
it('throws ValidationException for invalid email format', async () => {
const command = new RegisterUserCommand(
'0912345678', 'StrongP@ss1', 'Nguyen Van A', 'not-an-email',
);
await expect(handler.execute(command)).rejects.toThrow();
});
it('publishes UserRegisteredEvent after saving', async () => {
const command = new RegisterUserCommand('0912345678', 'StrongP@ss1', 'Nguyen Van A');
await handler.execute(command);
expect(mockEventBus.publish).toHaveBeenCalledWith(
expect.objectContaining({ eventName: 'user.registered' }),
);
});
it('does not save user if phone validation fails', async () => {
const command = new RegisterUserCommand('invalid', 'StrongP@ss1', 'Nguyen Van A');
await expect(handler.execute(command)).rejects.toThrow();
expect(mockUserRepo.save).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,158 @@
import { type IRefreshTokenRepository, type RefreshTokenRecord } from '../../domain/repositories/refresh-token.repository';
import { TokenService } from '../services/token.service';
describe('TokenService', () => {
let service: TokenService;
let mockJwtService: { sign: ReturnType<typeof vi.fn>; verify: ReturnType<typeof vi.fn> };
let mockRefreshTokenRepo: { [K in keyof IRefreshTokenRepository]: ReturnType<typeof vi.fn> };
const payload = { sub: 'user-1', phone: '0912345678', role: 'BUYER' };
beforeEach(() => {
mockJwtService = {
sign: vi.fn().mockReturnValue('signed-jwt'),
verify: vi.fn(),
};
mockRefreshTokenRepo = {
create: vi.fn().mockResolvedValue({} as RefreshTokenRecord),
findByToken: vi.fn(),
revokeByFamily: vi.fn().mockResolvedValue(undefined),
revokeAllForUser: vi.fn().mockResolvedValue(undefined),
deleteExpired: vi.fn(),
};
service = new TokenService(
mockJwtService as any,
mockRefreshTokenRepo as any,
);
});
describe('generateTokenPair', () => {
it('returns access token, refresh token with family prefix, and expiresIn', async () => {
const result = await service.generateTokenPair(payload);
expect(result.accessToken).toBe('signed-jwt');
expect(result.refreshToken).toContain('.');
expect(result.expiresIn).toBe(900);
expect(mockJwtService.sign).toHaveBeenCalledWith(payload);
expect(mockRefreshTokenRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'user-1',
revokedAt: null,
}),
);
});
it('creates refresh token record with 30-day expiry', async () => {
await service.generateTokenPair(payload);
const createCall = mockRefreshTokenRepo.create.mock.calls[0][0];
const expiresAt = createCall.expiresAt as Date;
const now = new Date();
const daysDiff = Math.round((expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
expect(daysDiff).toBeGreaterThanOrEqual(29);
expect(daysDiff).toBeLessThanOrEqual(31);
});
});
describe('rotateRefreshToken', () => {
const makeExistingToken = (overrides?: Partial<RefreshTokenRecord>): RefreshTokenRecord => ({
id: 'rt-1',
userId: 'user-1',
token: 'hashed-token',
family: 'old-family',
expiresAt: new Date(Date.now() + 86400000),
revokedAt: null,
createdAt: new Date(),
...overrides,
});
it('rotates valid token: revokes old family, creates new token', async () => {
mockRefreshTokenRepo.findByToken.mockResolvedValue(makeExistingToken());
mockRefreshTokenRepo.create.mockResolvedValue({} as RefreshTokenRecord);
const result = await service.rotateRefreshToken('old-family.raw-token-hex');
expect(result).not.toBeNull();
expect(result!.userId).toBe('user-1');
expect(result!.refreshToken).toContain('.');
expect(mockRefreshTokenRepo.revokeByFamily).toHaveBeenCalledWith('old-family');
expect(mockRefreshTokenRepo.create).toHaveBeenCalled();
});
it('returns null for malformed token (no dot separator)', async () => {
const result = await service.rotateRefreshToken('no-dot-separator');
expect(result).toBeNull();
});
it('returns null and revokes family when token not found (reuse attack)', async () => {
mockRefreshTokenRepo.findByToken.mockResolvedValue(null);
const result = await service.rotateRefreshToken('suspect-family.unknown-token');
expect(result).toBeNull();
expect(mockRefreshTokenRepo.revokeByFamily).toHaveBeenCalledWith('suspect-family');
});
it('returns null and revokes family when token is already revoked', async () => {
mockRefreshTokenRepo.findByToken.mockResolvedValue(
makeExistingToken({ revokedAt: new Date() }),
);
const result = await service.rotateRefreshToken('old-family.revoked-token');
expect(result).toBeNull();
expect(mockRefreshTokenRepo.revokeByFamily).toHaveBeenCalled();
});
it('returns null and revokes family when token is expired', async () => {
mockRefreshTokenRepo.findByToken.mockResolvedValue(
makeExistingToken({ expiresAt: new Date(Date.now() - 86400000) }),
);
const result = await service.rotateRefreshToken('old-family.expired-token');
expect(result).toBeNull();
expect(mockRefreshTokenRepo.revokeByFamily).toHaveBeenCalled();
});
it('returns null for empty family segment', async () => {
const result = await service.rotateRefreshToken('.some-raw-token');
expect(result).toBeNull();
});
it('returns null for empty raw token segment', async () => {
const result = await service.rotateRefreshToken('some-family.');
expect(result).toBeNull();
});
});
describe('generateAccessToken', () => {
it('delegates to jwtService.sign', () => {
const token = service.generateAccessToken(payload);
expect(token).toBe('signed-jwt');
expect(mockJwtService.sign).toHaveBeenCalledWith(payload);
});
});
describe('revokeAllUserTokens', () => {
it('revokes all tokens for a user', async () => {
await service.revokeAllUserTokens('user-1');
expect(mockRefreshTokenRepo.revokeAllForUser).toHaveBeenCalledWith('user-1');
});
});
describe('verifyAccessToken', () => {
it('returns decoded payload for valid token', () => {
mockJwtService.verify.mockReturnValue(payload);
const result = service.verifyAccessToken('valid-jwt');
expect(result).toEqual(payload);
});
it('returns null for invalid token', () => {
mockJwtService.verify.mockImplementation(() => { throw new Error('invalid'); });
const result = service.verifyAccessToken('bad-jwt');
expect(result).toBeNull();
});
});
});