251209:1453 Frontend: progress nest = UAT & Bug Fixing
This commit is contained in:
@@ -1,30 +1,86 @@
|
||||
import { Controller, Post, Body, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service.js';
|
||||
import { LoginDto } from './dto/login.dto.js'; // <--- Import DTO
|
||||
import { RegisterDto } from './dto/register.dto.js'; // <--- Import DTO
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private authService: AuthService) {}
|
||||
describe('AuthController', () => {
|
||||
let controller: AuthController;
|
||||
let mockAuthService: Partial<AuthService>;
|
||||
|
||||
@Post('login')
|
||||
// เปลี่ยน @Body() req เป็น @Body() loginDto: LoginDto
|
||||
async login(@Body() loginDto: LoginDto) {
|
||||
const user = await this.authService.validateUser(
|
||||
loginDto.username,
|
||||
loginDto.password,
|
||||
);
|
||||
beforeEach(async () => {
|
||||
mockAuthService = {
|
||||
validateUser: jest.fn(),
|
||||
login: jest.fn(),
|
||||
register: jest.fn(),
|
||||
refreshToken: jest.fn(),
|
||||
logout: jest.fn(),
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
{
|
||||
provide: AuthService,
|
||||
useValue: mockAuthService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
return this.authService.login(user);
|
||||
}
|
||||
controller = module.get<AuthController>(AuthController);
|
||||
});
|
||||
|
||||
@Post('register-admin')
|
||||
// เปลี่ยน @Body() req เป็น @Body() registerDto: RegisterDto
|
||||
async register(@Body() registerDto: RegisterDto) {
|
||||
return this.authService.register(registerDto);
|
||||
}
|
||||
}
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('should return tokens when credentials are valid', async () => {
|
||||
const loginDto = { username: 'test', password: 'password' };
|
||||
const mockUser = { user_id: 1, username: 'test' };
|
||||
const mockTokens = {
|
||||
access_token: 'access_token',
|
||||
refresh_token: 'refresh_token',
|
||||
user: mockUser,
|
||||
};
|
||||
|
||||
(mockAuthService.validateUser as jest.Mock).mockResolvedValue(mockUser);
|
||||
(mockAuthService.login as jest.Mock).mockResolvedValue(mockTokens);
|
||||
|
||||
const result = await controller.login(loginDto);
|
||||
|
||||
expect(mockAuthService.validateUser).toHaveBeenCalledWith(
|
||||
'test',
|
||||
'password'
|
||||
);
|
||||
expect(mockAuthService.login).toHaveBeenCalledWith(mockUser);
|
||||
expect(result).toEqual(mockTokens);
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException when credentials are invalid', async () => {
|
||||
const loginDto = { username: 'test', password: 'wrong' };
|
||||
(mockAuthService.validateUser as jest.Mock).mockResolvedValue(null);
|
||||
|
||||
await expect(controller.login(loginDto)).rejects.toThrow(
|
||||
UnauthorizedException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('register', () => {
|
||||
it('should register a new user', async () => {
|
||||
const registerDto = {
|
||||
username: 'newuser',
|
||||
password: 'password',
|
||||
email: 'test@test.com',
|
||||
display_name: 'Test User',
|
||||
};
|
||||
const mockUser = { user_id: 1, ...registerDto };
|
||||
|
||||
(mockAuthService.register as jest.Mock).mockResolvedValue(mockUser);
|
||||
|
||||
const result = await controller.register(registerDto);
|
||||
|
||||
expect(mockAuthService.register).toHaveBeenCalledWith(registerDto);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,13 +11,15 @@ import {
|
||||
Req,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Delete,
|
||||
Param,
|
||||
} from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { AuthService } from './auth.service.js';
|
||||
import { LoginDto } from './dto/login.dto.js';
|
||||
import { RegisterDto } from './dto/register.dto.js';
|
||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard.js';
|
||||
import { JwtRefreshGuard } from '../guards/jwt-refresh.guard.js';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||
import { JwtRefreshGuard } from '../guards/jwt-refresh.guard';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
@@ -130,4 +132,22 @@ export class AuthController {
|
||||
getProfile(@Req() req: RequestWithUser) {
|
||||
return req.user;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('sessions')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Get active sessions' })
|
||||
@ApiResponse({ status: 200, description: 'List of active sessions' })
|
||||
async getSessions() {
|
||||
return this.authService.getActiveSessions();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete('sessions/:id')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Revoke session' })
|
||||
@ApiResponse({ status: 200, description: 'Session revoked' })
|
||||
async revokeSession(@Param('id') id: string) {
|
||||
return this.authService.revokeSession(parseInt(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,18 @@ import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { User } from '../../modules/user/entities/user.entity';
|
||||
import { RefreshToken } from './entities/refresh-token.entity';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
|
||||
// Mock bcrypt at top level
|
||||
jest.mock('bcrypt', () => ({
|
||||
compare: jest.fn(),
|
||||
hash: jest.fn().mockResolvedValue('hashedpassword'),
|
||||
genSalt: jest.fn().mockResolvedValue('salt'),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
let userService: UserService;
|
||||
@@ -42,6 +51,9 @@ describe('AuthService', () => {
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset bcrypt mocks
|
||||
bcrypt.compare.mockResolvedValue(true);
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AuthService,
|
||||
@@ -63,7 +75,7 @@ describe('AuthService', () => {
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: {
|
||||
get: jest.fn().mockImplementation((key) => {
|
||||
get: jest.fn().mockImplementation((key: string) => {
|
||||
if (key.includes('EXPIRATION')) return '1h';
|
||||
return 'secret';
|
||||
}),
|
||||
@@ -90,17 +102,6 @@ describe('AuthService', () => {
|
||||
userService = module.get<UserService>(UserService);
|
||||
jwtService = module.get<JwtService>(JwtService);
|
||||
tokenRepo = module.get(getRepositoryToken(RefreshToken));
|
||||
|
||||
// Mock bcrypt
|
||||
jest
|
||||
.spyOn(bcrypt, 'compare')
|
||||
.mockImplementation(() => Promise.resolve(true));
|
||||
jest
|
||||
.spyOn(bcrypt, 'hash')
|
||||
.mockImplementation(() => Promise.resolve('hashedpassword'));
|
||||
jest
|
||||
.spyOn(bcrypt, 'genSalt')
|
||||
.mockImplementation(() => Promise.resolve('salt'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -126,9 +127,7 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
it('should return null if password mismatch', async () => {
|
||||
jest
|
||||
.spyOn(bcrypt, 'compare')
|
||||
.mockImplementation(() => Promise.resolve(false));
|
||||
bcrypt.compare.mockResolvedValueOnce(false);
|
||||
const result = await service.validateUser('testuser', 'wrongpassword');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
@@ -19,9 +19,9 @@ import type { Cache } from 'cache-manager';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
import { UserService } from '../../modules/user/user.service.js';
|
||||
import { UserService } from '../../modules/user/user.service';
|
||||
import { User } from '../../modules/user/entities/user.entity';
|
||||
import { RegisterDto } from './dto/register.dto.js';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { RefreshToken } from './entities/refresh-token.entity'; // [P2-2]
|
||||
|
||||
@Injectable()
|
||||
@@ -230,4 +230,43 @@ export class AuthService {
|
||||
|
||||
return { message: 'Logged out successfully' };
|
||||
}
|
||||
|
||||
// [New] Get Active Sessions
|
||||
async getActiveSessions() {
|
||||
// Only return tokens that are NOT revoked and NOT expired
|
||||
const activeTokens = await this.refreshTokenRepository.find({
|
||||
where: {
|
||||
isRevoked: false,
|
||||
},
|
||||
relations: ['user'], // Ensure relations: ['user'] works if RefreshToken entity has relation
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
// Filter expired tokens in memory if query builder is complex, or rely on where clause if possible.
|
||||
// Since we want to return mapped data:
|
||||
return activeTokens
|
||||
.filter((t) => t.expiresAt > now)
|
||||
.map((t) => ({
|
||||
id: t.tokenId.toString(),
|
||||
userId: t.userId,
|
||||
user: {
|
||||
username: t.user?.username || 'Unknown',
|
||||
first_name: t.user?.firstName || '',
|
||||
last_name: t.user?.lastName || '',
|
||||
},
|
||||
deviceName: 'Unknown Device', // Not stored in DB
|
||||
ipAddress: 'Unknown IP', // Not stored in DB
|
||||
lastActive: t.createdAt.toISOString(), // Best approximation
|
||||
isCurrent: false, // Cannot determine isCurrent without current session context match
|
||||
}));
|
||||
}
|
||||
|
||||
// [New] Revoke Session by ID
|
||||
async revokeSession(sessionId: number) {
|
||||
return this.refreshTokenRepository.update(
|
||||
{ tokenId: sessionId },
|
||||
{ isRevoked: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { FileStorageController } from './file-storage.controller';
|
||||
import { FileStorageService } from './file-storage.service';
|
||||
|
||||
describe('FileStorageController', () => {
|
||||
let controller: FileStorageController;
|
||||
let mockFileStorageService: Partial<FileStorageService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockFileStorageService = {
|
||||
upload: jest.fn(),
|
||||
download: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [FileStorageController],
|
||||
providers: [
|
||||
{
|
||||
provide: FileStorageService,
|
||||
useValue: mockFileStorageService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<FileStorageController>(FileStorageController);
|
||||
@@ -15,4 +29,25 @@ describe('FileStorageController', () => {
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
describe('uploadFile', () => {
|
||||
it('should upload a file successfully', async () => {
|
||||
const mockFile = {
|
||||
originalname: 'test.pdf',
|
||||
buffer: Buffer.from('test'),
|
||||
mimetype: 'application/pdf',
|
||||
size: 100,
|
||||
} as Express.Multer.File;
|
||||
|
||||
const mockResult = { attachment_id: 1, originalFilename: 'test.pdf' };
|
||||
(mockFileStorageService.upload as jest.Mock).mockResolvedValue(
|
||||
mockResult
|
||||
);
|
||||
|
||||
const mockReq = { user: { userId: 1, username: 'testuser' } };
|
||||
const result = await controller.uploadFile(mockFile, mockReq as any);
|
||||
|
||||
expect(mockFileStorageService.upload).toHaveBeenCalledWith(mockFile, 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,8 +18,8 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { FileStorageService } from './file-storage.service.js';
|
||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard.js';
|
||||
import { FileStorageService } from './file-storage.service';
|
||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||
|
||||
// Interface เพื่อระบุ Type ของ Request ที่ผ่าน JwtAuthGuard มาแล้ว
|
||||
interface RequestWithUser {
|
||||
@@ -47,10 +47,10 @@ export class FileStorageController {
|
||||
/(pdf|msword|openxmlformats|zip|octet-stream|image|jpeg|png)/,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
})
|
||||
)
|
||||
file: Express.Multer.File,
|
||||
@Request() req: RequestWithUser,
|
||||
@Request() req: RequestWithUser
|
||||
) {
|
||||
// ส่ง userId จาก Token ไปด้วย
|
||||
return this.fileStorageService.upload(file, req.user.userId);
|
||||
@@ -63,7 +63,7 @@ export class FileStorageController {
|
||||
@Get(':id/download')
|
||||
async downloadFile(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@Res({ passthrough: true }) res: Response
|
||||
): Promise<StreamableFile> {
|
||||
const { stream, attachment } = await this.fileStorageService.download(id);
|
||||
|
||||
@@ -87,7 +87,7 @@ export class FileStorageController {
|
||||
@Delete(':id')
|
||||
async deleteFile(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Request() req: RequestWithUser,
|
||||
@Request() req: RequestWithUser
|
||||
) {
|
||||
// ส่ง userId ไปด้วยเพื่อตรวจสอบความเป็นเจ้าของ
|
||||
await this.fileStorageService.delete(id, req.user.userId);
|
||||
|
||||
Reference in New Issue
Block a user