260322:1648 Correct Coresspondence / Doing RFA / Correct CI
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)');
|
||||
|
||||
@@ -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,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',
|
||||
}));
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user