Skip to content

Commit af077f5

Browse files
Merge pull request #311 from boostcampwm-2024/feature-be-#279
권한 관련 api 구현
2 parents 0668dcc + a6b84c4 commit af077f5

15 files changed

+519
-39
lines changed

apps/backend/src/auth/auth.controller.spec.ts

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ import { AuthController } from './auth.controller';
33
import { AuthService } from './auth.service';
44
import { JwtAuthGuard } from './guards/jwt-auth.guard';
55
import { TokenService } from './token/token.service';
6-
import { LoginRequiredException } from '../exception/login.exception';
6+
import { User } from '../user/user.entity';
77

88
describe('AuthController', () => {
9-
let authController: AuthController;
9+
let controller: AuthController;
1010
let authService: AuthService;
1111

1212
beforeEach(async () => {
@@ -37,33 +37,27 @@ describe('AuthController', () => {
3737
})
3838
.compile();
3939

40-
authController = module.get<AuthController>(AuthController);
40+
controller = module.get<AuthController>(AuthController);
4141
authService = module.get<AuthService>(AuthService);
4242
});
4343

4444
it('컨트롤러 클래스가 정상적으로 인스턴스화된다.', () => {
45-
expect(authController).toBeDefined();
45+
expect(controller).toBeDefined();
4646
});
4747

4848
describe('getProfile', () => {
4949
it('JWT 토큰이 유효한 경우 profile을 return한다.', async () => {
5050
const req = {
51-
user: { sub: 1, email: '[email protected]', provider: 'naver' },
51+
user: { sub: 1 },
5252
} as any;
53-
const result = await authController.getProfile(req);
53+
const returnedUser = { id: 1, snowflakeId: 'snowflake-id-1' } as User;
54+
jest.spyOn(authService, 'findUserById').mockResolvedValue(returnedUser);
55+
56+
const result = await controller.getProfile(req);
5457
expect(result).toEqual({
5558
message: '인증된 사용자 정보',
56-
user: req.user,
59+
snowflakeId: returnedUser.snowflakeId,
5760
});
5861
});
59-
60-
it('JWT 토큰이 없는 경우 예외를 던진다.', async () => {
61-
const req = {} as any;
62-
try {
63-
authController.getProfile(req);
64-
} catch (error) {
65-
expect(error).toBeInstanceOf(LoginRequiredException);
66-
}
67-
});
6862
});
6963
});

apps/backend/src/auth/auth.controller.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,15 +82,17 @@ export class AuthController {
8282
});
8383
}
8484

85-
// Example: 로그인한 사용자만 접근할 수 있는 엔드포인트
85+
// 클라이언트가 사용자의 외부 id(snowflakeId) + 이름을 알 수 있는 엔드포인트
8686
// auth/profile
87+
// TODO: 사용자 지정 닉네임 + 프로필 이미지도 return하게 확장
8788
@Get('profile')
8889
@UseGuards(JwtAuthGuard) // JWT 인증 검사
8990
async getProfile(@Req() req) {
91+
const user = await this.authService.findUserById(req.user.sub);
9092
// JWT 토큰을 검증하고 사용자 정보 반환
9193
return {
9294
message: '인증된 사용자 정보',
93-
user: req.user,
95+
snowflakeId: user.snowflakeId,
9496
};
9597
}
9698
}

apps/backend/src/auth/auth.service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export class AuthService {
2222
return this.userRepository.save(user);
2323
}
2424

25-
async findUserBySnowflakeId(snowflakeId: string): Promise<User | null> {
26-
return await this.userRepository.findOneBy({ snowflakeId });
25+
async findUserById(id: number): Promise<User | null> {
26+
return await this.userRepository.findOneBy({ id });
2727
}
2828
}

apps/backend/src/auth/token/token.service.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { Injectable } from '@nestjs/common';
22
import { JwtService } from '@nestjs/jwt';
33
import { Response } from 'express';
4+
import { InvalidTokenException } from '../../exception/invalid.exception';
45

56
const HOUR = 60 * 60;
7+
const DAY = 24 * 60 * 60;
68
const FIVE_MONTHS = 5 * 30 * 24 * 60 * 60;
79
const MS_HALF_YEAR = 6 * 30 * 24 * 60 * 60 * 1000;
810

@@ -22,6 +24,25 @@ export class TokenService {
2224
});
2325
}
2426

27+
generateInviteToken(workspaceId: number, role: string): string {
28+
// 초대용 JWT 토큰 생성
29+
const payload = { workspaceId, role };
30+
return this.jwtService.sign(payload, {
31+
expiresIn: DAY, // 초대 유효 기간: 1일
32+
secret: process.env.JWT_SECRET,
33+
});
34+
}
35+
36+
verifyInviteToken(token: string): { workspaceId: string; role: string } {
37+
try {
38+
return this.jwtService.verify(token, {
39+
secret: process.env.JWT_SECRET,
40+
});
41+
} catch (error) {
42+
throw new InvalidTokenException();
43+
}
44+
}
45+
2546
// 후에 DB 로직 (지금은 refreshToken이 DB로 관리 X)
2647
// 추가될 때를 위해 일단 비동기 선언
2748
async refreshAccessToken(refreshToken: string): Promise<string> {
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { HttpException, HttpStatus } from '@nestjs/common';
2+
3+
export class ForbiddenAccessException extends HttpException {
4+
constructor() {
5+
super('워크스페이스에 접근할 권한이 없습니다.', HttpStatus.FORBIDDEN);
6+
}
7+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { BadRequestException } from '@nestjs/common';
2+
3+
export class UserAlreadyInWorkspaceException extends BadRequestException {
4+
constructor() {
5+
super('사용자가 이미 워크스페이스에 등록되어 있습니다.');
6+
}
7+
}

apps/backend/src/exception/workspace-auth.exception.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@ import { HttpException, HttpStatus } from '@nestjs/common';
22

33
export class NotWorkspaceOwnerException extends HttpException {
44
constructor() {
5-
super('You are not the owner of this workspace.', HttpStatus.FORBIDDEN);
5+
super('해당 워크스페이스의 소유자가 아닙니다.', HttpStatus.FORBIDDEN);
66
}
77
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsString } from 'class-validator';
3+
4+
export class CreateWorkspaceInviteUrlDto {
5+
@ApiProperty({
6+
example: 'OO 생성에 성공했습니다.',
7+
description: 'api 요청 결과 메시지',
8+
})
9+
@IsString()
10+
message: string;
11+
12+
@ApiProperty({
13+
example: 'https://octodocs.local/api/workspace/join?token=12345',
14+
description: '워크스페이스 초대용 링크입니다.',
15+
})
16+
@IsString()
17+
inviteUrl: string;
18+
}

apps/backend/src/workspace/dtos/getUserWorkspacesResponse.dto.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { ApiProperty } from '@nestjs/swagger';
22
import { IsString, IsArray } from 'class-validator';
33
import { UserWorkspaceDto } from './userWorkspace.dto';
44

5-
export class getUserWorkspacesResponseDto {
5+
export class GetUserWorkspacesResponseDto {
66
@ApiProperty({
77
example: 'OO 생성에 성공했습니다.',
88
description: 'api 요청 결과 메시지',

apps/backend/src/workspace/workspace.controller.spec.ts

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import { CreateWorkspaceDto } from './dtos/createWorkspace.dto';
66
import { WorkspaceResponseMessage } from './workspace.controller';
77
import { NotWorkspaceOwnerException } from '../exception/workspace-auth.exception';
88
import { UserWorkspaceDto } from './dtos/userWorkspace.dto';
9+
import { TokenService } from '../auth/token/token.service';
10+
import { WorkspaceNotFoundException } from '../exception/workspace.exception';
11+
import { ForbiddenAccessException } from '../exception/access.exception';
912

1013
describe('WorkspaceController', () => {
1114
let controller: WorkspaceController;
@@ -21,8 +24,15 @@ describe('WorkspaceController', () => {
2124
createWorkspace: jest.fn(),
2225
deleteWorkspace: jest.fn(),
2326
getUserWorkspaces: jest.fn(),
27+
generateInviteUrl: jest.fn(),
28+
processInviteUrl: jest.fn(),
29+
checkAccess: jest.fn(),
2430
},
2531
},
32+
{
33+
provide: TokenService,
34+
useValue: {},
35+
},
2636
],
2737
})
2838
.overrideGuard(JwtAuthGuard)
@@ -116,14 +126,126 @@ describe('WorkspaceController', () => {
116126
},
117127
] as UserWorkspaceDto[];
118128

129+
const expectedResult = {
130+
message: WorkspaceResponseMessage.WORKSPACES_RETURNED,
131+
workspaces: mockWorkspaces,
132+
};
133+
119134
jest
120135
.spyOn(service, 'getUserWorkspaces')
121136
.mockResolvedValue(mockWorkspaces);
122137

123138
const result = await controller.getUserWorkspaces(req);
124139

125140
expect(service.getUserWorkspaces).toHaveBeenCalledWith(req.user.sub);
126-
expect(result).toEqual(mockWorkspaces);
141+
expect(result).toEqual(expectedResult);
142+
});
143+
});
144+
145+
it('컨트롤러가 정상적으로 인스턴스화된다.', () => {
146+
expect(controller).toBeDefined();
147+
});
148+
149+
describe('generateInviteLink', () => {
150+
it('초대 링크를 생성하고 반환한다.', async () => {
151+
const req = { user: { sub: 1 } };
152+
const workspaceId = 'workspace-snowflake-id';
153+
const mockInviteUrl =
154+
'https://example.com/api/workspace/join?token=abc123';
155+
156+
jest.spyOn(service, 'generateInviteUrl').mockResolvedValue(mockInviteUrl);
157+
158+
const result = await controller.generateInviteLink(req, workspaceId);
159+
160+
expect(service.generateInviteUrl).toHaveBeenCalledWith(
161+
req.user.sub,
162+
workspaceId,
163+
);
164+
expect(result).toEqual({
165+
message: WorkspaceResponseMessage.WORKSPACE_INVITED,
166+
inviteUrl: mockInviteUrl,
167+
});
168+
});
169+
});
170+
171+
describe('joinWorkspace', () => {
172+
it('초대 토큰을 처리하고 성공 메시지를 반환한다.', async () => {
173+
const req = { user: { sub: 1 } };
174+
const token = 'valid-token';
175+
176+
jest.spyOn(service, 'processInviteUrl').mockResolvedValue();
177+
178+
const result = await controller.joinWorkspace(req, token);
179+
180+
expect(service.processInviteUrl).toHaveBeenCalledWith(
181+
req.user.sub,
182+
token,
183+
);
184+
expect(result).toEqual({
185+
message: WorkspaceResponseMessage.WORKSPACE_INVITED,
186+
});
187+
});
188+
});
189+
190+
describe('checkWorkspaceAccess', () => {
191+
it('워크스페이스에 접근 가능한 경우 메시지를 반환한다.', async () => {
192+
const workspaceId = 'workspace-snowflake-id';
193+
const userId = 'user-snowflake-id';
194+
195+
jest.spyOn(service, 'checkAccess').mockResolvedValue(undefined);
196+
197+
const result = await controller.checkWorkspaceAccess(workspaceId, userId);
198+
199+
expect(service.checkAccess).toHaveBeenCalledWith(userId, workspaceId);
200+
expect(result).toEqual({
201+
message: WorkspaceResponseMessage.WORKSPACE_ACCESS_CHECKED,
202+
});
203+
});
204+
205+
it('로그인하지 않은 사용자의 경우 null로 처리하고 접근 가능한 경우 메시지를 반환한다.', async () => {
206+
const workspaceId = 'workspace-snowflake-id';
207+
const userId = 'null'; // 로그인되지 않은 상태를 나타냄
208+
209+
jest.spyOn(service, 'checkAccess').mockResolvedValue(undefined);
210+
211+
const result = await controller.checkWorkspaceAccess(workspaceId, userId);
212+
213+
expect(service.checkAccess).toHaveBeenCalledWith(null, workspaceId);
214+
expect(result).toEqual({
215+
message: WorkspaceResponseMessage.WORKSPACE_ACCESS_CHECKED,
216+
});
217+
});
218+
219+
it('권한이 없는 경우 ForbiddenAccessException을 던진다.', async () => {
220+
const workspaceId = 'workspace-snowflake-id';
221+
const userId = 'user-snowflake-id';
222+
223+
// 권한 없음
224+
jest
225+
.spyOn(service, 'checkAccess')
226+
.mockRejectedValue(new ForbiddenAccessException());
227+
228+
await expect(
229+
controller.checkWorkspaceAccess(workspaceId, userId),
230+
).rejects.toThrow(ForbiddenAccessException);
231+
232+
expect(service.checkAccess).toHaveBeenCalledWith(userId, workspaceId);
233+
});
234+
235+
it('워크스페이스가 존재하지 않는 경우 WorkspaceNotFoundException을 던진다.', async () => {
236+
const workspaceId = 'invalid-snowflake-id';
237+
const userId = 'user-snowflake-id';
238+
239+
// 워크스페이스 없음
240+
jest
241+
.spyOn(service, 'checkAccess')
242+
.mockRejectedValue(new WorkspaceNotFoundException());
243+
244+
await expect(
245+
controller.checkWorkspaceAccess(workspaceId, userId),
246+
).rejects.toThrow(WorkspaceNotFoundException);
247+
248+
expect(service.checkAccess).toHaveBeenCalledWith(userId, workspaceId);
127249
});
128250
});
129251
});

0 commit comments

Comments
 (0)