Commit 3b47a518 authored by ThinhNC's avatar ThinhNC

fix(core): complete project audit remediation for auth, financial integrity, and performance

parent 441be079
......@@ -20,7 +20,7 @@ services:
image: redis:7-alpine
container_name: finwise_redis
ports:
- "${REDIS_PORT:-7379}:6379"
- "${REDIS_PORT:-6379}:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
......
......@@ -24,7 +24,14 @@ export default tseslint.config(
},
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'@typescript-eslint/no-namespace': 'off',
'no-console': 'off',
'no-undef': 'off', // TypeScript compiler already checks undefined variables
......
-- CreateIndex
CREATE INDEX "wallets_user_id_is_archived_idx" ON "wallets"("user_id", "is_archived");
-- CreateIndex
CREATE INDEX "notification_deliveries_status_updated_at_idx" ON "notification_deliveries"("status", "updated_at");
......@@ -153,6 +153,7 @@ model Wallet {
@@unique([userId, name])
@@index([userId])
@@index([userId, isArchived])
@@map("wallets")
}
......@@ -414,6 +415,7 @@ model NotificationDelivery {
@@unique([notificationId, channel])
@@index([status, nextAttemptAt])
@@index([status, updatedAt])
@@map("notification_deliveries")
}
......
......@@ -14,7 +14,7 @@ export const envConfig = {
jwt: {
accessSecret: process.env.JWT_ACCESS_SECRET || 'default_access_secret',
refreshSecret: process.env.JWT_REFRESH_SECRET || 'default_refresh_secret',
accessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '1d',
accessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '30m',
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
},
trustProxy: (() => {
......
......@@ -3,8 +3,14 @@ import jwt from 'jsonwebtoken';
import { jwtConfig } from '../config/jwt.config';
import { AppError } from '../common/errors/app-error';
import { ERROR_CODE } from '../common/errors/error-code';
import { cacheService } from '../common/services/cache.service';
import { prisma } from '../database/prisma.client';
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
export async function authMiddleware(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
let token = req.cookies?.accessToken;
if (!token) {
......@@ -26,6 +32,32 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction):
role: string;
};
// Check user active status in cache first, fallback to DB
const cacheKey = `finwise:user:status:${payload.id}`;
let isUserActive = await cacheService.get<boolean>(cacheKey);
if (isUserActive === null) {
const user = await prisma.user.findUnique({
where: { id: payload.id },
select: { id: true, isActive: true, deletedAt: true },
});
isUserActive = Boolean(user && user.isActive && user.deletedAt === null);
// Cache user status for 60 seconds
await cacheService.set(cacheKey, isUserActive, 60);
}
if (!isUserActive) {
next(
new AppError(
'User account is inactive or has been deleted',
403,
ERROR_CODE.USER_INACTIVE,
),
);
return;
}
req.user = {
id: payload.id,
email: payload.email,
......@@ -34,6 +66,11 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction):
next();
} catch (error) {
if (error instanceof AppError) {
next(error);
return;
}
if (error instanceof jwt.TokenExpiredError) {
next(new AppError('Token expired', 401, ERROR_CODE.TOKEN_EXPIRED));
} else {
......
......@@ -14,7 +14,7 @@ export function errorMiddleware(
error: Error,
req: Request,
res: Response,
next: NextFunction,
_next: NextFunction,
): void {
if (error instanceof AppError) {
res.status(error.statusCode).json({
......
......@@ -32,6 +32,8 @@ export class AuthController {
success: true,
data: {
user: result.user,
accessToken: result.accessToken,
refreshToken: result.refreshToken,
},
});
} catch (error) {
......@@ -79,6 +81,10 @@ export class AuthController {
res.json({
success: true,
data: {
accessToken: result.accessToken,
refreshToken: result.refreshToken,
},
});
} catch (error) {
next(error);
......
......@@ -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 } from './auth.validation';
import { loginSchema, refreshSchema, logoutSchema, registerSchema, verifyEmailSchema, updateProfileSchema, updatePasswordSchema, forgotPasswordSchema, resetPasswordSchema, resendVerificationSchema, sessionParamsSchema, revokeOtherSessionsSchema } from './auth.validation';
const router = Router();
const controller = new AuthController();
......@@ -21,7 +21,7 @@ router.post('/resend-verification', validate(resendVerificationSchema), controll
// Session management
router.get('/sessions', authMiddleware, controller.getSessions);
router.delete('/sessions/:id', authMiddleware, controller.revokeSession);
router.delete('/sessions', authMiddleware, controller.revokeOtherSessions);
router.delete('/sessions/:id', authMiddleware, validate(sessionParamsSchema, 'params'), controller.revokeSession);
router.delete('/sessions', authMiddleware, validate(revokeOtherSessionsSchema), controller.revokeOtherSessions);
export default router;
......@@ -114,7 +114,7 @@ export class AuthService {
let payload: any;
try {
payload = jwt.verify(token, jwtConfig.refreshSecret);
} catch (error) {
} catch (_error) {
throw new AppError('Invalid refresh token', 401, ERROR_CODE.TOKEN_INVALID);
}
......
......@@ -78,3 +78,11 @@ export const resetPasswordSchema = z.object({
export const resendVerificationSchema = z.object({
email: z.string().min(1, 'Email is required').email('Invalid email format'),
});
export const sessionParamsSchema = z.object({
id: z.string().uuid('Invalid session id'),
});
export const revokeOtherSessionsSchema = z.object({
refreshToken: z.string().optional(),
});
......@@ -172,6 +172,64 @@ export class BudgetRepository {
};
}
async getBatchSpendingSummaries(
userId: string,
budgets: BudgetRecord[],
): Promise<Map<string, BudgetSpendingSummary>> {
const summaryMap = new Map<string, BudgetSpendingSummary>();
if (budgets.length === 0) return summaryMap;
let minDate = budgets[0].startDate;
let maxDate = budgets[0].endDate;
for (const b of budgets) {
if (b.startDate < minDate) minDate = b.startDate;
if (b.endDate > maxDate) maxDate = b.endDate;
}
const transactions = await prisma.transaction.findMany({
where: {
userId,
type: TransactionType.EXPENSE,
date: { gte: minDate, lte: maxDate },
},
select: {
categoryId: true,
amount: true,
date: true,
wallet: { select: { currency: true } },
},
});
for (const budget of budgets) {
let totalAmount = new Prisma.Decimal(0);
let count = 0;
let lastDate: Date | null = null;
for (const tx of transactions) {
if (
tx.wallet.currency === budget.currency &&
tx.date >= budget.startDate &&
tx.date <= budget.endDate &&
(!budget.categoryId || tx.categoryId === budget.categoryId)
) {
totalAmount = totalAmount.plus(tx.amount);
count++;
if (!lastDate || tx.date > lastDate) {
lastDate = tx.date;
}
}
}
summaryMap.set(budget.id, {
amount: totalAmount,
transactionCount: count,
lastTransactionAt: lastDate ? prismaDateToBusinessDate(lastDate) : null,
});
}
return summaryMap;
}
create(userId: string, data: PersistBudgetDto) {
return prisma.budget.create({
data: {
......
import {
BudgetPeriod,
BudgetType,
Prisma,
TransactionType,
} from '@prisma/client';
import { AppError } from '../../common/errors/app-error';
......@@ -33,9 +34,18 @@ export class BudgetService {
async findAll(userId: string, query: BudgetQueryDto) {
const result = await this.repository.findAll(userId, query);
const data = await Promise.all(
result.data.map((budget) => this.toResponse(userId, budget)),
const summaries = await this.repository.getBatchSpendingSummaries(
userId,
result.data,
);
const data = result.data.map((budget) => {
const spending = summaries.get(budget.id) ?? {
amount: new Prisma.Decimal(0),
transactionCount: 0,
lastTransactionAt: null,
};
return this.formatBudgetResponse(budget, spending);
});
return { data, meta: result.meta };
}
......@@ -288,6 +298,13 @@ export class BudgetService {
budget.currency,
);
return this.formatBudgetResponse(budget, spending);
}
private formatBudgetResponse(
budget: BudgetRecord,
spending: BudgetSpendingSummary,
): BudgetResponseDto {
return {
...budget,
startDate: prismaDateToBusinessDate(budget.startDate),
......
......@@ -253,23 +253,52 @@ export class NotificationRepository {
take: limit,
});
return Promise.all(budgets.map(async (budget) => {
const spending = await prisma.transaction.aggregate({
if (budgets.length === 0) {
return [];
}
const userIds = Array.from(new Set(budgets.map((b) => b.userId)));
let minDate = budgets[0].startDate;
let maxDate = budgets[0].endDate;
for (const b of budgets) {
if (b.startDate < minDate) minDate = b.startDate;
if (b.endDate > maxDate) maxDate = b.endDate;
}
const transactions = await prisma.transaction.findMany({
where: {
userId: budget.userId,
userId: { in: userIds },
type: TransactionType.EXPENSE,
...(budget.categoryId ? { categoryId: budget.categoryId } : {}),
date: { gte: budget.startDate, lte: budget.endDate },
wallet: { currency: budget.currency },
date: { gte: minDate, lte: maxDate },
},
select: {
userId: true,
categoryId: true,
amount: true,
date: true,
wallet: { select: { currency: true } },
},
_sum: { amount: true },
});
return budgets.map((budget) => {
let spentAmount = new Prisma.Decimal(0);
for (const tx of transactions) {
if (
tx.userId === budget.userId &&
tx.wallet.currency === budget.currency &&
tx.date >= budget.startDate &&
tx.date <= budget.endDate &&
(!budget.categoryId || tx.categoryId === budget.categoryId)
) {
spentAmount = spentAmount.plus(tx.amount);
}
}
return {
...budget,
spentAmount: spending._sum.amount ?? new Prisma.Decimal(0),
spentAmount,
};
}));
});
}
async findSavingGoalCandidates(
......
......@@ -3,7 +3,7 @@ import { UserController } from './user.controller';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { requireRole } from '../../middlewares/role.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { createUserSchema, findAllUserSchema, updateUserSchema } from './user.validation';
import { createUserSchema, findAllUserSchema, updateUserSchema, userParamsSchema } from './user.validation';
import { ROLES } from '../../common/constants/role.constant';
const router = Router();
......@@ -11,9 +11,9 @@ const controller = new UserController();
// GET /users?email=...&fullName=...&roleName=...&isActive=...&sortBy=...&order=...&page=...&limit=...
router.get('/', authMiddleware, requireRole(ROLES.ADMIN), validate(findAllUserSchema, 'query'), controller.findAll);
router.get('/:id', authMiddleware, requireRole(ROLES.ADMIN), controller.findById);
router.get('/:id', authMiddleware, requireRole(ROLES.ADMIN), validate(userParamsSchema, 'params'), controller.findById);
router.post('/', authMiddleware, requireRole(ROLES.ADMIN), validate(createUserSchema), controller.create);
router.put('/:id', authMiddleware, requireRole(ROLES.ADMIN), validate(updateUserSchema), controller.update);
router.delete('/:id', authMiddleware, requireRole(ROLES.ADMIN), controller.softDelete);
router.put('/:id', authMiddleware, requireRole(ROLES.ADMIN), validate(userParamsSchema, 'params'), validate(updateUserSchema), controller.update);
router.delete('/:id', authMiddleware, requireRole(ROLES.ADMIN), validate(userParamsSchema, 'params'), controller.softDelete);
export default router;
......@@ -39,3 +39,7 @@ export const updateUserSchema = z.object({
isActive: z.boolean().optional(),
roleId: z.string().uuid('Invalid roleId format').optional(),
});
export const userParamsSchema = z.object({
id: z.string().uuid('Invalid user id'),
});
......@@ -21,7 +21,6 @@ export interface CreateWalletDto {
export interface UpdateWalletDto {
name?: string;
balance?: string;
currency?: string;
icon?: string | null;
color?: string | null;
......
......@@ -52,7 +52,6 @@ export const createWalletSchema = z.object({
export const updateWalletSchema = z
.object({
name: z.string().trim().min(1, 'Name cannot be empty').max(100).optional(),
balance: decimalSchema.optional(),
currency: currencySchema.optional(),
icon: nullableIconSchema.optional(),
color: nullableColorSchema.optional(),
......
......@@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from 'express';
import { prisma } from '../database/prisma.client';
import { cacheService } from '../common/services/cache.service';
export async function healthCheck(req: Request, res: Response, next: NextFunction): Promise<void> {
export async function healthCheck(req: Request, res: Response, _next: NextFunction): Promise<void> {
const timestamp = new Date().toISOString();
const uptime = process.uptime();
const memoryUsage = process.memoryUsage();
......
......@@ -108,6 +108,8 @@ describe('Auth Integration Tests', () => {
expect(res.body).toHaveProperty('success', true);
expect(res.body.data).toHaveProperty('user');
expect(res.body.data.user).toHaveProperty('email', testUser.email);
expect(res.body.data).toHaveProperty('accessToken');
expect(res.body.data).toHaveProperty('refreshToken');
// Lấy cookie
const cookies = (res.headers['set-cookie'] || []) as string[];
......@@ -170,4 +172,14 @@ describe('Auth Integration Tests', () => {
});
expect(tokensCount).toBe(0);
});
it('should return 422 for invalid session UUID parameter', async () => {
const res = await request(app)
.delete('/api/v1/auth/sessions/invalid-session-uuid')
.set('Authorization', `Bearer ${accessTokenHeader}`);
expect(res.status).toBe(422);
expect(res.body.success).toBe(false);
expect(res.body.code).toBe('VALIDATION_ERROR');
});
});
import request from 'supertest';
import app from '../src/app';
import { prisma } from '../src/database/prisma.client';
import bcrypt from 'bcryptjs';
describe('Budget & Report Integration Tests', () => {
const testUser = {
email: 'budget-report-test@gmail.com',
password: 'Password@123456',
fullName: 'Budget Report Test User',
};
let userId = '';
let accessToken = '';
let walletId = '';
let categoryId = '';
beforeAll(async () => {
const defaultRole = await prisma.role.findUnique({ where: { name: 'USER' } });
const passwordHash = await bcrypt.hash(testUser.password, 10);
const user = await prisma.user.create({
data: {
email: testUser.email,
password: passwordHash,
fullName: testUser.fullName,
roleId: defaultRole!.id,
isActive: true,
},
});
userId = user.id;
// Create wallet
const wallet = await prisma.wallet.create({
data: {
userId,
name: 'Ví chi tiêu',
balance: 10000000.0,
currency: 'VND',
},
});
walletId = wallet.id;
// Create category
const category = await prisma.category.create({
data: {
userId,
name: 'Ăn uống',
type: 'EXPENSE',
},
});
categoryId = category.id;
// Login
const loginRes = await request(app)
.post('/api/v1/auth/login')
.send({ email: testUser.email, password: testUser.password });
accessToken = loginRes.body.data.accessToken;
});
afterAll(async () => {
await prisma.transaction.deleteMany({ where: { userId } });
await prisma.budget.deleteMany({ where: { userId } });
await prisma.category.deleteMany({ where: { userId } });
await prisma.wallet.deleteMany({ where: { userId } });
await prisma.refreshToken.deleteMany({ where: { userId } });
await prisma.userDevice.deleteMany({ where: { userId } });
await prisma.user.deleteMany({ where: { id: userId } });
await prisma.$disconnect();
});
it('should create budgets and query budget list with batch spending summaries without N+1 error', async () => {
// Create 3 budgets
const budget1 = await request(app)
.post('/api/v1/budgets')
.set('Authorization', `Bearer ${accessToken}`)
.send({
name: 'Ngân sách ăn uống tháng 8',
amount: '3000000.00',
currency: 'VND',
type: 'CATEGORY',
period: 'CUSTOM',
categoryId,
startDate: '2026-08-01',
endDate: '2026-08-31',
alertThreshold: '80.00',
});
expect(budget1.status).toBe(201);
// Create an expense transaction within the budget
await request(app)
.post('/api/v1/transactions')
.set('Authorization', `Bearer ${accessToken}`)
.send({
walletId,
categoryId,
type: 'EXPENSE',
amount: '500000.00',
date: '2026-08-15',
note: 'Ăn trưa',
});
// Query budgets list
const res = await request(app)
.get('/api/v1/budgets')
.set('Authorization', `Bearer ${accessToken}`);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.length).toBeGreaterThanOrEqual(1);
const targetBudget = res.body.data.find((b: any) => b.id === budget1.body.data.id);
expect(targetBudget).toBeDefined();
expect(targetBudget.usage.spentAmount).toBe('500000.00');
expect(targetBudget.usage.transactionCount).toBe(1);
});
it('should include boundary end-date transactions in custom reports', async () => {
// Add transaction on August 31 (the boundary end date)
await request(app)
.post('/api/v1/transactions')
.set('Authorization', `Bearer ${accessToken}`)
.send({
walletId,
categoryId,
type: 'EXPENSE',
amount: '200000.00',
date: '2026-08-31',
note: 'Cà phê cuối tháng',
});
// Query custom report from 2026-08-01 to 2026-08-31
const res = await request(app)
.get('/api/v1/reports/overview')
.set('Authorization', `Bearer ${accessToken}`)
.query({
period: 'CUSTOM',
dateFrom: '2026-07-31T17:00:00.000Z', // 2026-08-01 00:00 VN
dateTo: '2026-08-31T17:00:00.000Z', // 2026-09-01 00:00 VN (inclusive of Aug 31)
walletId,
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
const vndMetric = res.body.data.metricsByCurrency.find((m: any) => m.currency === 'VND');
expect(vndMetric).toBeDefined();
// 500k from Aug 15 + 200k from Aug 31 = 700k
expect(vndMetric.expense).toBe('700000.00');
expect(vndMetric.transactionCount).toBe(2);
});
});
import request from 'supertest';
import app from '../src/app';
import { prisma } from '../src/database/prisma.client';
import bcrypt from 'bcryptjs';
describe('Wallet Integration Tests', () => {
const testUser = {
email: 'wallet-test@gmail.com',
password: 'Password@123456',
fullName: 'Wallet Test User',
};
let userId = '';
let accessToken = '';
let walletId = '';
beforeAll(async () => {
// Setup test user
const defaultRole = await prisma.role.findUnique({ where: { name: 'USER' } });
const passwordHash = await bcrypt.hash(testUser.password, 10);
const user = await prisma.user.create({
data: {
email: testUser.email,
password: passwordHash,
fullName: testUser.fullName,
roleId: defaultRole!.id,
isActive: true,
},
});
userId = user.id;
// Login to get token
const loginRes = await request(app)
.post('/api/v1/auth/login')
.send({ email: testUser.email, password: testUser.password });
accessToken = loginRes.body.data.accessToken;
});
afterAll(async () => {
await prisma.transaction.deleteMany({ where: { userId } });
await prisma.transfer.deleteMany({ where: { userId } });
await prisma.wallet.deleteMany({ where: { userId } });
await prisma.refreshToken.deleteMany({ where: { userId } });
await prisma.userDevice.deleteMany({ where: { userId } });
await prisma.user.deleteMany({ where: { id: userId } });
await prisma.$disconnect();
});
it('should create a new wallet with initial balance', async () => {
const res = await request(app)
.post('/api/v1/wallets')
.set('Authorization', `Bearer ${accessToken}`)
.send({
name: 'Ví tiền mặt',
balance: '500000.00',
currency: 'VND',
icon: 'cash',
color: '#10B981',
});
expect(res.status).toBe(201);
expect(res.body.success).toBe(true);
expect(res.body.data.name).toBe('Ví tiền mặt');
expect(res.body.data.balance).toBe('500000.00');
expect(res.body.data.isDefault).toBe(true);
walletId = res.body.data.id;
});
it('should update wallet name and not allow balance tampering via update', async () => {
const res = await request(app)
.put(`/api/v1/wallets/${walletId}`)
.set('Authorization', `Bearer ${accessToken}`)
.send({
name: 'Ví tiền mặt mới',
balance: '999999999.00', // Attempt to tamper balance
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.name).toBe('Ví tiền mặt mới');
// Balance must remain unchanged
expect(res.body.data.balance).toBe('500000.00');
// Verify directly in DB
const dbWallet = await prisma.wallet.findUnique({ where: { id: walletId } });
expect(dbWallet?.balance.toFixed(2)).toBe('500000.00');
});
});
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