Commit c9394e72 authored by ThinhNC's avatar ThinhNC

feat(auth): add dedicated avatar endpoints, session pagination, and audit log idempotency

parent e125e0ff
......@@ -141,6 +141,7 @@ model User {
asyncJobs AsyncJob[]
@@index([roleId])
@@index([deletedAt, isActive])
@@map("users")
}
......@@ -395,6 +396,8 @@ model RefreshToken {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([userId, expiresAt])
@@index([expiresAt])
@@map("refresh_tokens")
}
......@@ -422,6 +425,8 @@ model VerificationToken {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([userId, expiresAt])
@@index([expiresAt])
@@map("verification_tokens")
}
......@@ -435,6 +440,8 @@ model PasswordResetToken {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([userId, expiresAt])
@@index([expiresAt])
@@map("password_reset_tokens")
}
......
......@@ -7,7 +7,10 @@ const ENCRYPTION_ALGORITHM = 'aes-256-gcm';
* Lấy encryption key 32-byte từ APP_SECRET hoặc JWT accessSecret.
*/
function getEncryptionKey(): Buffer {
const secret = process.env.APP_SECRET || envConfig.jwt.accessSecret || 'finwise_default_secure_app_secret_key_32_bytes';
const secret = process.env.APP_SECRET || envConfig.jwt.accessSecret;
if (!secret) {
throw new Error('Encryption secret is not configured. Set APP_SECRET or JWT_ACCESS_SECRET environment variable.');
}
return crypto.createHash('sha256').update(secret).digest();
}
......
import { Request, Response, NextFunction } from 'express';
import { AuthService } from './auth.service';
import { LoginDto, RegisterDto, UpdateProfileDto, UpdatePasswordDto, ForgotPasswordDto, ResetPasswordDto, ResendVerificationDto } from './auth.dto';
import { LoginDto, RegisterDto, UpdateProfileDto, UpdateAvatarDto, UpdatePasswordDto, ForgotPasswordDto, ResetPasswordDto, ResendVerificationDto, SessionQueryDto } from './auth.dto';
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
......@@ -153,6 +153,35 @@ export class AuthController {
}
};
updateAvatar = async (req: Request, res: Response, next: NextFunction) => {
try {
const body = req.body as UpdateAvatarDto;
const result = await this.service.updateAvatar(req.user.id, body);
res.json({
success: true,
message: 'Avatar updated successfully',
data: result,
});
} catch (error) {
next(error);
}
};
deleteAvatar = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.updateAvatar(req.user.id, { avatarUrl: null });
res.json({
success: true,
message: 'Avatar deleted successfully',
data: result,
});
} catch (error) {
next(error);
}
};
updatePassword = async (req: Request, res: Response, next: NextFunction) => {
try {
const body = req.body as UpdatePasswordDto;
......@@ -216,10 +245,12 @@ export class AuthController {
req.body?.refreshToken ||
(req.headers['x-refresh-token'] as string | undefined) ||
(req.query?.refreshToken as string | undefined);
const result = await this.service.getActiveSessions(req.user.id, currentToken);
const query = req.query as unknown as SessionQueryDto;
const result = await this.service.getActiveSessions(req.user.id, currentToken, query);
res.json({
success: true,
data: result,
data: result.data,
meta: result.meta,
});
} catch (error) {
next(error);
......
......@@ -43,10 +43,13 @@ export interface LoginResponseDto {
export interface UpdateProfileDto {
fullName?: string;
avatarUrl?: string | null;
phoneNumber?: string;
}
export interface UpdateAvatarDto {
avatarUrl: string | null;
avatarPositionX?: number;
avatarPositionY?: number;
phoneNumber?: string;
}
export interface UpdatePasswordDto {
......@@ -66,3 +69,27 @@ export interface ResetPasswordDto {
export interface ResendVerificationDto {
email: string;
}
export interface SessionQueryDto {
page?: number;
limit?: number;
}
export interface SessionItemDto {
id: string;
deviceName: string;
ipAddress: string;
createdAt: Date;
isCurrent: boolean;
}
export interface SessionsResponseDto {
data: SessionItemDto[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
......@@ -39,6 +39,22 @@ export class AuthRepository {
});
}
async rotateRefreshToken(
oldToken: string,
newRecord: {
userId: string;
token: string;
expiresAt: Date;
userAgent?: string;
ipAddress?: string;
}
) {
return prisma.$transaction(async (tx) => {
await tx.refreshToken.deleteMany({ where: { token: oldToken } });
return tx.refreshToken.create({ data: newRecord });
});
}
async findBySocial(provider: string, providerUserId: string) {
const socialAccount = await prisma.userSocial.findUnique({
where: {
......@@ -142,10 +158,18 @@ export class AuthRepository {
async updateProfile(userId: string, data: {
fullName?: string;
avatarUrl?: string | null;
phoneNumber?: string | null;
}) {
return prisma.user.update({
where: { id: userId },
data,
});
}
async updateAvatar(userId: string, data: {
avatarUrl: string | null;
avatarPositionX?: number;
avatarPositionY?: number;
phoneNumber?: string;
}) {
return prisma.user.update({
where: { id: userId },
......@@ -231,12 +255,65 @@ export class AuthRepository {
});
}
async findSessionsByUserId(userId: string) {
return prisma.refreshToken.findMany({
where: { userId },
async cleanupExpiredSessions(userId: string) {
return prisma.refreshToken.deleteMany({
where: {
userId,
expiresAt: { lte: new Date() },
},
});
}
async enforceSessionLimit(userId: string, maxSessions = 10) {
const activeSessions = await prisma.refreshToken.findMany({
where: {
userId,
expiresAt: { gt: new Date() },
},
orderBy: { createdAt: 'desc' },
select: { id: true },
});
if (activeSessions.length > maxSessions) {
const idsToDelete = activeSessions.slice(maxSessions).map((s) => s.id);
await prisma.refreshToken.deleteMany({
where: {
id: { in: idsToDelete },
userId,
},
});
}
}
async findSessionsByUserId(userId: string, query: { page: number; limit: number }) {
const { page, limit } = query;
const skip = (page - 1) * limit;
const now = new Date();
const where = {
userId,
expiresAt: { gt: now },
};
const [sessions, total] = await prisma.$transaction([
prisma.refreshToken.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: limit,
}),
prisma.refreshToken.count({ where }),
]);
return {
data: sessions,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
async findSessionById(userId: string, sessionId: string) {
return prisma.refreshToken.findFirst({
......@@ -261,3 +338,4 @@ export class AuthRepository {
});
}
}
......@@ -2,7 +2,7 @@ import { Router } from 'express';
import { AuthController } from './auth.controller';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { loginSchema, refreshSchema, logoutSchema, registerSchema, verifyEmailSchema, updateProfileSchema, updatePasswordSchema, forgotPasswordSchema, resetPasswordSchema, resendVerificationSchema, sessionParamsSchema, revokeOtherSessionsSchema } from './auth.validation';
import { loginSchema, refreshSchema, logoutSchema, registerSchema, verifyEmailSchema, updateProfileSchema, updateAvatarSchema, updatePasswordSchema, forgotPasswordSchema, resetPasswordSchema, resendVerificationSchema, sessionParamsSchema, sessionQuerySchema, revokeOtherSessionsSchema } from './auth.validation';
const router = Router();
const controller = new AuthController();
......@@ -14,13 +14,15 @@ router.get('/me', authMiddleware, controller.me);
router.post('/refresh', validate(refreshSchema), controller.refresh);
router.post('/logout', validate(logoutSchema), controller.logout);
router.put('/profile', authMiddleware, validate(updateProfileSchema), controller.updateProfile);
router.put('/avatar', authMiddleware, validate(updateAvatarSchema), controller.updateAvatar);
router.delete('/avatar', authMiddleware, controller.deleteAvatar);
router.put('/password', authMiddleware, validate(updatePasswordSchema), controller.updatePassword);
router.post('/forgot-password', validate(forgotPasswordSchema), controller.forgotPassword);
router.post('/reset-password', validate(resetPasswordSchema), controller.resetPassword);
router.post('/resend-verification', validate(resendVerificationSchema), controller.resendVerification);
// Session management
router.get('/sessions', authMiddleware, controller.getSessions);
router.get('/sessions', authMiddleware, validate(sessionQuerySchema, 'query'), controller.getSessions);
router.delete('/sessions/:id', authMiddleware, validate(sessionParamsSchema, 'params'), controller.revokeSession);
router.delete('/sessions', authMiddleware, validate(revokeOtherSessionsSchema), controller.revokeOtherSessions);
......
This diff is collapsed.
......@@ -40,6 +40,16 @@ export const verifyEmailSchema = z.object({
export const updateProfileSchema = z.object({
fullName: z.string().min(1, 'Full name cannot be empty').optional(),
phoneNumber: z
.union([
z.string().regex(/^[0-9]{10,11}$/, 'Invalid phone number format (must be 10-11 digits)'),
z.literal('').transform(() => null),
z.null(),
])
.optional(),
});
export const updateAvatarSchema = z.object({
avatarUrl: z
.union([
z
......@@ -53,17 +63,9 @@ export const updateProfileSchema = z.object({
),
z.null(),
z.literal('').transform(() => null),
])
.optional(),
]),
avatarPositionX: z.number().int().min(0).max(100).optional(),
avatarPositionY: z.number().int().min(0).max(100).optional(),
phoneNumber: z
.union([
z.string().regex(/^[0-9]{10,11}$/, 'Invalid phone number format (must be 10-11 digits)'),
z.literal('').transform(() => null),
z.null(),
])
.optional(),
});
export const updatePasswordSchema = z.object({
......@@ -108,6 +110,12 @@ export const sessionParamsSchema = z.object({
id: z.string().uuid('Invalid session id'),
});
export const sessionQuerySchema = z.object({
page: z.coerce.number().int().positive().optional().default(1),
limit: z.coerce.number().int().min(1).max(100).optional().default(10),
});
export const revokeOtherSessionsSchema = z.object({
refreshToken: z.string().optional(),
});
......@@ -99,14 +99,99 @@ export class QueryCompiler {
const fromDatePrisma = businessDateToPrismaDate(from);
const toDatePrisma = businessDateToPrismaDate(to);
// Branch 1: Handle TRANSFER querying on Prisma Transfer model
if (ast.transactionType === 'TRANSFER') {
const nextDayAfterTo = new Date(toDatePrisma.getTime() + 24 * 60 * 60 * 1000);
const transferWhere: Prisma.TransferWhereInput = {
userId,
transferredAt: {
gte: fromDatePrisma,
lt: nextDayAfterTo,
},
...(ast.walletIds && ast.walletIds.length > 0
? {
OR: [
{ sourceWalletId: { in: ast.walletIds } },
{ destinationWalletId: { in: ast.walletIds } },
],
}
: {}),
...(ast.amountFilter?.minAmount || ast.amountFilter?.maxAmount
? {
amount: {
...(ast.amountFilter.minAmount ? { gte: ast.amountFilter.minAmount } : {}),
...(ast.amountFilter.maxAmount ? { lte: ast.amountFilter.maxAmount } : {}),
},
}
: {}),
};
const agg = await prisma.transfer.aggregate({
where: transferWhere,
_sum: { amount: true },
_count: { id: true },
_avg: { amount: true },
_min: { amount: true },
_max: { amount: true },
});
const totalVal = agg._sum.amount ? agg._sum.amount.toNumber() : 0;
const count = agg._count.id || 0;
const avgVal = agg._avg.amount ? agg._avg.amount.toNumber() : 0;
const minVal = agg._min.amount ? agg._min.amount.toNumber() : null;
const maxVal = agg._max.amount ? agg._max.amount.toNumber() : null;
let items: QueryTransactionItemDto[] | undefined;
if (ast.aggregation === 'LIST' || count > 0) {
const records = await prisma.transfer.findMany({
where: transferWhere,
include: {
sourceWallet: { select: { name: true } },
destinationWallet: { select: { name: true } },
},
orderBy: { transferredAt: 'desc' },
take: ast.limit || 30,
});
items = records.map((r) => ({
id: r.id,
date: instantToBusinessDate(r.transferredAt),
amount: r.amount.toFixed(2),
type: 'EXPENSE' as TransactionType,
description: r.note || `Chuyển từ ${r.sourceWallet.name} sang ${r.destinationWallet.name}`,
categoryName: 'Chuyển tiền',
walletName: `${r.sourceWallet.name} -> ${r.destinationWallet.name}`,
}));
}
const entityLabel = ast.walletNames?.length ? `ví ${ast.walletNames.join(', ')}` : '';
const summary = count === 0
? `Không tìm thấy giao dịch chuyển khoản nào ${entityLabel ? `thuộc ${entityLabel} ` : ''}trong khoảng thời gian ${timeRangeDesc}.`
: `Tổng chuyển khoản ${entityLabel ? `thuộc ${entityLabel} ` : ''}trong ${timeRangeDesc}${totalVal.toLocaleString('vi-VN')} VND qua ${count} giao dịch (bình quân: ${Math.round(avgVal).toLocaleString('vi-VN')} VND/giao dịch).`;
return {
summary,
timeRangeDescription: timeRangeDesc,
aggregation: ast.aggregation,
totalValue: (agg._sum.amount ?? new Prisma.Decimal(0)).toFixed(2),
count,
average: (agg._avg.amount ?? new Prisma.Decimal(0)).toFixed(2),
minValue: agg._min.amount ? agg._min.amount.toFixed(2) : null,
maxValue: agg._max.amount ? agg._max.amount.toFixed(2) : null,
currency: 'VND',
items,
};
}
// Branch 2: Handle Standard INCOME / EXPENSE / ALL
const where: Prisma.TransactionWhereInput = {
userId,
date: {
gte: fromDatePrisma,
lte: toDatePrisma,
},
...(ast.transactionType !== 'ALL'
? { type: ast.transactionType as TransactionType }
...(ast.transactionType === 'INCOME' || ast.transactionType === 'EXPENSE'
? { type: ast.transactionType }
: {}),
...(ast.categoryIds && ast.categoryIds.length > 0
? { categoryId: { in: ast.categoryIds } }
......@@ -215,8 +300,6 @@ export class QueryCompiler {
const typeLabel =
ast.transactionType === 'INCOME'
? 'thu nhập'
: ast.transactionType === 'TRANSFER'
? 'chuyển khoản'
: 'chi tiêu';
const entityLabel = ast.categoryNames?.length
......@@ -244,11 +327,11 @@ export class QueryCompiler {
summary,
timeRangeDescription: timeRangeDesc,
aggregation: ast.aggregation,
totalValue: totalVal.toFixed(2),
totalValue: (agg._sum.amount ?? new Prisma.Decimal(0)).toFixed(2),
count,
average: avgVal.toFixed(2),
minValue: minVal !== null ? minVal.toFixed(2) : null,
maxValue: maxVal !== null ? maxVal.toFixed(2) : null,
average: (agg._avg.amount ?? new Prisma.Decimal(0)).toFixed(2),
minValue: agg._min.amount ? agg._min.amount.toFixed(2) : null,
maxValue: agg._max.amount ? agg._max.amount.toFixed(2) : null,
currency: resolvedCurrency,
groups,
items,
......
......@@ -80,6 +80,18 @@ export class RbacService {
) {
const role = await this.findRoleById(id);
// Check if fields are unchanged
const isNameSame = data.name === undefined || data.name === role.name;
const isDescSame =
data.description === undefined ||
data.description === role.description ||
(!data.description && !role.description);
if (isNameSame && isDescSame) {
this.logger.info(`Role "${role.name}" (ID: ${id}) fields unchanged, skipping update and audit log`);
return role;
}
// Protected: cannot rename ADMIN or SUPER_ADMIN system roles
if (
role.isSystem &&
......@@ -185,13 +197,27 @@ export class RbacService {
}
}
const uniqueNewIds = Array.from(new Set(permissionIds));
const currentPermissionIds = (role.rolePermissions || []).map((rp) => rp.permissionId);
const currentIdSet = new Set(currentPermissionIds);
// If permissions are identical, skip database mutation and audit log
const isUnchanged =
uniqueNewIds.length === currentIdSet.size &&
uniqueNewIds.every((id) => currentIdSet.has(id));
if (isUnchanged) {
this.logger.info(`Permissions for role "${role.name}" (ID: ${roleId}) unchanged, skipping update and audit log`);
return role;
}
// Last Admin Protection: Check if removing critical permissions from the last admin role
const criticalPermissions = ['ROLE_PERMISSION_ASSIGN', 'ROLE_UPDATE', 'USER_UPDATE'];
const currentPermissionNames = await this.repository.getPermissionNamesByRoleId(roleId);
const currentPermissionNames = (role.rolePermissions || []).map((rp) => rp.permission.name);
const hasCriticalPerms = criticalPermissions.some((p) => currentPermissionNames.includes(p));
if (hasCriticalPerms) {
const newPermissions = await this.repository.findPermissionsByIds(permissionIds);
const newPermissions = await this.repository.findPermissionsByIds(uniqueNewIds);
const newPermissionNames = newPermissions.map((p) => p.name);
const willRetainCritical = criticalPermissions.some((p) => newPermissionNames.includes(p));
......@@ -210,7 +236,7 @@ export class RbacService {
}
}
const updatedRole = await this.repository.assignRolePermissions(roleId, permissionIds);
const updatedRole = await this.repository.assignRolePermissions(roleId, uniqueNewIds);
// Invalidate caches
await this.invalidateRoleCache(roleId);
......@@ -221,12 +247,12 @@ export class RbacService {
targetType: 'ROLE',
targetId: roleId,
previousState: { permissions: currentPermissionNames },
newState: { permissionIds },
newState: { permissionIds: uniqueNewIds },
ipAddress: metadata?.ipAddress,
userAgent: metadata?.userAgent,
});
this.logger.info(`Assigned ${permissionIds.length} permissions to role "${role.name}" by actor ${actorId || 'system'}`);
this.logger.info(`Assigned ${uniqueNewIds.length} permissions to role "${role.name}" by actor ${actorId || 'system'}`);
return updatedRole;
}
......@@ -242,6 +268,12 @@ export class RbacService {
throw new AppError('Quyền hạn không tồn tại', 404, ERROR_CODE.PERMISSION_NOT_FOUND);
}
const currentPermissionIds = (role.rolePermissions || []).map((rp) => rp.permissionId);
if (!currentPermissionIds.includes(permissionId)) {
this.logger.info(`Role "${role.name}" (ID: ${roleId}) does not have permission "${permission.name}", skipping remove`);
return { success: true, message: `Vai trò "${role.name}" không có quyền "${permission.name}"` };
}
// Last Admin Protection check
if (['ROLE_PERMISSION_ASSIGN', 'ROLE_UPDATE', 'USER_UPDATE'].includes(permission.name)) {
const otherRoles = await this.repository.findRolesWithPermissions([permission.name]);
......
import { randomUUID } from 'crypto';
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { PutObjectCommand, DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
import { envConfig } from '../../config/env.config';
import { LoggerService } from '../../common/services/logger.service';
import {
AvatarContentType,
CreatePresignedUploadDto,
......@@ -17,6 +18,8 @@ const EXTENSION_BY_CONTENT_TYPE: Record<AvatarContentType, string> = {
};
export class UploadService {
private readonly logger = new LoggerService('UploadService');
async createPresignedUpload(
userId: string,
data: CreatePresignedUploadDto,
......@@ -69,4 +72,53 @@ export class UploadService {
},
};
}
async deleteFileByUrl(url: string): Promise<void> {
if (!url) return;
const config = envConfig.r2;
if (
!config.accountId
|| !config.bucketName
|| !config.accessKeyId
|| !config.secretAccessKey
) {
return;
}
try {
let objectKey = '';
if (config.publicBaseUrl && url.startsWith(config.publicBaseUrl)) {
objectKey = url.substring(config.publicBaseUrl.length).replace(/^\/+/, '');
} else {
const avatarIdx = url.indexOf('avatars/');
if (avatarIdx !== -1) {
objectKey = url.substring(avatarIdx);
}
}
if (!objectKey) return;
objectKey = decodeURIComponent(objectKey);
const client = new S3Client({
region: 'auto',
endpoint: `https://${config.accountId}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
});
const command = new DeleteObjectCommand({
Bucket: config.bucketName,
Key: objectKey,
});
await client.send(command);
this.logger.info(`Deleted old cloud storage file: ${objectKey}`);
} catch (error) {
this.logger.warn(`Failed to delete file from R2 (${url}): ${error instanceof Error ? error.message : String(error)}`);
}
}
}
......@@ -13,6 +13,7 @@ export interface CreateUserDto {
email: string;
password: string;
roleId: string;
fullName?: string;
}
export interface UpdateUserDto {
......
......@@ -2,6 +2,23 @@ import { Prisma } from '@prisma/client';
import { prisma } from '../../database/prisma.client';
import { UserQueryDto } from './user.dto';
export const userSelect = {
id: true,
email: true,
fullName: true,
avatarUrl: true,
avatarPositionX: true,
avatarPositionY: true,
phoneNumber: true,
roleId: true,
isActive: true,
deletedAt: true,
deletedBy: true,
createdAt: true,
updatedAt: true,
role: true,
} satisfies Prisma.UserSelect;
export class UserRepository {
async findAll(query: UserQueryDto) {
const {
......@@ -28,7 +45,7 @@ export class UserRepository {
const [data, total] = await prisma.$transaction([
prisma.user.findMany({
where,
include: { role: true },
select: userSelect,
orderBy: { [sortBy]: order },
skip,
take: limit,
......@@ -45,21 +62,21 @@ export class UserRepository {
findById(id: string) {
return prisma.user.findFirst({
where: { id, deletedAt: null },
include: { role: true },
select: userSelect,
});
}
findDeletedById(id: string) {
return prisma.user.findFirst({
where: { id, deletedAt: { not: null } },
include: { role: true },
select: userSelect,
});
}
findByEmail(email: string) {
return prisma.user.findFirst({
where: { email, deletedAt: null },
include: { role: true },
select: userSelect,
});
}
......@@ -82,7 +99,7 @@ export class UserRepository {
roleId: data.roleId,
isActive: data.isActive,
},
include: { role: true },
select: userSelect,
});
}
......@@ -90,7 +107,7 @@ export class UserRepository {
return prisma.user.update({
where: { id },
data,
include: { role: true },
select: userSelect,
});
}
......@@ -103,6 +120,7 @@ export class UserRepository {
deletedBy: adminId,
isActive: false,
},
select: userSelect,
}),
prisma.refreshToken.deleteMany({
where: { userId: id },
......@@ -118,7 +136,7 @@ export class UserRepository {
deletedBy: null,
isActive: true,
},
include: { role: true },
select: userSelect,
});
}
......
......@@ -36,6 +36,7 @@ export class UserService {
return this.repository.create({
email: data.email,
passwordHash,
fullName: data.fullName,
roleId: data.roleId,
isActive: true, // Admin-created users are active by default
});
......@@ -49,8 +50,10 @@ export class UserService {
) {
const user = await this.findById(id);
const roleChanged = Boolean(data.roleId && data.roleId !== user.roleId);
// If role is changing, delegate to rbacService for safety checks
if (data.roleId && data.roleId !== user.roleId) {
if (roleChanged && data.roleId) {
await rbacService.updateUserRole(id, data.roleId, actorId, metadata);
}
......@@ -66,10 +69,17 @@ export class UserService {
}
}
const updated = await this.repository.update(id, {
isActive: data.isActive,
roleId: data.roleId,
});
const updatePayload: { isActive?: boolean; roleId?: string } = {};
if (data.isActive !== undefined && data.isActive !== user.isActive) {
updatePayload.isActive = data.isActive;
}
if (data.roleId !== undefined && !roleChanged && data.roleId !== user.roleId) {
updatePayload.roleId = data.roleId;
}
const updated = Object.keys(updatePayload).length > 0
? await this.repository.update(id, updatePayload)
: await this.findById(id);
await rbacService.invalidateUserCache(id);
......
......@@ -30,6 +30,7 @@ export const createUserSchema = z.object({
.regex(/[0-9]/, 'Password must contain at least one number')
.regex(/[^a-zA-Z0-9]/, 'Password must contain at least one special character'),
roleId: z.string().uuid('Invalid roleId format'),
fullName: z.string().trim().min(1).max(100).optional(),
});
export const updateUserSchema = z.object({
......
......@@ -145,6 +145,105 @@ describe('Auth Integration Tests', () => {
expect(res.body.data).toHaveProperty('email', testUser.email);
});
it('should update user profile successfully', async () => {
const res = await request(app)
.put('/api/v1/auth/profile')
.set('Authorization', `Bearer ${accessTokenHeader}`)
.send({
fullName: 'Updated Test User',
phoneNumber: '0987654321',
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.fullName).toBe('Updated Test User');
expect(res.body.data.phoneNumber).toBe('0987654321');
});
it('should handle unchanged profile update gracefully', async () => {
const res = await request(app)
.put('/api/v1/auth/profile')
.set('Authorization', `Bearer ${accessTokenHeader}`)
.send({
fullName: 'Updated Test User',
phoneNumber: '0987654321',
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.fullName).toBe('Updated Test User');
expect(res.body.data.phoneNumber).toBe('0987654321');
});
it('should update user avatar successfully', async () => {
const res = await request(app)
.put('/api/v1/auth/avatar')
.set('Authorization', `Bearer ${accessTokenHeader}`)
.send({
avatarUrl: 'https://example.com/avatar.png',
avatarPositionX: 60,
avatarPositionY: 40,
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.avatarUrl).toBe('https://example.com/avatar.png');
expect(res.body.data.avatarPositionX).toBe(60);
expect(res.body.data.avatarPositionY).toBe(40);
});
it('should handle unchanged avatar update gracefully', async () => {
const res = await request(app)
.put('/api/v1/auth/avatar')
.set('Authorization', `Bearer ${accessTokenHeader}`)
.send({
avatarUrl: 'https://example.com/avatar.png',
avatarPositionX: 60,
avatarPositionY: 40,
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.avatarUrl).toBe('https://example.com/avatar.png');
});
it('should delete user avatar successfully', async () => {
const res = await request(app)
.delete('/api/v1/auth/avatar')
.set('Authorization', `Bearer ${accessTokenHeader}`);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.avatarUrl).toBeNull();
expect(res.body.data.avatarPositionX).toBe(50);
expect(res.body.data.avatarPositionY).toBe(50);
});
it('should get active sessions with pagination and meta', async () => {
const res = await request(app)
.get('/api/v1/auth/sessions?page=1&limit=5')
.set('Authorization', `Bearer ${accessTokenHeader}`);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(Array.isArray(res.body.data)).toBe(true);
expect(res.body.meta).toBeDefined();
expect(res.body.meta.page).toBe(1);
expect(res.body.meta.limit).toBe(5);
expect(typeof res.body.meta.total).toBe('number');
expect(typeof res.body.meta.totalPages).toBe('number');
});
it('should return 422 for invalid pagination query parameters', async () => {
const res = await request(app)
.get('/api/v1/auth/sessions?page=-1&limit=200')
.set('Authorization', `Bearer ${accessTokenHeader}`);
expect(res.status).toBe(422);
expect(res.body.success).toBe(false);
expect(res.body.code).toBe('VALIDATION_ERROR');
});
it('should logout successfully and clear cookies', async () => {
// Trích xuất refresh token từ DB để gửi kèm body nếu logout yêu cầu (hoặc qua cookie)
const dbUser = await prisma.user.findUnique({
......
......@@ -234,6 +234,37 @@ describe('Dynamic RBAC Integration Tests', () => {
expect(res.body.success).toBe(true);
expect(res.body.data.rolePermissions.length).toBe(4);
});
it('should NOT create redundant audit log when assigning identical permissions', async () => {
// Get current audit log count for this role
const initialLogs = await prisma.auditLog.count({
where: { targetId: createdRoleId, action: 'ROLE_PERMISSIONS_ASSIGN' },
});
// Get current role permissions
const role = await prisma.role.findUnique({
where: { id: createdRoleId },
include: { rolePermissions: true },
});
const currentPermIds = role!.rolePermissions.map((rp) => rp.permissionId);
// Re-assign identical permission IDs
const res = await request(app)
.put(`/api/v1/roles/${createdRoleId}/permissions`)
.set('Authorization', `Bearer ${adminToken}`)
.send({
permissionIds: currentPermIds,
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
// Audit logs count should NOT increase
const newLogs = await prisma.auditLog.count({
where: { targetId: createdRoleId, action: 'ROLE_PERMISSIONS_ASSIGN' },
});
expect(newLogs).toBe(initialLogs);
});
});
describe('3. System Role Protection & Invariants', () => {
......
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