diff --git a/nestjs-BE/server/package-lock.json b/nestjs-BE/server/package-lock.json index 65779b59..55ec2be7 100644 --- a/nestjs-BE/server/package-lock.json +++ b/nestjs-BE/server/package-lock.json @@ -25,6 +25,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "dotenv": "^16.3.1", + "lodash": "^4.17.21", "mongoose": "^8.0.2", "passport": "^0.6.0", "passport-jwt": "^4.0.1", @@ -38,6 +39,7 @@ "@nestjs/testing": "^10.0.0", "@types/express": "^4.17.17", "@types/jest": "^29.5.2", + "@types/lodash": "^4.17.15", "@types/multer": "^1.4.11", "@types/node": "^20.3.1", "@types/passport-jwt": "^3.0.13", @@ -3916,6 +3918,12 @@ "@types/node": "*" } }, + "node_modules/@types/lodash": { + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.15.tgz", + "integrity": "sha512-w/P33JFeySuhN6JLkysYUK2gEmy9kHHFN7E8ro0tkfmlDOgxBDzWEZ/J8cWA+fHqFevpswDTFZnDx+R9lbL6xw==", + "dev": true + }, "node_modules/@types/luxon": { "version": "3.3.6", "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.6.tgz", diff --git a/nestjs-BE/server/package.json b/nestjs-BE/server/package.json index 1d751a11..30530bbf 100644 --- a/nestjs-BE/server/package.json +++ b/nestjs-BE/server/package.json @@ -36,6 +36,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "dotenv": "^16.3.1", + "lodash": "^4.17.21", "mongoose": "^8.0.2", "passport": "^0.6.0", "passport-jwt": "^4.0.1", @@ -49,6 +50,7 @@ "@nestjs/testing": "^10.0.0", "@types/express": "^4.17.17", "@types/jest": "^29.5.2", + "@types/lodash": "^4.17.15", "@types/multer": "^1.4.11", "@types/node": "^20.3.1", "@types/passport-jwt": "^3.0.13", diff --git a/nestjs-BE/server/src/auth/auth.module.ts b/nestjs-BE/server/src/auth/auth.module.ts index 2a6b5b0a..1622c181 100644 --- a/nestjs-BE/server/src/auth/auth.module.ts +++ b/nestjs-BE/server/src/auth/auth.module.ts @@ -6,7 +6,6 @@ import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { JwtStrategy } from './strategies/jwt.strategy'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; -import { MatchUserProfileGuard } from './guards/match-user-profile.guard'; import { IsProfileInSpaceGuard } from './guards/is-profile-in-space.guard'; import { UsersModule } from '../users/users.module'; import { ProfilesModule } from '../profiles/profiles.module'; @@ -27,12 +26,10 @@ import { ProfileSpaceModule } from '../profile-space/profile-space.module'; AuthService, JwtStrategy, { provide: APP_GUARD, useClass: JwtAuthGuard }, - MatchUserProfileGuard, IsProfileInSpaceGuard, ], exports: [ AuthService, - MatchUserProfileGuard, ProfilesModule, IsProfileInSpaceGuard, ProfileSpaceModule, diff --git a/nestjs-BE/server/src/invite-codes/invite-codes.controller.spec.ts b/nestjs-BE/server/src/invite-codes/invite-codes.controller.spec.ts index 41882343..0c6b6961 100644 --- a/nestjs-BE/server/src/invite-codes/invite-codes.controller.spec.ts +++ b/nestjs-BE/server/src/invite-codes/invite-codes.controller.spec.ts @@ -8,8 +8,8 @@ import { Test, TestingModule } from '@nestjs/testing'; import { InviteCode, Space } from '@prisma/client'; import { InviteCodesController } from './invite-codes.controller'; import { InviteCodesService } from './invite-codes.service'; -import { MatchUserProfileGuard } from '../auth/guards/match-user-profile.guard'; import { ProfilesService } from '../profiles/profiles.service'; +import { MatchUserProfileGuard } from '../profiles/guards/match-user-profile.guard'; import { IsProfileInSpaceGuard } from '../auth/guards/is-profile-in-space.guard'; import { ProfileSpaceService } from '../profile-space/profile-space.service'; diff --git a/nestjs-BE/server/src/invite-codes/invite-codes.controller.ts b/nestjs-BE/server/src/invite-codes/invite-codes.controller.ts index a0796bcc..4acf7067 100644 --- a/nestjs-BE/server/src/invite-codes/invite-codes.controller.ts +++ b/nestjs-BE/server/src/invite-codes/invite-codes.controller.ts @@ -9,8 +9,8 @@ import { import { ApiTags, ApiOperation, ApiResponse, ApiBody } from '@nestjs/swagger'; import { InviteCodesService } from './invite-codes.service'; import { CreateInviteCodeDto } from './dto/create-invite-code.dto'; -import { MatchUserProfileGuard } from '../auth/guards/match-user-profile.guard'; import { IsProfileInSpaceGuard } from '../auth/guards/is-profile-in-space.guard'; +import { MatchUserProfileGuard } from '../profiles/guards/match-user-profile.guard'; @Controller('inviteCodes') @ApiTags('inviteCodes') diff --git a/nestjs-BE/server/src/invite-codes/invite-codes.module.ts b/nestjs-BE/server/src/invite-codes/invite-codes.module.ts index 2467d984..1e1e583c 100644 --- a/nestjs-BE/server/src/invite-codes/invite-codes.module.ts +++ b/nestjs-BE/server/src/invite-codes/invite-codes.module.ts @@ -3,9 +3,10 @@ import { InviteCodesService } from './invite-codes.service'; import { InviteCodesController } from './invite-codes.controller'; import { SpacesModule } from '../spaces/spaces.module'; import { AuthModule } from '../auth/auth.module'; +import { ProfilesModule } from '../profiles/profiles.module'; @Module({ - imports: [AuthModule, SpacesModule], + imports: [AuthModule, SpacesModule, ProfilesModule], controllers: [InviteCodesController], providers: [InviteCodesService], }) diff --git a/nestjs-BE/server/src/profiles/dto/update-profile.dto.ts b/nestjs-BE/server/src/profiles/dto/update-profile.dto.ts index 5ab2b2b4..2e6e908a 100644 --- a/nestjs-BE/server/src/profiles/dto/update-profile.dto.ts +++ b/nestjs-BE/server/src/profiles/dto/update-profile.dto.ts @@ -1,24 +1,21 @@ -import { PartialType } from '@nestjs/mapped-types'; import { ApiProperty } from '@nestjs/swagger'; -import { MaxLength } from 'class-validator'; -import { CreateProfileDto } from './create-profile.dto'; +import { IsOptional, MaxLength } from 'class-validator'; import { MAX_NAME_LENGTH } from '../../config/constants'; -export class UpdateProfileDto extends PartialType(CreateProfileDto) { +export class UpdateProfileDto { + @IsOptional() @MaxLength(MAX_NAME_LENGTH) @ApiProperty({ example: 'new nickname', description: 'Updated nickname of the profile', required: false, }) - nickname?: string; + nickname: string; @ApiProperty({ example: 'new image.png', description: 'Updated Profile image file', required: false, }) - image?: string; - - uuid?: string; + image: Express.Multer.File; } diff --git a/nestjs-BE/server/src/profiles/entities/profile.entity.ts b/nestjs-BE/server/src/profiles/entities/profile.entity.ts deleted file mode 100644 index b4a8829d..00000000 --- a/nestjs-BE/server/src/profiles/entities/profile.entity.ts +++ /dev/null @@ -1 +0,0 @@ -export class Profile {} diff --git a/nestjs-BE/server/src/profiles/guards/match-user-profile.guard.spec.ts b/nestjs-BE/server/src/profiles/guards/match-user-profile.guard.spec.ts new file mode 100644 index 00000000..7a437869 --- /dev/null +++ b/nestjs-BE/server/src/profiles/guards/match-user-profile.guard.spec.ts @@ -0,0 +1,116 @@ +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { MatchUserProfileGuard } from './match-user-profile.guard'; +import { ProfilesService } from '../profiles.service'; + +import type { ExecutionContext } from '@nestjs/common'; +import type { TestingModule } from '@nestjs/testing'; +import type { Profile } from '@prisma/client'; + +describe('MatchUserProfileGuard', () => { + const userUuid = 'user uuid'; + const profileUuid = 'profile uuid'; + let guard: MatchUserProfileGuard; + let profilesService: ProfilesService; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [{ provide: ProfilesService, useValue: {} }], + }).compile(); + + profilesService = module.get(ProfilesService); + + guard = new MatchUserProfileGuard(profilesService); + }); + + it('throw bad request when profile uuid not include', async () => { + const context = createExecutionContext({ user: { uuid: userUuid } }); + + await expect(guard.canActivate(context)).rejects.toThrow( + BadRequestException, + ); + }); + + it('throw bad request when user uuid not include (body)', async () => { + const context = createExecutionContext({ + body: { profile_uuid: profileUuid }, + }); + + await expect(guard.canActivate(context)).rejects.toThrow( + BadRequestException, + ); + }); + + it('throw bad request when user uuid not include (query)', async () => { + const context = createExecutionContext({ + query: { profile_uuid: profileUuid }, + }); + + await expect(guard.canActivate(context)).rejects.toThrow( + BadRequestException, + ); + }); + + it('throw bad request when user uuid not include (params)', async () => { + const context = createExecutionContext({ + params: { profile_uuid: profileUuid }, + }); + + await expect(guard.canActivate(context)).rejects.toThrow( + BadRequestException, + ); + }); + + it('throw forbidden when profile not found', async () => { + const context = createExecutionContext({ + user: { uuid: userUuid }, + params: { profile_uuid: profileUuid }, + }); + profilesService.findProfileByProfileUuid = jest.fn(async () => null); + + await expect(guard.canActivate(context)).rejects.toThrow( + ForbiddenException, + ); + }); + + it("throw forbidden when profile not user's profile", async () => { + const context = createExecutionContext({ + user: { uuid: userUuid }, + params: { profile_uuid: profileUuid }, + }); + profilesService.findProfileByProfileUuid = jest.fn( + async () => ({ userUuid: 'other user uuid' }) as Profile, + ); + + await expect(guard.canActivate(context)).rejects.toThrow( + ForbiddenException, + ); + }); + + it('return true when valid user', async () => { + const context = createExecutionContext({ + user: { uuid: userUuid }, + params: { profile_uuid: profileUuid }, + }); + profilesService.findProfileByProfileUuid = jest.fn( + async () => ({ userUuid }) as Profile, + ); + + await expect(guard.canActivate(context)).resolves.toBeTruthy(); + }); +}); + +function createExecutionContext(request: object): ExecutionContext { + const innerRequest = { + body: {}, + params: {}, + query: {}, + ...request, + }; + const context: ExecutionContext = { + switchToHttp: () => ({ + getRequest: () => innerRequest, + }), + } as ExecutionContext; + return context; +} diff --git a/nestjs-BE/server/src/auth/guards/match-user-profile.guard.ts b/nestjs-BE/server/src/profiles/guards/match-user-profile.guard.ts similarity index 95% rename from nestjs-BE/server/src/auth/guards/match-user-profile.guard.ts rename to nestjs-BE/server/src/profiles/guards/match-user-profile.guard.ts index ff9f01f9..0a2471f2 100644 --- a/nestjs-BE/server/src/auth/guards/match-user-profile.guard.ts +++ b/nestjs-BE/server/src/profiles/guards/match-user-profile.guard.ts @@ -13,7 +13,7 @@ export class MatchUserProfileGuard implements CanActivate { async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); - const userUuid = request.user.uuid; + const userUuid = request.user?.uuid; const profileUuid = request.body.profile_uuid || request.query.profile_uuid || diff --git a/nestjs-BE/server/src/profiles/profiles.controller.spec.ts b/nestjs-BE/server/src/profiles/profiles.controller.spec.ts index e2389df1..329477c2 100644 --- a/nestjs-BE/server/src/profiles/profiles.controller.spec.ts +++ b/nestjs-BE/server/src/profiles/profiles.controller.spec.ts @@ -1,12 +1,10 @@ -import { - ForbiddenException, - HttpStatus, - NotFoundException, -} from '@nestjs/common'; +import { HttpStatus } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ProfilesController } from './profiles.controller'; import { ProfilesService } from './profiles.service'; +import type { UpdateProfileDto } from './dto/update-profile.dto'; + describe('ProfilesController', () => { let controller: ProfilesController; let profilesService: ProfilesService; @@ -14,15 +12,7 @@ describe('ProfilesController', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [ProfilesController], - providers: [ - { - provide: ProfilesService, - useValue: { - findProfileByUserUuid: jest.fn(), - updateProfile: jest.fn(), - }, - }, - ], + providers: [{ provide: ProfilesService, useValue: {} }], }).compile(); controller = module.get(ProfilesController); @@ -40,9 +30,7 @@ describe('ProfilesController', () => { nickname: 'test nickname', }; - jest - .spyOn(profilesService, 'findProfileByUserUuid') - .mockResolvedValue(testProfile); + profilesService.findProfileByUserUuid = jest.fn(async () => testProfile); const response = controller.findProfileByUserUuid(userUuidMock); @@ -55,41 +43,28 @@ describe('ProfilesController', () => { userUuidMock, ); }); - - it('not found profile', async () => { - jest - .spyOn(profilesService, 'findProfileByUserUuid') - .mockRejectedValue(new NotFoundException()); - - const response = controller.findProfileByUserUuid(userUuidMock); - - await expect(response).rejects.toThrow(NotFoundException); - }); }); describe('update', () => { const imageMock = {} as Express.Multer.File; - const userUuidMock = 'user uuid'; + const profileUuidMock = 'profile uuid'; const bodyMock = { - uuid: 'profile test uuid', nickname: 'test nickname', - }; + } as UpdateProfileDto; it('updated profile', async () => { const testProfile = { - uuid: 'profile test uuid', - userUuid: userUuidMock, + uuid: profileUuidMock, + userUuid: 'user uuid', image: 'www.test.com/image', nickname: 'test nickname', }; - jest - .spyOn(profilesService, 'updateProfile') - .mockResolvedValue(testProfile); + profilesService.updateProfile = jest.fn(async () => testProfile); const response = controller.updateProfile( imageMock, - userUuidMock, + profileUuidMock, bodyMock, ); @@ -99,25 +74,10 @@ describe('ProfilesController', () => { data: testProfile, }); expect(profilesService.updateProfile).toHaveBeenCalledWith( - userUuidMock, - bodyMock.uuid, - imageMock, - bodyMock, - ); - }); - - it('not found user', async () => { - jest - .spyOn(profilesService, 'updateProfile') - .mockRejectedValue(new ForbiddenException()); - - const response = controller.updateProfile( + profileUuidMock, imageMock, - userUuidMock, bodyMock, ); - - await expect(response).rejects.toThrow(ForbiddenException); }); }); }); diff --git a/nestjs-BE/server/src/profiles/profiles.controller.ts b/nestjs-BE/server/src/profiles/profiles.controller.ts index c8fa1fb4..6d4f7197 100644 --- a/nestjs-BE/server/src/profiles/profiles.controller.ts +++ b/nestjs-BE/server/src/profiles/profiles.controller.ts @@ -7,12 +7,15 @@ import { UploadedFile, ValidationPipe, HttpStatus, + UseGuards, + Param, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger'; import { ProfilesService } from './profiles.service'; import { UpdateProfileDto } from './dto/update-profile.dto'; import { User } from '../auth/decorators/user.decorator'; +import { MatchUserProfileGuard } from './guards/match-user-profile.guard'; @Controller('profiles') @ApiTags('profiles') @@ -34,7 +37,8 @@ export class ProfilesController { return { statusCode: HttpStatus.OK, message: 'Success', data: profile }; } - @Patch() + @Patch(':profile_uuid') + @UseGuards(MatchUserProfileGuard) @UseInterceptors(FileInterceptor('image')) @ApiOperation({ summary: 'Update profile' }) @ApiResponse({ @@ -45,15 +49,18 @@ export class ProfilesController { status: HttpStatus.UNAUTHORIZED, description: 'Unauthorized.', }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: 'Inappropriate profile uuid requested.', + }) async updateProfile( @UploadedFile() image: Express.Multer.File, - @User('uuid') userUuid: string, + @Param('profile_uuid') profileUuid: string, @Body(new ValidationPipe({ whitelist: true })) updateProfileDto: UpdateProfileDto, ) { const profile = await this.profilesService.updateProfile( - userUuid, - updateProfileDto.uuid, + profileUuid, image, updateProfileDto, ); diff --git a/nestjs-BE/server/src/profiles/profiles.module.ts b/nestjs-BE/server/src/profiles/profiles.module.ts index 0515229e..f5a07137 100644 --- a/nestjs-BE/server/src/profiles/profiles.module.ts +++ b/nestjs-BE/server/src/profiles/profiles.module.ts @@ -3,11 +3,12 @@ import { ProfilesService } from './profiles.service'; import { ProfilesController } from './profiles.controller'; import { UploadModule } from '../upload/upload.module'; import { PrismaModule } from '../prisma/prisma.module'; +import { MatchUserProfileGuard } from './guards/match-user-profile.guard'; @Module({ imports: [UploadModule, PrismaModule], controllers: [ProfilesController], - providers: [ProfilesService], - exports: [ProfilesService], + providers: [ProfilesService, MatchUserProfileGuard], + exports: [ProfilesService, MatchUserProfileGuard], }) export class ProfilesModule {} diff --git a/nestjs-BE/server/src/profiles/profiles.service.spec.ts b/nestjs-BE/server/src/profiles/profiles.service.spec.ts index 938bee15..145e527d 100644 --- a/nestjs-BE/server/src/profiles/profiles.service.spec.ts +++ b/nestjs-BE/server/src/profiles/profiles.service.spec.ts @@ -1,12 +1,15 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { NotFoundException } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; +import { omit } from 'lodash'; import { ProfilesService } from './profiles.service'; import { PrismaService } from '../prisma/prisma.service'; import { UploadService } from '../upload/upload.service'; +import type { UpdateProfileDto } from './dto/update-profile.dto'; + describe('ProfilesService', () => { - let profilesService: ProfilesService; + let service: ProfilesService; let prisma: PrismaService; let configService: ConfigService; let uploadService: UploadService; @@ -16,23 +19,12 @@ describe('ProfilesService', () => { imports: [ConfigModule], providers: [ ProfilesService, - { - provide: PrismaService, - useValue: { - profile: { - findUnique: jest.fn(), - update: jest.fn(), - }, - }, - }, - { - provide: UploadService, - useValue: { uploadFile: jest.fn() }, - }, + { provide: PrismaService, useValue: { profile: {} } }, + { provide: UploadService, useValue: {} }, ], }).compile(); - profilesService = module.get(ProfilesService); + service = module.get(ProfilesService); prisma = module.get(PrismaService); configService = module.get(ConfigService); uploadService = module.get(UploadService); @@ -43,13 +35,13 @@ describe('ProfilesService', () => { const profile = { uuid: 'profile uuid', userUuid }; beforeEach(() => { - (prisma.profile.findUnique as jest.Mock).mockResolvedValue(profile); + (prisma.profile.findUnique as jest.Mock) = jest.fn(async () => profile); }); it('found', async () => { - const res = profilesService.findProfileByUserUuid(userUuid); + const res = service.findProfileByUserUuid(userUuid); - await expect(res).resolves.toEqual(profile); + await expect(res).resolves.toEqual(omit(profile, ['userUuid'])); }); it('not found', async () => { @@ -57,7 +49,7 @@ describe('ProfilesService', () => { new NotFoundException(), ); - const res = profilesService.findProfileByUserUuid(userUuid); + const res = service.findProfileByUserUuid(userUuid); await expect(res).rejects.toThrow(NotFoundException); }); @@ -70,91 +62,27 @@ describe('ProfilesService', () => { const imageUrl = 'www.test.com/image'; beforeEach(() => { - jest.spyOn(profilesService, 'verifyUserProfile').mockResolvedValue(true); - (uploadService.uploadFile as jest.Mock).mockResolvedValue(imageUrl); - (prisma.profile.update as jest.Mock).mockImplementation(async (args) => { - return { - uuid: profileUuid, - userUuid, - nickname: args.data.nickname ? args.data.nickname : 'test nickname', - image: args.data.image - ? args.data.image - : configService.get('BASE_IMAGE_URL'), - }; - }); + uploadService.uploadFile = jest.fn(async () => imageUrl); + (prisma.profile.update as jest.Mock) = jest.fn(async (args) => ({ + uuid: profileUuid, + userUuid, + nickname: args.data.nickname ? args.data.nickname : 'test nickname', + image: args.data.image + ? args.data.image + : configService.get('BASE_IMAGE_URL'), + })); }); it('updated', async () => { - const data = { nickname: 'new nickname' }; + const data = { nickname: 'new nickname' } as UpdateProfileDto; - const profile = profilesService.updateProfile( - userUuid, - profileUuid, - image, - data, - ); + const profile = service.updateProfile(profileUuid, image, data); await expect(profile).resolves.toEqual({ uuid: profileUuid, - userUuid, image: imageUrl, nickname: data.nickname, }); }); - - it('wrong user uuid', async () => { - const data = {}; - - jest - .spyOn(profilesService, 'verifyUserProfile') - .mockRejectedValue(new ForbiddenException()); - - const profile = profilesService.updateProfile( - userUuid, - profileUuid, - image, - data, - ); - - await expect(profile).rejects.toThrow(ForbiddenException); - }); - }); - - describe('verifyUserProfile', () => { - const userUuid = 'user uuid'; - const profileUuid = 'profile uuid'; - const image = 'www.test.com'; - const nickname = 'test nickname'; - - beforeEach(() => { - jest - .spyOn(profilesService, 'findProfileByProfileUuid') - .mockResolvedValue({ uuid: profileUuid, userUuid, image, nickname }); - }); - - it('verified', async () => { - const res = profilesService.verifyUserProfile(userUuid, profileUuid); - - await expect(res).resolves.toBeTruthy(); - }); - - it('profile not found', async () => { - jest - .spyOn(profilesService, 'findProfileByProfileUuid') - .mockResolvedValue(null); - - const res = profilesService.verifyUserProfile(userUuid, profileUuid); - - await expect(res).rejects.toThrow(ForbiddenException); - }); - - it('profile user not own', async () => { - const res = profilesService.verifyUserProfile( - 'other user uuid', - profileUuid, - ); - - await expect(res).rejects.toThrow(ForbiddenException); - }); }); }); diff --git a/nestjs-BE/server/src/profiles/profiles.service.ts b/nestjs-BE/server/src/profiles/profiles.service.ts index 42c57130..90e5585a 100644 --- a/nestjs-BE/server/src/profiles/profiles.service.ts +++ b/nestjs-BE/server/src/profiles/profiles.service.ts @@ -1,15 +1,17 @@ -import { - ForbiddenException, - Injectable, - NotFoundException, -} from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { Profile, Prisma } from '@prisma/client'; +import { isUndefined, omit, omitBy } from 'lodash'; import { v4 as uuid } from 'uuid'; import { UpdateProfileDto } from './dto/update-profile.dto'; import { CreateProfileDto } from './dto/create-profile.dto'; import { PrismaService } from '../prisma/prisma.service'; import { UploadService } from '../upload/upload.service'; +type UpdateData = { + nickname?: string; + image?: string; +}; + @Injectable() export class ProfilesService { constructor( @@ -17,24 +19,20 @@ export class ProfilesService { private uploadService: UploadService, ) {} - async findProfileByUserUuid(userUuid: string): Promise { + async findProfileByUserUuid( + userUuid: string, + ): Promise | null> { const profile = await this.prisma.profile.findUnique({ where: { userUuid }, }); if (!profile) throw new NotFoundException(); - return profile; + return omit(profile, ['userUuid']); } async findProfileByProfileUuid(uuid: string): Promise { return this.prisma.profile.findUnique({ where: { uuid } }); } - async findProfiles(profileUuids: string[]): Promise { - return this.prisma.profile.findMany({ - where: { uuid: { in: profileUuids } }, - }); - } - async getOrCreateProfile(data: CreateProfileDto): Promise { return this.prisma.profile.upsert({ where: { userUuid: data.userUuid }, @@ -49,20 +47,20 @@ export class ProfilesService { } async updateProfile( - userUuid: string, profileUuid: string, image: Express.Multer.File, updateProfileDto: UpdateProfileDto, - ): Promise { - await this.verifyUserProfile(userUuid, profileUuid); + ): Promise | null> { + const updateData: UpdateData = { nickname: updateProfileDto.nickname }; if (image) { - updateProfileDto.image = await this.uploadService.uploadFile(image); + updateData.image = await this.uploadService.uploadFile(image); } try { - return await this.prisma.profile.update({ - where: { userUuid }, - data: updateProfileDto, + const profile = await this.prisma.profile.update({ + where: { uuid: profileUuid }, + data: omitBy(updateData, isUndefined), }); + return omit(profile, ['userUuid']); } catch (err) { if (err instanceof Prisma.PrismaClientKnownRequestError) { return null; @@ -71,14 +69,4 @@ export class ProfilesService { } } } - - async verifyUserProfile( - userUuid: string, - profileUuid: string, - ): Promise { - const profile = await this.findProfileByProfileUuid(profileUuid); - if (!profile) throw new ForbiddenException(); - if (userUuid !== profile.userUuid) throw new ForbiddenException(); - return true; - } } diff --git a/nestjs-BE/server/src/spaces/spaces.controller.spec.ts b/nestjs-BE/server/src/spaces/spaces.controller.spec.ts index 1b9d1b04..a756bb0d 100644 --- a/nestjs-BE/server/src/spaces/spaces.controller.spec.ts +++ b/nestjs-BE/server/src/spaces/spaces.controller.spec.ts @@ -5,9 +5,9 @@ import { SpacesController } from './spaces.controller'; import { SpacesService } from './spaces.service'; import { UpdateSpaceDto } from './dto/update-space.dto'; import { CreateSpaceDto } from './dto/create-space.dto'; -import { MatchUserProfileGuard } from '../auth/guards/match-user-profile.guard'; import { IsProfileInSpaceGuard } from '../auth/guards/is-profile-in-space.guard'; import { ProfilesService } from '../profiles/profiles.service'; +import { MatchUserProfileGuard } from '../profiles/guards/match-user-profile.guard'; import { ProfileSpaceService } from '../profile-space/profile-space.service'; describe('SpacesController', () => { diff --git a/nestjs-BE/server/src/spaces/spaces.controller.ts b/nestjs-BE/server/src/spaces/spaces.controller.ts index 0c4732a1..cf00b0dd 100644 --- a/nestjs-BE/server/src/spaces/spaces.controller.ts +++ b/nestjs-BE/server/src/spaces/spaces.controller.ts @@ -17,7 +17,7 @@ import { SpacesService } from './spaces.service'; import { CreateSpaceDto } from './dto/create-space.dto'; import { UpdateSpaceDto } from './dto/update-space.dto'; import { IsProfileInSpaceGuard } from '../auth/guards/is-profile-in-space.guard'; -import { MatchUserProfileGuard } from '../auth/guards/match-user-profile.guard'; +import { MatchUserProfileGuard } from '../profiles/guards/match-user-profile.guard'; @Controller('spaces') @ApiTags('spaces') diff --git a/nestjs-BE/server/src/spaces/spaces.module.ts b/nestjs-BE/server/src/spaces/spaces.module.ts index 63214130..c4e3e8c3 100644 --- a/nestjs-BE/server/src/spaces/spaces.module.ts +++ b/nestjs-BE/server/src/spaces/spaces.module.ts @@ -10,9 +10,10 @@ import { UploadModule } from '../upload/upload.module'; import { ProfileSpaceModule } from '../profile-space/profile-space.module'; import { AuthModule } from '../auth/auth.module'; import { MulterFileMiddleware } from '../common/middlewares/multer-file.middleware'; +import { ProfilesModule } from '../profiles/profiles.module'; @Module({ - imports: [ProfileSpaceModule, UploadModule, AuthModule], + imports: [ProfileSpaceModule, UploadModule, AuthModule, ProfilesModule], controllers: [SpacesController], providers: [SpacesService], exports: [SpacesService], diff --git a/nestjs-BE/server/test/profiles.e2e-spec.ts b/nestjs-BE/server/test/profiles.e2e-spec.ts new file mode 100644 index 00000000..c4f04210 --- /dev/null +++ b/nestjs-BE/server/test/profiles.e2e-spec.ts @@ -0,0 +1,227 @@ +import { HttpStatus } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { Test } from '@nestjs/testing'; +import { readFile } from 'fs/promises'; +import { sign } from 'jsonwebtoken'; +import { omit } from 'lodash'; +import { resolve } from 'path'; +import * as request from 'supertest'; +import { v4 as uuid } from 'uuid'; +import { AuthModule } from '../src/auth/auth.module'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { ProfilesModule } from '../src/profiles/profiles.module'; + +import type { INestApplication } from '@nestjs/common'; +import type { TestingModule } from '@nestjs/testing'; +import type { Profile } from '@prisma/client'; + +describe('ProfilesController (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + let config: ConfigService; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ + ProfilesModule, + AuthModule, + ConfigModule.forRoot({ isGlobal: true }), + ], + }).compile(); + + app = module.createNestApplication(); + + await app.init(); + + prisma = module.get(PrismaService); + config = module.get(ConfigService); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('Checking if JwtAuthGuard is applied', () => { + it('/profiles (GET)', () => { + return request(app.getHttpServer()) + .get('/profiles') + .expect(HttpStatus.UNAUTHORIZED) + .expect({ + statusCode: HttpStatus.UNAUTHORIZED, + message: 'Unauthorized', + }); + }); + + it('/profiles (PATCH)', async () => { + const testUser = await createUser(prisma); + const testProfile = await createProfile(testUser.uuid, prisma); + + return request(app.getHttpServer()) + .patch(`/profiles/${testProfile.uuid}`) + .expect(HttpStatus.UNAUTHORIZED) + .expect({ + statusCode: HttpStatus.UNAUTHORIZED, + message: 'Unauthorized', + }); + }); + }); + + describe('Checking if MatchUserProfileGuard is applied', () => { + let testToken: string; + + beforeEach(async () => { + const testUser = await createUser(prisma); + testToken = createToken(testUser.uuid, config); + await createProfile(testUser.uuid, prisma); + }); + + it('/profiles (PATCH)', async () => { + return request(app.getHttpServer()) + .patch(`/profiles/${uuid()}`) + .auth(testToken, { type: 'bearer' }) + .expect(HttpStatus.FORBIDDEN) + .expect({ + statusCode: HttpStatus.FORBIDDEN, + message: 'Forbidden', + }); + }); + }); + + describe('/profiles (GET)', () => { + const path = '/profiles'; + let testToken: string; + let testProfile: Profile; + + beforeEach(async () => { + const testUser = await createUser(prisma); + testToken = createToken(testUser.uuid, config); + testProfile = await createProfile(testUser.uuid, prisma); + }); + + it('success', () => { + return request(app.getHttpServer()) + .get(path) + .auth(testToken, { type: 'bearer' }) + .expect(HttpStatus.OK) + .expect({ + statusCode: HttpStatus.OK, + message: 'Success', + data: omit(testProfile, ['userUuid']), + }); + }); + }); + + describe('/profiles (PATCH)', () => { + let testToken: string; + let testProfile: Profile; + let path: string; + + beforeEach(async () => { + const testUser = await createUser(prisma); + testToken = createToken(testUser.uuid, config); + testProfile = await createProfile(testUser.uuid, prisma); + path = `/profiles/${testProfile.uuid}`; + }); + + it('success without update', () => { + return request(app.getHttpServer()) + .patch(path) + .auth(testToken, { type: 'bearer' }) + .expect(HttpStatus.OK) + .expect({ + statusCode: HttpStatus.OK, + message: 'Success', + data: omit(testProfile, ['userUuid']), + }); + }); + + it('success nickname update', () => { + const newNickname = 'new nickname'; + + return request(app.getHttpServer()) + .patch(path) + .auth(testToken, { type: 'bearer' }) + .send({ nickname: newNickname }) + .expect(HttpStatus.OK) + .expect({ + statusCode: HttpStatus.OK, + message: 'Success', + data: { + ...omit(testProfile, ['userUuid']), + nickname: newNickname, + }, + }); + }); + + it('success image update', async () => { + const imageUrlPattern = `^https\\:\\/\\/${config.get( + 'S3_BUCKET_NAME', + )}\\.s3\\.${config.get( + 'AWS_REGION', + )}\\.amazonaws\\.com\\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-`; + const imageRegExp = new RegExp(imageUrlPattern); + const testImage = await readFile(resolve(__dirname, './base_image.png')); + + return request(app.getHttpServer()) + .patch(path) + .auth(testToken, { type: 'bearer' }) + .attach('image', testImage, 'base_image.png') + .expect(HttpStatus.OK) + .expect((res) => { + expect(res.body.message).toBe('Success'); + expect(res.body.statusCode).toBe(HttpStatus.OK); + expect(res.body.data.uuid).toBe(testProfile.uuid); + expect(res.body.data.userUuid).toBeUndefined(); + expect(res.body.data.image).toMatch(imageRegExp); + expect(res.body.data.nickname).toBe(testProfile.nickname); + }); + }); + + it('success update all', async () => { + const newNickname = 'new nickname'; + const imageUrlPattern = `^https\\:\\/\\/${config.get( + 'S3_BUCKET_NAME', + )}\\.s3\\.${config.get( + 'AWS_REGION', + )}\\.amazonaws\\.com\\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-`; + const imageRegExp = new RegExp(imageUrlPattern); + const testImage = await readFile(resolve(__dirname, './base_image.png')); + + return request(app.getHttpServer()) + .patch(path) + .auth(testToken, { type: 'bearer' }) + .attach('image', testImage, 'base_image.png') + .field('nickname', newNickname) + .expect(HttpStatus.OK) + .expect((res) => { + expect(res.body.message).toBe('Success'); + expect(res.body.statusCode).toBe(HttpStatus.OK); + expect(res.body.data.uuid).toBe(testProfile.uuid); + expect(res.body.data.userUuid).toBeUndefined(); + expect(res.body.data.image).toMatch(imageRegExp); + expect(res.body.data.nickname).toBe(newNickname); + }); + }); + }); +}); + +async function createUser(prisma: PrismaService) { + return prisma.user.create({ data: { uuid: uuid() } }); +} + +function createToken(userUuid: string, config: ConfigService) { + return sign({ sub: userUuid }, config.get('JWT_ACCESS_SECRET'), { + expiresIn: '5m', + }); +} + +async function createProfile(userUuid: string, prisma: PrismaService) { + return prisma.profile.create({ + data: { + uuid: uuid(), + userUuid, + image: 'test image', + nickname: 'test nickname', + }, + }); +}