260322:1648 Correct Coresspondence / Doing RFA / Correct CI
CI Pipeline / build (push) Failing after 12m41s
Build and Deploy / deploy (push) Failing after 2m44s

This commit is contained in:
admin
2026-03-22 16:48:12 +07:00
parent e5deedb42e
commit 11984bfa29
683 changed files with 105251 additions and 29068 deletions
@@ -78,7 +78,7 @@ describe('AuthController', () => {
(mockAuthService.register as jest.Mock).mockResolvedValue(mockUser);
const result = await controller.register(registerDto);
const _result = await controller.register(registerDto);
expect(mockAuthService.register).toHaveBeenCalledWith(registerDto);
});
+5 -2
View File
@@ -27,7 +27,10 @@ import {
ApiResponse,
ApiBody,
} from '@nestjs/swagger';
import type { RequestWithUser, RequestWithRefreshUser } from '../interfaces/request-with-user.interface';
import type {
RequestWithUser,
RequestWithRefreshUser,
} from '../interfaces/request-with-user.interface';
@ApiTags('Authentication')
@Controller('auth')
@@ -143,6 +146,6 @@ export class AuthController {
@ApiOperation({ summary: 'Revoke session' })
@ApiResponse({ status: 200, description: 'Session revoked' })
async revokeSession(@Param('id') id: string) {
return this.authService.revokeSession(parseInt(id));
return this.authService.revokeSession(Number(id));
}
}
+49 -11
View File
@@ -17,14 +17,13 @@ jest.mock('bcrypt', () => ({
genSalt: jest.fn().mockResolvedValue('salt'),
}));
// eslint-disable-next-line @typescript-eslint/no-require-imports
const bcrypt = require('bcrypt');
import * as bcrypt from 'bcrypt';
describe('AuthService', () => {
let service: AuthService;
let userService: UserService;
let jwtService: JwtService;
let tokenRepo: Repository<RefreshToken>;
let _jwtService: JwtService;
let _tokenRepo: Repository<RefreshToken>;
const mockUser = {
user_id: 1,
@@ -53,7 +52,7 @@ describe('AuthService', () => {
beforeEach(async () => {
// Reset bcrypt mocks
bcrypt.compare.mockResolvedValue(true);
(bcrypt.compare as jest.Mock).mockResolvedValue(true);
const module: TestingModule = await Test.createTestingModule({
providers: [
@@ -101,8 +100,8 @@ describe('AuthService', () => {
service = module.get<AuthService>(AuthService);
userService = module.get<UserService>(UserService);
jwtService = module.get<JwtService>(JwtService);
tokenRepo = module.get(getRepositoryToken(RefreshToken));
_jwtService = module.get<JwtService>(JwtService);
_tokenRepo = module.get(getRepositoryToken(RefreshToken));
});
afterEach(() => {
@@ -118,7 +117,7 @@ describe('AuthService', () => {
const result = await service.validateUser('testuser', 'password');
expect(result).toBeDefined();
expect(result).not.toHaveProperty('password');
expect(result.username).toBe('testuser');
expect(result!.username).toBe('testuser');
});
it('should return null if user not found', async () => {
@@ -128,7 +127,7 @@ describe('AuthService', () => {
});
it('should return null if password mismatch', async () => {
bcrypt.compare.mockResolvedValueOnce(false);
(bcrypt.compare as jest.Mock).mockResolvedValueOnce(false);
const result = await service.validateUser('testuser', 'wrongpassword');
expect(result).toBeNull();
});
@@ -139,7 +138,7 @@ describe('AuthService', () => {
mockTokenRepo.create.mockReturnValue({ id: 1 });
mockTokenRepo.save.mockResolvedValue({ id: 1 });
const result = await service.login(mockUser);
const result = await service.login(mockUser as User);
expect(result).toHaveProperty('access_token');
expect(result).toHaveProperty('refresh_token');
@@ -161,8 +160,9 @@ describe('AuthService', () => {
};
const result = await service.register(dto);
const createMock = userService.create as jest.Mock;
expect(result).toBeDefined();
expect(userService.create).toHaveBeenCalled();
expect(createMock).toHaveBeenCalled();
});
});
@@ -198,5 +198,43 @@ describe('AuthService', () => {
UnauthorizedException
);
});
it('should allow refresh within 30s grace period if already revoked', async () => {
const updatedAt = new Date(Date.now() - 5000); // 5 seconds ago
const mockStoredToken = {
tokenHash: 'somehash',
isRevoked: true,
updatedAt: updatedAt,
replacedByToken: 'new_token_hash',
expiresAt: new Date(Date.now() + 10000),
};
mockTokenRepo.findOne.mockResolvedValue(mockStoredToken);
(userService.findOne as jest.Mock).mockResolvedValue(mockUser);
mockTokenRepo.create.mockReturnValue({ token_id: 2 });
mockTokenRepo.save.mockResolvedValue({ token_id: 2 });
const result = await service.refreshToken(1, 'valid_refresh_token');
expect(result.access_token).toBeDefined();
expect(result.refresh_token).toBeDefined();
// Should not call revokeAllUserTokens
expect(mockTokenRepo.update).not.toHaveBeenCalled();
});
it('should throw UnauthorizedException if token revoked more than 30s ago', async () => {
const updatedAt = new Date(Date.now() - 35000); // 35 seconds ago
const mockStoredToken = {
tokenHash: 'somehash',
isRevoked: true,
updatedAt: updatedAt,
replacedByToken: 'new_token_hash',
expiresAt: new Date(Date.now() + 10000),
};
mockTokenRepo.findOne.mockResolvedValue(mockStoredToken);
await expect(service.refreshToken(1, 'revoked_token')).rejects.toThrow(
UnauthorizedException
);
});
});
});
+50 -12
View File
@@ -9,6 +9,7 @@ import {
UnauthorizedException,
Inject,
BadRequestException,
Logger,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
@@ -27,6 +28,8 @@ import { RefreshToken } from './entities/refresh-token.entity'; // [P2-2]
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(
private userService: UserService,
private jwtService: JwtService,
@@ -40,8 +43,8 @@ export class AuthService {
) {}
// 1. ตรวจสอบ Username/Password
async validateUser(username: string, pass: string): Promise<any> {
console.log(`🔍 Checking login for: ${username}`);
async validateUser(username: string, pass: string): Promise<User | null> {
this.logger.log(`🔍 Checking login for: ${username}`);
const user = await this.usersRepository
.createQueryBuilder('user')
.addSelect('user.password')
@@ -51,7 +54,7 @@ export class AuthService {
.getOne();
if (!user) {
console.log('❌ User not found in database');
this.logger.warn('❌ User not found in database');
return null;
}
@@ -75,7 +78,6 @@ export class AuthService {
derivedRole = 'DC';
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { password, ...result } = user;
return { ...result, role: derivedRole };
@@ -121,7 +123,10 @@ export class AuthService {
}
// [P2-2] Store Refresh Token Logic
private async storeRefreshToken(userId: number, token: string) {
private async storeRefreshToken(
userId: number,
token: string
): Promise<void> {
// Hash token before storing for security
const hash = crypto.createHash('sha256').update(token).digest('hex');
const expiresInDays = 7; // Should match JWT_REFRESH_EXPIRATION
@@ -157,7 +162,10 @@ export class AuthService {
}
// 4. Refresh Token: ตรวจสอบและออก Token ใหม่ (Rotation)
async refreshToken(userId: number, refreshToken: string) {
async refreshToken(
userId: number,
refreshToken: string
): Promise<{ access_token: string; refresh_token: string }> {
// Hash incoming token to match with DB
const hash = crypto.createHash('sha256').update(refreshToken).digest('hex');
@@ -171,9 +179,37 @@ export class AuthService {
}
if (storedToken.isRevoked) {
// Possible token theft! Invalidate all user tokens family
await this.revokeAllUserTokens(userId);
throw new UnauthorizedException('Refresh token revoked - Security alert');
// [P2-2.1] Grace period for Token Rotation (30 seconds)
// ป้องกัน Race Condition เมื่อ Frontend ส่ง Refresh Request ซ้อนกันในชั่วพริบตา
const now = new Date();
const revokedAt = new Date(storedToken.updatedAt);
const diffMs = now.getTime() - revokedAt.getTime();
this.logger.debug(`[DEBUG-TOKEN] user=${userId}`);
this.logger.debug(`[DEBUG-TOKEN] now=${now.toISOString()}`);
this.logger.debug(
`[DEBUG-TOKEN] updatedAt=${storedToken.updatedAt ? new Date(storedToken.updatedAt).toISOString() : 'NULL'}`
);
this.logger.debug(`[DEBUG-TOKEN] diffMs=${diffMs}`);
this.logger.debug(
`[DEBUG-TOKEN] replacedBy=${storedToken.replacedByToken ? 'YES(HASHED)' : 'NULL'}`
);
if (diffMs <= 30000 && storedToken.replacedByToken) {
this.logger.warn(
`Refresh token reuse detected within grace period (${diffMs}ms) for user ${userId}. Allowing another rotation.`
);
// ไม่ต้อง revokeAllUserTokens และอนุญาตให้ทำงานต่อด้านล่างเพื่อออก Token ชุดใหม่
} else {
// Possible token theft! Invalidate all user tokens family
await this.revokeAllUserTokens(userId);
this.logger.error(
`Refresh token revoked - Security alert for user ${userId}. All tokens invalidated.`
);
throw new UnauthorizedException(
'Refresh token revoked - Security alert'
);
}
}
if (storedToken.expiresAt < new Date()) {
@@ -205,8 +241,10 @@ export class AuthService {
.update(newRefreshToken)
.digest('hex');
// [P2-2] Mark old token as revoked and rotated
storedToken.isRevoked = true;
storedToken.replacedByToken = newHash;
storedToken.updatedAt = new Date(); // Fallback: Manually update instead of relying solely on @UpdateDateColumn
await this.refreshTokenRepository.save(storedToken);
// Save NEW token
@@ -219,7 +257,7 @@ export class AuthService {
}
// [P2-2] Helper: Revoke all tokens for a user (Security Measure)
private async revokeAllUserTokens(userId: number) {
private async revokeAllUserTokens(userId: number): Promise<void> {
await this.refreshTokenRepository.update(
{ userId, isRevoked: false },
{ isRevoked: true }
@@ -230,7 +268,7 @@ export class AuthService {
async logout(userId: number, accessToken: string, refreshToken?: string) {
// Blacklist Access Token
try {
const decoded = this.jwtService.decode(accessToken);
const decoded = this.jwtService.decode<{ exp: number }>(accessToken);
if (decoded && decoded.exp) {
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
@@ -241,7 +279,7 @@ export class AuthService {
);
}
}
} catch (error) {
} catch {
// Ignore decoding error
}
+3 -2
View File
@@ -17,7 +17,6 @@ import { RequirePermission } from '../common/decorators/require-permission.decor
@Controller('correspondences')
@UseGuards(JwtAuthGuard) // Step 1: Authenticate user
export class CorrespondenceController {
// ตัวอย่าง 1: Single Permission
@Post()
@UseGuards(PermissionsGuard) // Step 2: Check permissions
@@ -63,7 +62,6 @@ Permissions guard จะ extract scope จาก request params/body/query:
@Controller('projects/:projectId/correspondences')
@UseGuards(JwtAuthGuard)
export class ProjectCorrespondenceController {
@Post()
@UseGuards(PermissionsGuard)
@RequirePermission('correspondence.create')
@@ -99,6 +97,7 @@ export class ProjectCorrespondenceController {
Permission ใน database ต้องเป็นรูปแบบ: `{subject}.{action}`
ตัวอย่าง:
- `correspondence.create`
- `correspondence.view`
- `correspondence.edit`
@@ -110,11 +109,13 @@ Permission ใน database ต้องเป็นรูปแบบ: `{subject
## Testing
Run unit tests:
```bash
npm run test -- ability.factory.spec
```
Expected output:
```
✓ should grant all permissions for global admin
✓ should grant permissions for matching organization
@@ -3,6 +3,7 @@ import {
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
@@ -28,6 +29,9 @@ export class RefreshToken {
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
@Column({ name: 'replaced_by_token', nullable: true, length: 255 })
replacedByToken?: string; // For rotation support
@@ -12,6 +12,14 @@ import {
Subjects,
} from '../casl/ability.factory';
import { PERMISSIONS_KEY } from '../../decorators/require-permission.decorator';
import { User } from '../../../modules/user/entities/user.entity';
interface RequestWithUser {
user?: User;
params: Record<string, string>;
body: Record<string, unknown>;
query: Record<string, unknown>;
}
@Injectable()
export class PermissionsGuard implements CanActivate {
@@ -20,7 +28,7 @@ export class PermissionsGuard implements CanActivate {
private abilityFactory: AbilityFactory
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
canActivate(context: ExecutionContext): boolean {
// Get required permissions from decorator metadata
const requiredPermissions = this.reflector.getAllAndOverride<string[]>(
PERMISSIONS_KEY,
@@ -32,7 +40,7 @@ export class PermissionsGuard implements CanActivate {
return true;
}
const request = context.switchToHttp().getRequest();
const request = context.switchToHttp().getRequest<RequestWithUser>();
const user = request.user;
if (!user) {
@@ -24,7 +24,7 @@ export class JwtRefreshStrategy extends PassportStrategy(
});
}
async validate(req: Request, payload: JwtPayload) {
validate(req: Request, payload: JwtPayload) {
const refreshToken = ExtractJwt.fromAuthHeaderAsBearerToken()(req);
return {
...payload,
@@ -21,7 +21,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
configService: ConfigService,
private userService: UserService,
@Inject(CACHE_MANAGER) private cacheManager: Cache,
@Inject(CACHE_MANAGER) private cacheManager: Cache
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
@@ -38,7 +38,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
// 2. ตรวจสอบว่า Token นี้อยู่ใน Redis Blacklist หรือไม่
const isBlacklisted = await this.cacheManager.get(
`blacklist:token:${token}`,
`blacklist:token:${token}`
);
if (isBlacklisted) {
throw new UnauthorizedException('Token has been revoked (Logged out)');
+2 -2
View File
@@ -8,8 +8,8 @@ export default registerAs('redis', () => ({
// ใช้ค่า Default 'cache' ถ้าหาไม่เจอ
host: process.env.REDIS_HOST || 'cache',
// ✅ Fix: ใช้ || '6379' เพื่อให้มั่นใจว่าเป็น string ก่อนเข้า parseInt
port: parseInt(process.env.REDIS_PORT || '6379', 10),
port: Number(process.env.REDIS_PORT || '6379'),
// ✅ Fix: ใช้ || '3600' เพื่อให้มั่นใจว่าเป็น string
ttl: parseInt(process.env.REDIS_TTL || '3600', 10),
ttl: Number(process.env.REDIS_TTL || '3600'),
// password: process.env.REDIS_PASSWORD,
}));
@@ -15,11 +15,15 @@ export interface CircuitBreakerOptions {
*/
export function UseCircuitBreaker(options: CircuitBreakerOptions = {}) {
return function (
target: any,
target: object,
propertyKey: string,
descriptor: PropertyDescriptor
descriptor: TypedPropertyDescriptor<
(...args: unknown[]) => Promise<unknown>
>
) {
const originalMethod = descriptor.value;
if (!originalMethod) return;
const logger = new Logger('CircuitBreakerDecorator');
// สร้าง Opossum Circuit Breaker Instance
@@ -39,7 +43,7 @@ export function UseCircuitBreaker(options: CircuitBreakerOptions = {}) {
breaker.fallback(options.fallback);
}
descriptor.value = async function (...args: unknown[]) {
descriptor.value = async function (this: unknown, ...args: unknown[]) {
// ✅ ใช้ .fire โดยส่ง this context ให้ถูกต้อง
return breaker.fire.apply(breaker, [this, ...args]);
};
@@ -1,6 +1,5 @@
// File: src/common/decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { User } from '../../modules/user/entities/user.entity';
/**
* Decorator สำหรับดึงข้อมูล User ปัจจุบันจาก Request Object
@@ -12,8 +11,8 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common';
*/
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const request = ctx.switchToHttp().getRequest<{ user?: User }>();
// request.user ถูก set โดย Passport/JwtStrategy
return request.user;
},
}
);
@@ -16,14 +16,16 @@ export interface RetryOptions {
*/
export function Retry(options: RetryOptions = {}) {
return function (
target: any,
target: object,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
const originalMethod = descriptor.value as (
...args: unknown[]
) => Promise<unknown>;
const logger = new Logger('RetryDecorator');
descriptor.value = async function (...args: unknown[]) {
descriptor.value = async function (this: unknown, ...args: unknown[]) {
return retry(
// ✅ ระบุ Type ให้กับ bail และ attempt เพื่อแก้ Implicit any
async (bail: (e: Error) => void, attempt: number) => {
@@ -1,9 +1,7 @@
// Mock uuid module to avoid ESM import issue with uuid@13
jest.mock('uuid', () => ({
validate: (str: string) =>
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
str
),
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(str),
v7: () => '01912345-6789-7abc-8def-0123456789ab',
}));
@@ -12,7 +12,7 @@ export class FileCleanupService {
constructor(
@InjectRepository(Attachment)
private attachmentRepository: Repository<Attachment>,
private attachmentRepository: Repository<Attachment>
) {}
/**
@@ -39,7 +39,7 @@ export class FileCleanupService {
}
this.logger.log(
`Found ${expiredAttachments.length} expired files. Deleting...`,
`Found ${expiredAttachments.length} expired files. Deleting...`
);
let deletedCount = 0;
@@ -64,7 +64,7 @@ export class FileCleanupService {
}
this.logger.log(
`Cleanup complete. Deleted: ${deletedCount}, Failed: ${errors.length}`,
`Cleanup complete. Deleted: ${deletedCount}, Failed: ${errors.length}`
);
}
}
@@ -48,7 +48,7 @@ describe('FileStorageController', () => {
const mockReq = {
user: { user_id: 1, username: 'testuser' },
} as unknown as RequestWithUser;
const result = await controller.uploadFile(mockFile, mockReq);
const _result = await controller.uploadFile(mockFile, mockReq);
expect(mockFileStorageService.upload).toHaveBeenCalledWith(mockFile, 1);
});
@@ -84,9 +84,9 @@ describe('FileStorageService', () => {
it('should save file to temp and create DB record', async () => {
const result = await service.upload(mockFile, 1);
expect(fs.writeFile).toHaveBeenCalled();
expect(attachmentRepo.create).toHaveBeenCalled();
expect(attachmentRepo.save).toHaveBeenCalled();
expect(fs.writeFile as unknown as jest.Mock).toHaveBeenCalled();
expect(attachmentRepo.create as jest.Mock).toHaveBeenCalled();
expect(attachmentRepo.save as jest.Mock).toHaveBeenCalled();
expect(result).toBeDefined();
});
@@ -116,9 +116,9 @@ describe('FileStorageService', () => {
await service.commit(tempIds);
expect(fs.ensureDir).toHaveBeenCalled();
expect(fs.move).toHaveBeenCalled();
expect(attachmentRepo.save).toHaveBeenCalled();
expect(fs.ensureDir as unknown as jest.Mock).toHaveBeenCalled();
expect(fs.move as unknown as jest.Mock).toHaveBeenCalled();
expect(attachmentRepo.save as jest.Mock).toHaveBeenCalled();
});
it('should show warning if file counts mismatch', async () => {
@@ -135,8 +135,8 @@ describe('FileStorageService', () => {
await service.delete(1, 1);
expect(fs.remove).toHaveBeenCalled();
expect(attachmentRepo.remove).toHaveBeenCalled();
expect(fs.remove as unknown as jest.Mock).toHaveBeenCalled();
expect(attachmentRepo.remove as jest.Mock).toHaveBeenCalled();
});
it('should throw ForbiddenException if user does not own file', async () => {
@@ -22,14 +22,14 @@ export class MaintenanceModeGuard implements CanActivate {
constructor(
private reflector: Reflector,
@Inject(CACHE_MANAGER) private cacheManager: Cache,
@Inject(CACHE_MANAGER) private cacheManager: Cache
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
// 1. ตรวจสอบว่า Route นี้ได้รับการยกเว้นหรือไม่ (Bypass)
const isBypassed = this.reflector.getAllAndOverride<boolean>(
BYPASS_MAINTENANCE_KEY,
[context.getHandler(), context.getClass()],
[context.getHandler(), context.getClass()]
);
if (isBypassed) {
@@ -43,12 +43,12 @@ export class MaintenanceModeGuard implements CanActivate {
// ถ้า Redis มีค่าเป็น true หรือ string "true" ให้ Block
if (isMaintenanceOn === true || isMaintenanceOn === 'true') {
// (Optional) 3. ตรวจสอบ Backdoor Header สำหรับ Admin (ถ้าต้องการ Bypass ฉุกเฉิน)
const request = context.switchToHttp().getRequest();
const request = context.switchToHttp().getRequest<{ url: string }>();
// const bypassToken = request.headers['x-maintenance-bypass'];
// if (bypassToken === process.env.ADMIN_SECRET) return true;
this.logger.warn(
`Blocked request to ${request.url} due to Maintenance Mode`,
`Blocked request to ${request.url} due to Maintenance Mode`
);
throw new ServiceUnavailableException({
+7 -1
View File
@@ -7,6 +7,11 @@ import {
import { Reflector } from '@nestjs/core';
import { PERMISSIONS_KEY } from '../decorators/require-permission.decorator';
import { UserService } from '../../modules/user/user.service';
import { User } from '../../modules/user/entities/user.entity';
interface RequestWithUser {
user?: User;
}
@Injectable()
export class RbacGuard implements CanActivate {
@@ -28,7 +33,8 @@ export class RbacGuard implements CanActivate {
}
// 2. ดึง User จาก Request (ที่ JwtAuthGuard แปะไว้ให้)
const { user } = context.switchToHttp().getRequest();
const request = context.switchToHttp().getRequest<RequestWithUser>();
const user = request.user;
if (!user) {
throw new ForbiddenException('User not found in request');
}
@@ -51,11 +51,12 @@ export class AuditLogInterceptor implements NestInterceptor {
return next.handle();
}
const request = context.switchToHttp().getRequest<Request>();
const user = (request as Request & { user?: User }).user;
const rawIp: string | string[] | undefined =
request.ip ?? request.socket.remoteAddress;
const ip: string | undefined = Array.isArray(rawIp) ? rawIp[0] : rawIp;
const request = context
.switchToHttp()
.getRequest<Request & { user?: User }>();
const user = request.user;
const rawIp = request.ip ?? request.socket.remoteAddress;
const ip = (Array.isArray(rawIp) ? rawIp[0] : rawIp) as string | undefined;
const userAgent = request.get('user-agent');
return next.handle().pipe(
@@ -8,7 +8,6 @@ import {
Inject,
Injectable,
NestInterceptor,
ConflictException,
Logger,
} from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
@@ -25,8 +24,8 @@ export class IdempotencyInterceptor implements NestInterceptor {
async intercept(
context: ExecutionContext,
next: CallHandler,
): Promise<Observable<any>> {
next: CallHandler
): Promise<Observable<unknown>> {
const request = context.switchToHttp().getRequest<Request>();
const method = request.method;
@@ -46,24 +45,32 @@ export class IdempotencyInterceptor implements NestInterceptor {
if (cachedResponse) {
this.logger.warn(
`Idempotency key detected: ${idempotencyKey}. Returning cached response.`,
`Idempotency key detected: ${idempotencyKey}. Returning cached response.`
);
return of(cachedResponse);
}
return next.handle().pipe(
tap(async (response) => {
try {
await this.cacheManager.set(cacheKey, response, 86400 * 1000);
} catch (err) {
// ✅ Fix: ตรวจสอบว่า err เป็น Error Object หรือไม่ ก่อนเรียก .stack
const errorMessage = err instanceof Error ? err.stack : String(err);
this.logger.error(
`Failed to cache idempotency key ${idempotencyKey}`,
errorMessage,
);
}
}),
tap((response) => {
void this.cacheResponse(cacheKey, response, idempotencyKey);
})
);
}
private async cacheResponse(
cacheKey: string,
response: unknown,
idempotencyKey: string
): Promise<void> {
try {
await this.cacheManager.set(cacheKey, response, 86400 * 1000);
} catch (err) {
// ✅ Fix: ตรวจสอบว่า err เป็น Error Object หรือไม่ ก่อนเรียก .stack
const errorMessage = err instanceof Error ? err.stack : String(err);
this.logger.error(
`Failed to cache idempotency key ${idempotencyKey}`,
errorMessage
);
}
}
}
@@ -8,35 +8,43 @@ import {
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { Request, Response } from 'express';
import { MetricsService } from '../../modules/monitoring/services/metrics.service';
interface RequestWithRoute extends Request {
route: {
path: string;
};
}
@Injectable()
export class PerformanceInterceptor implements NestInterceptor {
private readonly logger = new Logger(PerformanceInterceptor.name);
constructor(private readonly metricsService: MetricsService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
// ข้ามการวัดผลสำหรับ Endpoint /metrics และ /health เพื่อลด Noise
const req = context.switchToHttp().getRequest();
const req = context.switchToHttp().getRequest<Request>();
if (req.url === '/metrics' || req.url === '/health') {
return next.handle();
}
const method = req.method;
const url = req.route ? req.route.path : req.url; // ใช้ Route path แทน Full URL เพื่อลด Cardinality
const reqWithRoute = req as RequestWithRoute;
const url = reqWithRoute.route ? reqWithRoute.route.path : req.url; // Use Route path if available
const startTime = process.hrtime();
return next.handle().pipe(
tap({
next: (data) => {
this.recordMetrics(context, method, url, startTime, 200); // สมมติ 200 หรือดึงจาก Response จริง
next: () => {
this.recordMetrics(context, method, url || '', startTime, 200);
},
error: (err) => {
error: (err: { status?: number }) => {
const status = err.status || 500;
this.recordMetrics(context, method, url, startTime, status);
},
}),
})
);
}
@@ -48,9 +56,9 @@ export class PerformanceInterceptor implements NestInterceptor {
method: string,
route: string,
startTime: [number, number],
statusCode: number,
statusCode: number
) {
const res = context.switchToHttp().getResponse();
const res = context.switchToHttp().getResponse<Response>();
const finalStatus = res.statusCode || statusCode;
// คำนวณระยะเวลา (Seconds)
@@ -71,7 +79,7 @@ export class PerformanceInterceptor implements NestInterceptor {
route,
status_code: finalStatus.toString(),
},
durationInSeconds,
durationInSeconds
);
// 2. บันทึก Log (Winston JSON) - เฉพาะ Request ที่ช้าเกิน 200ms หรือ Error
@@ -4,9 +4,7 @@ import { ParseUuidPipe } from './parse-uuid.pipe';
// Mock uuid module to avoid ESM import issue with uuid@13
jest.mock('uuid', () => ({
validate: (str: string) =>
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
str
),
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(str),
v7: () => '01912345-6789-7abc-8def-0123456789ab',
}));
@@ -20,8 +20,10 @@ export class CryptoService {
this.key = crypto.scryptSync(secret, 'salt', 32);
}
encrypt(text: string | number | boolean): string {
if (text === null || text === undefined) return text as any;
encrypt(
text: string | number | boolean | null | undefined
): string | null | undefined {
if (text === null || text === undefined) return text;
try {
const stringValue = String(text);
@@ -6,7 +6,7 @@ import { AsyncLocalStorage } from 'async_hooks';
@Injectable({ scope: Scope.DEFAULT })
export class RequestContextService {
private static readonly cls = new AsyncLocalStorage<Map<string, any>>();
private static readonly cls = new AsyncLocalStorage<Map<string, unknown>>();
static run(fn: () => void) {
this.cls.run(new Map(), fn);
@@ -21,7 +21,7 @@ export class RequestContextService {
static get<T>(key: string): T | undefined {
const store = this.cls.getStore();
return store?.get(key);
return store?.get(key) as T | undefined;
}
// Helper methods
@@ -6,9 +6,7 @@ import { UuidResolverService } from './uuid-resolver.service';
// Mock uuid module to avoid ESM import issue with uuid@13
jest.mock('uuid', () => ({
validate: (str: string) =>
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
str
),
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(str),
v7: () => '01912345-6789-7abc-8def-0123456789ab',
}));
+15
View File
@@ -0,0 +1,15 @@
/**
* UUID Guard Utility
* Ensures a string is a valid UUIDv7 (or compatible) before processing.
*/
export const assertUuid = (value: string): string => {
// Regex for UUIDv7 (Project standard uses UUIDv7 BINARY(16))
const uuidRegex =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(value)) {
throw new Error(`Invalid UUID format: ${value}`);
}
return value;
};