diff --git a/adapter/infra/src/main/java/me/chan99k/learningmanager/authentication/JwtProviderAdapter.java b/adapter/infra/src/main/java/me/chan99k/learningmanager/authentication/JwtProviderAdapter.java index efff8cea..f79810ae 100644 --- a/adapter/infra/src/main/java/me/chan99k/learningmanager/authentication/JwtProviderAdapter.java +++ b/adapter/infra/src/main/java/me/chan99k/learningmanager/authentication/JwtProviderAdapter.java @@ -2,7 +2,6 @@ import java.time.Instant; import java.util.Date; -import java.util.List; import java.util.UUID; import javax.crypto.SecretKey; @@ -19,6 +18,7 @@ @Component public class JwtProviderAdapter implements JwtProvider { + private final SecretKey secretKey; private final long accessTokenExpirationSeconds; private final String issuer; @@ -34,7 +34,7 @@ public JwtProviderAdapter( } @Override - public String createAccessToken(Long memberId, String email, List roles) { + public String createAccessToken(Long memberId, String email) { Instant now = Instant.now(); Instant expiration = now.plusSeconds(accessTokenExpirationSeconds); @@ -47,7 +47,6 @@ public String createAccessToken(Long memberId, String email, List roles) .id(UUID.randomUUID().toString()) .claim("member_id", memberId) .claim("email", email) - .claim("roles", roles) .signWith(secretKey) .compact(); } @@ -63,11 +62,9 @@ public Claims validateAndGetClaims(String token) { Long memberId = claims.get("member_id", Long.class); String email = claims.get("email", String.class); - @SuppressWarnings("unchecked") - List roles = claims.get("roles", List.class); Instant expiresAt = claims.getExpiration().toInstant(); - return new Claims(memberId, email, roles, expiresAt); + return new Claims(memberId, email, expiresAt); } catch (ExpiredJwtException e) { throw new DomainException(AuthProblemCode.EXPIRED_TOKEN); diff --git a/adapter/infra/src/main/java/me/chan99k/learningmanager/authorization/AttendanceSecurity.java b/adapter/infra/src/main/java/me/chan99k/learningmanager/authorization/AttendanceSecurity.java index 6ed37e82..8dbd656d 100644 --- a/adapter/infra/src/main/java/me/chan99k/learningmanager/authorization/AttendanceSecurity.java +++ b/adapter/infra/src/main/java/me/chan99k/learningmanager/authorization/AttendanceSecurity.java @@ -2,6 +2,7 @@ import java.util.List; import java.util.Optional; +import java.util.Set; import org.springframework.stereotype.Service; @@ -9,25 +10,27 @@ import me.chan99k.learningmanager.attendance.AttendanceQueryRepository; import me.chan99k.learningmanager.attendance.CorrectionRequested; import me.chan99k.learningmanager.course.CourseRole; -import me.chan99k.learningmanager.member.Member; -import me.chan99k.learningmanager.member.MemberQueryRepository; import me.chan99k.learningmanager.member.SystemRole; import me.chan99k.learningmanager.session.Session; import me.chan99k.learningmanager.session.SessionQueryRepository; @Service("attendanceSecurity") public class AttendanceSecurity { + private final AttendanceQueryRepository attendanceQueryRepository; private final SessionQueryRepository sessionQueryRepository; - private final MemberQueryRepository memberQueryRepository; + private final SystemAuthorizationPort systemAuthorizationPort; private final CourseAuthorizationPort courseAuthorizationPort; - public AttendanceSecurity(AttendanceQueryRepository attendanceQueryRepository, - SessionQueryRepository sessionQueryRepository, MemberQueryRepository memberQueryRepository, - CourseAuthorizationPort courseAuthorizationPort) { + public AttendanceSecurity( + AttendanceQueryRepository attendanceQueryRepository, + SessionQueryRepository sessionQueryRepository, + SystemAuthorizationPort systemAuthorizationPort, + CourseAuthorizationPort courseAuthorizationPort + ) { this.attendanceQueryRepository = attendanceQueryRepository; this.sessionQueryRepository = sessionQueryRepository; - this.memberQueryRepository = memberQueryRepository; + this.systemAuthorizationPort = systemAuthorizationPort; this.courseAuthorizationPort = courseAuthorizationPort; } @@ -38,14 +41,7 @@ public boolean canRequestCorrection(String attendanceId, Long memberId) { } // SystemRole 우선 확인 - Optional foundMember = memberQueryRepository.findById(memberId); - if (foundMember.isEmpty()) { - return false; - } - - var member = foundMember.get(); - var systemRole = member.getRole(); - if (systemRole == SystemRole.ADMIN || systemRole == SystemRole.REGISTRAR) { + if (systemAuthorizationPort.hasAnyRole(memberId, Set.of(SystemRole.ADMIN, SystemRole.REGISTRAR))) { return true; } @@ -82,14 +78,8 @@ public boolean canApproveCorrection(String attendanceId, Long memberId) { return false; // 대기중인 수정 요청 없음 } - Optional foundMember = memberQueryRepository.findById(memberId); - if (foundMember.isEmpty()) { - return false; - } - - var requestedApprover = foundMember.get(); - var systemRole = requestedApprover.getRole(); - if (systemRole == SystemRole.ADMIN || systemRole == SystemRole.REGISTRAR) { + // SystemRole 우선 확인 + if (systemAuthorizationPort.hasAnyRole(memberId, Set.of(SystemRole.ADMIN, SystemRole.REGISTRAR))) { return true; } diff --git a/adapter/infra/src/test/java/me/chan99k/learningmanager/authentication/JwtProviderAdapterTest.java b/adapter/infra/src/test/java/me/chan99k/learningmanager/authentication/JwtProviderAdapterTest.java index 5c6ce5d0..00aead03 100644 --- a/adapter/infra/src/test/java/me/chan99k/learningmanager/authentication/JwtProviderAdapterTest.java +++ b/adapter/infra/src/test/java/me/chan99k/learningmanager/authentication/JwtProviderAdapterTest.java @@ -3,7 +3,6 @@ import static org.assertj.core.api.Assertions.*; import java.util.Base64; -import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -16,7 +15,6 @@ class JwtProviderAdapterTest { private static final Long MEMBER_ID = 1L; private static final String EMAIL = "test@example.com"; - private static final List ROLES = List.of("MEMBER"); private static final long EXPIRATION_SECONDS = 3600L; private static final String ISSUER = "test-issuer"; @@ -42,7 +40,7 @@ class CreateAccessTokenTest { @Test @DisplayName("유효한 JWT 문자열을 생성한다") void creates_valid_jwt_string() { - String token = jwtProvider.createAccessToken(MEMBER_ID, EMAIL, ROLES); + String token = jwtProvider.createAccessToken(MEMBER_ID, EMAIL); assertThat(token).isNotNull(); assertThat(token.split("\\.")).hasSize(3); @@ -51,7 +49,7 @@ void creates_valid_jwt_string() { @Test @DisplayName("생성된 토큰에 memberId 클레임을 포함한다") void token_contains_member_id_claim() { - String token = jwtProvider.createAccessToken(MEMBER_ID, EMAIL, ROLES); + String token = jwtProvider.createAccessToken(MEMBER_ID, EMAIL); JwtProvider.Claims claims = jwtProvider.validateAndGetClaims(token); assertThat(claims.memberId()).isEqualTo(MEMBER_ID); @@ -60,20 +58,11 @@ void token_contains_member_id_claim() { @Test @DisplayName("생성된 토큰에 email 클레임을 포함한다") void token_contains_email_claim() { - String token = jwtProvider.createAccessToken(MEMBER_ID, EMAIL, ROLES); + String token = jwtProvider.createAccessToken(MEMBER_ID, EMAIL); JwtProvider.Claims claims = jwtProvider.validateAndGetClaims(token); assertThat(claims.email()).isEqualTo(EMAIL); } - - @Test - @DisplayName("생성된 토큰에 roles 클레임을 포함한다") - void token_contains_roles_claim() { - String token = jwtProvider.createAccessToken(MEMBER_ID, EMAIL, ROLES); - - JwtProvider.Claims claims = jwtProvider.validateAndGetClaims(token); - assertThat(claims.roles()).containsExactlyElementsOf(ROLES); - } } @Nested @@ -83,13 +72,12 @@ class ValidateAndGetClaimsTest { @Test @DisplayName("유효한 토큰에서 클레임을 추출한다") void extracts_claims_from_valid_token() { - String token = jwtProvider.createAccessToken(MEMBER_ID, EMAIL, ROLES); + String token = jwtProvider.createAccessToken(MEMBER_ID, EMAIL); JwtProvider.Claims claims = jwtProvider.validateAndGetClaims(token); assertThat(claims.memberId()).isEqualTo(MEMBER_ID); assertThat(claims.email()).isEqualTo(EMAIL); - assertThat(claims.roles()).isEqualTo(ROLES); assertThat(claims.expiresAt()).isNotNull(); } @@ -97,7 +85,7 @@ void extracts_claims_from_valid_token() { @DisplayName("만료된 토큰은 EXPIRED_TOKEN 예외를 던진다") void throws_expired_token_exception() { JwtProviderAdapter shortLivedProvider = new JwtProviderAdapter(SECRET, 0L, ISSUER); - String expiredToken = shortLivedProvider.createAccessToken(MEMBER_ID, EMAIL, ROLES); + String expiredToken = shortLivedProvider.createAccessToken(MEMBER_ID, EMAIL); assertThatThrownBy(() -> jwtProvider.validateAndGetClaims(expiredToken)) .isInstanceOf(DomainException.class) @@ -122,7 +110,7 @@ void throws_invalid_token_for_wrong_signature() { JwtProviderAdapter differentProvider = new JwtProviderAdapter( DIFFERENT_SECRET, EXPIRATION_SECONDS, ISSUER ); - String tokenWithDifferentSignature = differentProvider.createAccessToken(MEMBER_ID, EMAIL, ROLES); + String tokenWithDifferentSignature = differentProvider.createAccessToken(MEMBER_ID, EMAIL); assertThatThrownBy(() -> jwtProvider.validateAndGetClaims(tokenWithDifferentSignature)) .isInstanceOf(DomainException.class) @@ -138,7 +126,7 @@ class IsValidTest { @Test @DisplayName("유효한 토큰은 true를 반환한다") void returns_true_for_valid_token() { - String token = jwtProvider.createAccessToken(MEMBER_ID, EMAIL, ROLES); + String token = jwtProvider.createAccessToken(MEMBER_ID, EMAIL); boolean result = jwtProvider.isValid(token); @@ -149,7 +137,7 @@ void returns_true_for_valid_token() { @DisplayName("만료된 토큰은 false를 반환한다") void returns_false_for_expired_token() { JwtProviderAdapter shortLivedProvider = new JwtProviderAdapter(SECRET, 0L, ISSUER); - String expiredToken = shortLivedProvider.createAccessToken(MEMBER_ID, EMAIL, ROLES); + String expiredToken = shortLivedProvider.createAccessToken(MEMBER_ID, EMAIL); boolean result = jwtProvider.isValid(expiredToken); @@ -170,7 +158,7 @@ void returns_false_for_wrong_signature() { JwtProviderAdapter differentProvider = new JwtProviderAdapter( DIFFERENT_SECRET, EXPIRATION_SECONDS, ISSUER ); - String tokenWithDifferentSignature = differentProvider.createAccessToken(MEMBER_ID, EMAIL, ROLES); + String tokenWithDifferentSignature = differentProvider.createAccessToken(MEMBER_ID, EMAIL); boolean result = jwtProvider.isValid(tokenWithDifferentSignature); @@ -190,4 +178,4 @@ void returns_configured_expiration_seconds() { assertThat(result).isEqualTo(EXPIRATION_SECONDS); } } -} +} \ No newline at end of file diff --git a/adapter/persistence/src/main/java/me/chan99k/learningmanager/authorization/JpaSystemAuthorizationAdapter.java b/adapter/persistence/src/main/java/me/chan99k/learningmanager/authorization/JpaSystemAuthorizationAdapter.java new file mode 100644 index 00000000..8034a01c --- /dev/null +++ b/adapter/persistence/src/main/java/me/chan99k/learningmanager/authorization/JpaSystemAuthorizationAdapter.java @@ -0,0 +1,66 @@ +package me.chan99k.learningmanager.authorization; + +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import me.chan99k.learningmanager.member.JpaMemberSystemRoleRepository; +import me.chan99k.learningmanager.member.SystemRole; +import me.chan99k.learningmanager.member.SystemRoleHierarchy; +import me.chan99k.learningmanager.member.entity.MemberSystemRoleEntity; + +@Repository +public class JpaSystemAuthorizationAdapter implements SystemAuthorizationPort { + + private final JpaMemberSystemRoleRepository roleRepository; + private final SystemRoleHierarchy roleHierarchy; + + public JpaSystemAuthorizationAdapter( + JpaMemberSystemRoleRepository roleRepository, + SystemRoleHierarchy roleHierarchy + ) { + this.roleRepository = roleRepository; + this.roleHierarchy = roleHierarchy; + } + + @Override + public boolean hasRole(Long memberId, SystemRole role) { + return roleRepository.existsByMemberIdAndSystemRole(memberId, role); + } + + @Override + public boolean hasAnyRole(Long memberId, Set roles) { + return roleRepository.existsByMemberIdAndSystemRoleIn(memberId, roles); + } + + @Override + public Set getRoles(Long memberId) { + return roleRepository.findByMemberId(memberId).stream() + .map(MemberSystemRoleEntity::getSystemRole) + .collect(Collectors.toSet()); + } + + @Override + @Transactional + public void grantRole(Long memberId, SystemRole role) { + if (!hasRole(memberId, role)) { + roleRepository.save(new MemberSystemRoleEntity(memberId, role)); + } + } + + @Override + @Transactional + public void revokeRole(Long memberId, SystemRole role) { + roleRepository.deleteByMemberIdAndSystemRole(memberId, role); + } + + @Override + public boolean hasRoleOrHigher(Long memberId, SystemRole minimumRole) { + Set memberRoles = getRoles(memberId); + return memberRoles.stream() + .anyMatch(role -> roleHierarchy.isHigherOrEqual(role, minimumRole)); + } + +} \ No newline at end of file diff --git a/adapter/persistence/src/main/java/me/chan99k/learningmanager/config/JpaConfig.java b/adapter/persistence/src/main/java/me/chan99k/learningmanager/config/JpaConfig.java index 7b429c28..79acba97 100644 --- a/adapter/persistence/src/main/java/me/chan99k/learningmanager/config/JpaConfig.java +++ b/adapter/persistence/src/main/java/me/chan99k/learningmanager/config/JpaConfig.java @@ -8,12 +8,21 @@ import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import me.chan99k.learningmanager.member.SystemRoleHierarchy; + @Configuration @EnableJpaAuditing @EnableJpaRepositories(basePackages = "me.chan99k.learningmanager") public class JpaConfig { + @Bean public AuditorAware auditorAware() { return () -> Optional.of(1L); } + + @Bean + public SystemRoleHierarchy systemRoleHierarchy() { + return new SystemRoleHierarchy(); + } + } diff --git a/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/JpaMemberSystemRoleRepository.java b/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/JpaMemberSystemRoleRepository.java new file mode 100644 index 00000000..62052012 --- /dev/null +++ b/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/JpaMemberSystemRoleRepository.java @@ -0,0 +1,22 @@ +package me.chan99k.learningmanager.member; + +import java.util.List; +import java.util.Set; + +import org.springframework.data.jpa.repository.JpaRepository; + +import me.chan99k.learningmanager.member.entity.MemberSystemRoleEntity; + +public interface JpaMemberSystemRoleRepository extends JpaRepository { + + List findByMemberId(Long memberId); + + boolean existsByMemberIdAndSystemRole(Long memberId, SystemRole systemRole); + + boolean existsByMemberIdAndSystemRoleIn(Long memberId, Set systemRoles); + + void deleteByMemberIdAndSystemRole(Long memberId, SystemRole systemRole); + + void deleteByMemberId(Long memberId); + +} \ No newline at end of file diff --git a/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/entity/MemberEntity.java b/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/entity/MemberEntity.java index ddab4442..6f998987 100644 --- a/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/entity/MemberEntity.java +++ b/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/entity/MemberEntity.java @@ -15,7 +15,6 @@ import me.chan99k.learningmanager.common.MutableEntity; import me.chan99k.learningmanager.member.Email; import me.chan99k.learningmanager.member.MemberStatus; -import me.chan99k.learningmanager.member.SystemRole; @Entity @Table(name = "member") @@ -31,9 +30,6 @@ public class MemberEntity extends MutableEntity { @Column(name = "nickname", nullable = false, unique = true, length = 20) private String nickname; - @Enumerated(EnumType.STRING) - private SystemRole role; - @Enumerated(EnumType.STRING) private MemberStatus status; @@ -73,14 +69,6 @@ public void setNickname(String nickname) { this.nickname = nickname; } - public SystemRole getRole() { - return role; - } - - public void setRole(SystemRole role) { - this.role = role; - } - public MemberStatus getStatus() { return status; } diff --git a/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/entity/MemberSystemRoleEntity.java b/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/entity/MemberSystemRoleEntity.java new file mode 100644 index 00000000..27bdc7f4 --- /dev/null +++ b/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/entity/MemberSystemRoleEntity.java @@ -0,0 +1,47 @@ +package me.chan99k.learningmanager.member.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import me.chan99k.learningmanager.common.BaseEntity; +import me.chan99k.learningmanager.member.SystemRole; + +@Entity +@Table( + name = "member_system_role", + uniqueConstraints = @UniqueConstraint( + name = "uk_member_system_role", + columnNames = {"member_id", "system_role"} + ), + indexes = @Index(name = "ix_member_system_role_member", columnList = "member_id") +) +public class MemberSystemRoleEntity extends BaseEntity { + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Enumerated(EnumType.STRING) + @Column(name = "system_role", nullable = false, length = 20) + private SystemRole systemRole; + + protected MemberSystemRoleEntity() { + } + + public MemberSystemRoleEntity(Long memberId, SystemRole systemRole) { + this.memberId = memberId; + this.systemRole = systemRole; + } + + public Long getMemberId() { + return memberId; + } + + public SystemRole getSystemRole() { + return systemRole; + } + +} \ No newline at end of file diff --git a/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/mapper/MemberMapper.java b/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/mapper/MemberMapper.java index 0a3945ff..e90d58d9 100644 --- a/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/mapper/MemberMapper.java +++ b/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/mapper/MemberMapper.java @@ -22,7 +22,6 @@ public static MemberEntity toEntity(Member domain) { entity.setId(domain.getId()); entity.setPrimaryEmail(domain.getPrimaryEmail()); entity.setNickname(domain.getNickname().value()); - entity.setRole(domain.getRole()); entity.setStatus(domain.getStatus()); entity.setProfileImageUrl(domain.getProfileImageUrl()); entity.setSelfIntroduction(domain.getSelfIntroduction()); @@ -53,7 +52,6 @@ public static Member toDomain(MemberEntity entity) { entity.getId(), entity.getPrimaryEmail(), Nickname.of(entity.getNickname()), - entity.getRole(), entity.getStatus(), entity.getProfileImageUrl(), entity.getSelfIntroduction(), diff --git a/adapter/persistence/src/main/resources/db/migration/V0.0.5__create_member_system_role.sql b/adapter/persistence/src/main/resources/db/migration/V0.0.5__create_member_system_role.sql new file mode 100644 index 00000000..98bb9cde --- /dev/null +++ b/adapter/persistence/src/main/resources/db/migration/V0.0.5__create_member_system_role.sql @@ -0,0 +1,29 @@ +-- member_system_role: 회원 시스템 역할 (다중 역할 지원) +CREATE TABLE member_system_role +( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK', + created_at DATETIME(6) NOT NULL COMMENT '생성 일시', + created_by BIGINT NOT NULL COMMENT '생성자 ID', + + member_id BIGINT NOT NULL COMMENT '회원 ID (FK)', + system_role VARCHAR(20) NOT NULL COMMENT '시스템 역할 (ADMIN, MEMBER, SUPERVISOR, OPERATOR, REGISTRAR, AUDITOR)', + + CONSTRAINT pk_member_system_role PRIMARY KEY (id), + CONSTRAINT uk_member_system_role UNIQUE (member_id, system_role), + CONSTRAINT fk_member_system_role_member FOREIGN KEY (member_id) REFERENCES member (id), + + INDEX ix_member_system_role_member (member_id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci + COMMENT = '회원의 시스템 역할을 관리하는 테이블 (다중 역할 지원)'; + +-- 기존 member.role 데이터를 member_system_role로 이전 +INSERT INTO member_system_role (created_at, created_by, member_id, system_role) +SELECT NOW(), 0, id, role +FROM member +WHERE role IS NOT NULL; + +-- member 테이블에서 role 컬럼 삭제 +ALTER TABLE member + DROP COLUMN role; diff --git a/app/api/src/main/java/me/chan99k/learningmanager/filter/JwtAuthenticationFilter.java b/app/api/src/main/java/me/chan99k/learningmanager/filter/JwtAuthenticationFilter.java index 5c646a14..9e5906a0 100644 --- a/app/api/src/main/java/me/chan99k/learningmanager/filter/JwtAuthenticationFilter.java +++ b/app/api/src/main/java/me/chan99k/learningmanager/filter/JwtAuthenticationFilter.java @@ -1,6 +1,7 @@ package me.chan99k.learningmanager.filter; import java.io.IOException; +import java.util.Set; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; @@ -14,6 +15,8 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import me.chan99k.learningmanager.authentication.JwtProvider; +import me.chan99k.learningmanager.authorization.SystemAuthorizationPort; +import me.chan99k.learningmanager.member.SystemRole; import me.chan99k.learningmanager.security.CustomUserDetails; @Component @@ -23,9 +26,14 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private static final String BEARER_PREFIX = "Bearer "; private final JwtProvider jwtProvider; + private final SystemAuthorizationPort systemAuthorizationPort; - public JwtAuthenticationFilter(JwtProvider jwtProvider) { + public JwtAuthenticationFilter( + JwtProvider jwtProvider, + SystemAuthorizationPort systemAuthorizationPort + ) { this.jwtProvider = jwtProvider; + this.systemAuthorizationPort = systemAuthorizationPort; } @Override @@ -39,8 +47,10 @@ protected void doFilterInternal( if (token != null && jwtProvider.isValid(token)) { JwtProvider.Claims claims = jwtProvider.validateAndGetClaims(token); - var authorities = claims.roles().stream() - .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) + Set roles = systemAuthorizationPort.getRoles(claims.memberId()); + + var authorities = roles.stream() + .map(role -> new SimpleGrantedAuthority("ROLE_" + role.name())) .toList(); CustomUserDetails userDetails = new CustomUserDetails( @@ -68,4 +78,5 @@ private String resolveToken(HttpServletRequest request) { } return null; } + } diff --git a/app/api/src/test/java/me/chan99k/learningmanager/adapter/web/attendance/AttendanceControllerTest.java b/app/api/src/test/java/me/chan99k/learningmanager/adapter/web/attendance/AttendanceControllerTest.java index 3e8595ce..ce17c420 100644 --- a/app/api/src/test/java/me/chan99k/learningmanager/adapter/web/attendance/AttendanceControllerTest.java +++ b/app/api/src/test/java/me/chan99k/learningmanager/adapter/web/attendance/AttendanceControllerTest.java @@ -21,6 +21,7 @@ import me.chan99k.learningmanager.attendance.AttendanceRetrieval; import me.chan99k.learningmanager.attendance.AttendanceStatus; import me.chan99k.learningmanager.authentication.JwtProvider; +import me.chan99k.learningmanager.authorization.SystemAuthorizationPort; import me.chan99k.learningmanager.controller.attendance.AttendanceController; import me.chan99k.learningmanager.security.CustomUserDetails; @@ -41,6 +42,9 @@ class AttendanceControllerTest { @MockBean private JwtProvider jwtProvider; + @MockBean + private SystemAuthorizationPort systemAuthorizationPort; + private CustomUserDetails createMockUser() { return new CustomUserDetails( MEMBER_ID, @@ -76,12 +80,10 @@ void getMyAllAttendanceStatus_Success() throws Exception { @Test @DisplayName("과정별 출석 현황 조회 - 성공") void getMyCourseAttendanceStatus_Success() throws Exception { - // Given AttendanceRetrieval.Response mockResponse = createMockResponse(); when(attendanceRetrieval.getMyCourseAttendanceStatus(any(AttendanceRetrieval.CourseAttendanceRequest.class))) .thenReturn(mockResponse); - // When & Then mockMvc.perform(get("/api/v1/attendance/status/my/course") .with(user(createMockUser())) .param("courseId", COURSE_ID.toString())) @@ -98,13 +100,11 @@ void getMyCourseAttendanceStatus_Success() throws Exception { @Test @DisplayName("커리큘럼별 출석 현황 조회 - 성공") void getMyCurriculumAttendanceStatus_Success() throws Exception { - // Given AttendanceRetrieval.Response mockResponse = createMockResponse(); when(attendanceRetrieval.getMyCurriculumAttendanceStatus( any(AttendanceRetrieval.CurriculumAttendanceRequest.class))) .thenReturn(mockResponse); - // When & Then mockMvc.perform(get("/api/v1/attendance/status/my/curriculum") .with(user(createMockUser())) .param("curriculumId", CURRICULUM_ID.toString())) @@ -121,14 +121,12 @@ void getMyCurriculumAttendanceStatus_Success() throws Exception { @Test @DisplayName("월별 출석 현황 조회 - 성공") void getMyMonthlyAttendanceStatus_Success() throws Exception { - // Given int year = 2025; int month = 1; AttendanceRetrieval.Response mockResponse = createMockResponse(); when(attendanceRetrieval.getMyMonthlyAttendanceStatus(any(AttendanceRetrieval.MonthlyAttendanceRequest.class))) .thenReturn(mockResponse); - // When & Then mockMvc.perform(get("/api/v1/attendance/status/my/monthly") .with(user(createMockUser())) .param("year", String.valueOf(year)) @@ -152,7 +150,6 @@ void getMyMonthlyAttendanceStatus_Success() throws Exception { @Test @DisplayName("기간별 출석 현황 조회 - 성공") void getMyPeriodAttendanceStatus_Success() throws Exception { - // Given String startDate = "2025-01-01T00:00:00Z"; String endDate = "2025-01-31T23:59:59Z"; String status = "PRESENT"; @@ -161,7 +158,6 @@ void getMyPeriodAttendanceStatus_Success() throws Exception { when(attendanceRetrieval.getMyPeriodAttendanceStatus(any(AttendanceRetrieval.PeriodAttendanceRequest.class))) .thenReturn(mockResponse); - // When & Then mockMvc.perform(get("/api/v1/attendance/status/my/period") .with(user(createMockUser())) .param("startDate", startDate) @@ -187,7 +183,6 @@ void getMyPeriodAttendanceStatus_Success() throws Exception { @Test @DisplayName("기간별 출석 현황 조회 - 필수 파라미터만 제공") void getMyPeriodAttendanceStatus_RequiredParamsOnly_Success() throws Exception { - // Given String startDate = "2025-01-01T00:00:00Z"; String endDate = "2025-01-31T23:59:59Z"; @@ -195,7 +190,6 @@ void getMyPeriodAttendanceStatus_RequiredParamsOnly_Success() throws Exception { when(attendanceRetrieval.getMyPeriodAttendanceStatus(any(AttendanceRetrieval.PeriodAttendanceRequest.class))) .thenReturn(mockResponse); - // When & Then mockMvc.perform(get("/api/v1/attendance/status/my/period") .with(user(createMockUser())) .param("startDate", startDate) @@ -216,7 +210,6 @@ void getMyPeriodAttendanceStatus_RequiredParamsOnly_Success() throws Exception { @Test @DisplayName("과정별 출석 현황 조회 - courseId 누락 시 400 에러") void getMyCourseAttendanceStatus_MissingCourseId_BadRequest() throws Exception { - // When & Then mockMvc.perform(get("/api/v1/attendance/status/my/course") .with(user(createMockUser()))) .andExpect(status().isBadRequest()); @@ -227,7 +220,6 @@ void getMyCourseAttendanceStatus_MissingCourseId_BadRequest() throws Exception { @Test @DisplayName("커리큘럼별 출석 현황 조회 - curriculumId 누락 시 400 에러") void getMyCurriculumAttendanceStatus_MissingCurriculumId_BadRequest() throws Exception { - // When & Then mockMvc.perform(get("/api/v1/attendance/status/my/curriculum") .with(user(createMockUser()))) .andExpect(status().isBadRequest()); @@ -238,7 +230,6 @@ void getMyCurriculumAttendanceStatus_MissingCurriculumId_BadRequest() throws Exc @Test @DisplayName("기간별 출석 현황 조회 - startDate 누락 시 400 에러") void getMyPeriodAttendanceStatus_MissingStartDate_BadRequest() throws Exception { - // When & Then mockMvc.perform(get("/api/v1/attendance/status/my/period") .with(user(createMockUser())) .param("endDate", "2025-01-31T23:59:59Z")) diff --git a/app/api/src/test/java/me/chan99k/learningmanager/adapter/web/auth/AuthControllerTest.java b/app/api/src/test/java/me/chan99k/learningmanager/adapter/web/auth/AuthControllerTest.java index 0d42e129..772a70ce 100644 --- a/app/api/src/test/java/me/chan99k/learningmanager/adapter/web/auth/AuthControllerTest.java +++ b/app/api/src/test/java/me/chan99k/learningmanager/adapter/web/auth/AuthControllerTest.java @@ -26,6 +26,7 @@ import me.chan99k.learningmanager.authentication.RefreshAccessToken; import me.chan99k.learningmanager.authentication.RevokeAllTokens; import me.chan99k.learningmanager.authentication.RevokeToken; +import me.chan99k.learningmanager.authorization.SystemAuthorizationPort; import me.chan99k.learningmanager.controller.auth.AuthController; import me.chan99k.learningmanager.exception.DomainException; @@ -63,6 +64,9 @@ public class AuthControllerTest { @MockBean private JwtProvider jwtProvider; + @MockBean + private SystemAuthorizationPort systemAuthorizationPort; + @Nested @DisplayName("토큰 발급 API 테스트 (POST /api/v1/auth/token)") class IssueTokenTest { diff --git a/app/api/src/test/java/me/chan99k/learningmanager/adapter/web/member/MemberAdminControllerTest.java b/app/api/src/test/java/me/chan99k/learningmanager/adapter/web/member/MemberAdminControllerTest.java index caeeaaa4..9a1697ff 100644 --- a/app/api/src/test/java/me/chan99k/learningmanager/adapter/web/member/MemberAdminControllerTest.java +++ b/app/api/src/test/java/me/chan99k/learningmanager/adapter/web/member/MemberAdminControllerTest.java @@ -21,6 +21,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import me.chan99k.learningmanager.authentication.JwtProvider; +import me.chan99k.learningmanager.authorization.SystemAuthorizationPort; import me.chan99k.learningmanager.controller.member.MemberAdminController; import me.chan99k.learningmanager.member.MemberStatus; import me.chan99k.learningmanager.member.MemberStatusChange; @@ -43,6 +44,9 @@ class MemberAdminControllerTest { @MockBean private JwtProvider jwtProvider; + @MockBean + private SystemAuthorizationPort systemAuthorizationPort; + private CustomUserDetails createMockAdmin() { return new CustomUserDetails( ADMIN_MEMBER_ID, diff --git a/app/api/src/test/java/me/chan99k/learningmanager/adapter/web/member/MemberCourseParticipationControllerTest.java b/app/api/src/test/java/me/chan99k/learningmanager/adapter/web/member/MemberCourseParticipationControllerTest.java index 0cb8689d..14c68fc5 100644 --- a/app/api/src/test/java/me/chan99k/learningmanager/adapter/web/member/MemberCourseParticipationControllerTest.java +++ b/app/api/src/test/java/me/chan99k/learningmanager/adapter/web/member/MemberCourseParticipationControllerTest.java @@ -25,6 +25,7 @@ import me.chan99k.learningmanager.advice.GlobalExceptionHandler; import me.chan99k.learningmanager.authentication.JwtProvider; +import me.chan99k.learningmanager.authorization.SystemAuthorizationPort; import me.chan99k.learningmanager.controller.member.MemberCourseParticipationController; import me.chan99k.learningmanager.course.CourseRole; import me.chan99k.learningmanager.member.CourseParticipationInfo; @@ -49,6 +50,9 @@ class MemberCourseParticipationControllerTest { @MockBean private JwtProvider jwtProvider; + @MockBean + private SystemAuthorizationPort systemAuthorizationPort; + private CustomUserDetails createMockUser() { return new CustomUserDetails( MEMBER_ID, diff --git a/app/api/src/test/java/me/chan99k/learningmanager/adapter/web/member/MemberRegisterControllerTest.java b/app/api/src/test/java/me/chan99k/learningmanager/adapter/web/member/MemberRegisterControllerTest.java index 28b61696..e1aec67b 100644 --- a/app/api/src/test/java/me/chan99k/learningmanager/adapter/web/member/MemberRegisterControllerTest.java +++ b/app/api/src/test/java/me/chan99k/learningmanager/adapter/web/member/MemberRegisterControllerTest.java @@ -20,6 +20,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import me.chan99k.learningmanager.authentication.JwtProvider; +import me.chan99k.learningmanager.authorization.SystemAuthorizationPort; import me.chan99k.learningmanager.controller.member.MemberRegisterController; import me.chan99k.learningmanager.exception.DomainException; import me.chan99k.learningmanager.member.MemberProblemCode; @@ -48,6 +49,8 @@ public class MemberRegisterControllerTest { private Executor memberTaskExecutor; @MockBean private JwtProvider jwtProvider; + @MockBean + private SystemAuthorizationPort systemAuthorizationPort; @Nested @DisplayName("회원가입 API 테스트") diff --git a/app/api/src/test/java/me/chan99k/learningmanager/adapter/web/member/PasswordResetControllerTest.java b/app/api/src/test/java/me/chan99k/learningmanager/adapter/web/member/PasswordResetControllerTest.java index ee7bbe62..cb414f5c 100644 --- a/app/api/src/test/java/me/chan99k/learningmanager/adapter/web/member/PasswordResetControllerTest.java +++ b/app/api/src/test/java/me/chan99k/learningmanager/adapter/web/member/PasswordResetControllerTest.java @@ -18,6 +18,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import me.chan99k.learningmanager.authentication.JwtProvider; +import me.chan99k.learningmanager.authorization.SystemAuthorizationPort; import me.chan99k.learningmanager.controller.member.PasswordResetController; import me.chan99k.learningmanager.exception.DomainException; import me.chan99k.learningmanager.member.MemberProblemCode; @@ -54,6 +55,9 @@ class PasswordResetControllerTest { @MockBean private JwtProvider jwtProvider; + @MockBean + private SystemAuthorizationPort systemAuthorizationPort; + @Nested @DisplayName("비밀번호 재설정 요청 API 테스트 (POST /api/v1/auth/password/reset-request)") class RequestPasswordResetTest { diff --git a/app/api/src/test/java/me/chan99k/learningmanager/adapter/web/session/SessionControllerTest.java b/app/api/src/test/java/me/chan99k/learningmanager/adapter/web/session/SessionControllerTest.java index 9863541f..d9f88aa8 100644 --- a/app/api/src/test/java/me/chan99k/learningmanager/adapter/web/session/SessionControllerTest.java +++ b/app/api/src/test/java/me/chan99k/learningmanager/adapter/web/session/SessionControllerTest.java @@ -30,6 +30,7 @@ import me.chan99k.learningmanager.advice.GlobalExceptionHandler; import me.chan99k.learningmanager.authentication.JwtProvider; +import me.chan99k.learningmanager.authorization.SystemAuthorizationPort; import me.chan99k.learningmanager.controller.session.SessionController; import me.chan99k.learningmanager.course.CourseProblemCode; import me.chan99k.learningmanager.exception.DomainException; @@ -78,6 +79,9 @@ class SessionControllerTest { @MockBean JwtProvider jwtProvider; + @MockBean + SystemAuthorizationPort systemAuthorizationPort; + private CustomUserDetails createMockUser() { return new CustomUserDetails( MEMBER_ID, diff --git a/app/api/src/test/java/me/chan99k/learningmanager/controller/member/PasswordChangeControllerTest.java b/app/api/src/test/java/me/chan99k/learningmanager/controller/member/PasswordChangeControllerTest.java index 231f7580..f80f8006 100644 --- a/app/api/src/test/java/me/chan99k/learningmanager/controller/member/PasswordChangeControllerTest.java +++ b/app/api/src/test/java/me/chan99k/learningmanager/controller/member/PasswordChangeControllerTest.java @@ -22,6 +22,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import me.chan99k.learningmanager.authentication.JwtProvider; +import me.chan99k.learningmanager.authorization.SystemAuthorizationPort; import me.chan99k.learningmanager.exception.DomainException; import me.chan99k.learningmanager.member.MemberProblemCode; import me.chan99k.learningmanager.member.PasswordChange; @@ -45,6 +46,9 @@ class PasswordChangeControllerTest { @MockBean private JwtProvider jwtProvider; + @MockBean + private SystemAuthorizationPort systemAuthorizationPort; + @Test @DisplayName("[Success] 비밀번호 변경 성공") void changePassword_success() throws Exception { diff --git a/app/api/src/test/java/me/chan99k/learningmanager/filter/JwtAuthenticationFilterTest.java b/app/api/src/test/java/me/chan99k/learningmanager/filter/JwtAuthenticationFilterTest.java index 0fc84d8b..303dc361 100644 --- a/app/api/src/test/java/me/chan99k/learningmanager/filter/JwtAuthenticationFilterTest.java +++ b/app/api/src/test/java/me/chan99k/learningmanager/filter/JwtAuthenticationFilterTest.java @@ -4,7 +4,7 @@ import static org.mockito.BDDMockito.*; import java.time.Instant; -import java.util.List; +import java.util.Set; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -21,6 +21,8 @@ import org.springframework.security.core.context.SecurityContextHolder; import me.chan99k.learningmanager.authentication.JwtProvider; +import me.chan99k.learningmanager.authorization.SystemAuthorizationPort; +import me.chan99k.learningmanager.member.SystemRole; import me.chan99k.learningmanager.security.CustomUserDetails; @ExtendWith(MockitoExtension.class) @@ -28,13 +30,16 @@ class JwtAuthenticationFilterTest { private static final Long MEMBER_ID = 1L; private static final String EMAIL = "test@example.com"; - private static final List ROLES = List.of("MEMBER"); + private static final Set ROLES = Set.of(SystemRole.MEMBER); private static final String VALID_TOKEN = "valid-jwt-token"; private static final String INVALID_TOKEN = "invalid-jwt-token"; @Mock JwtProvider jwtProvider; + @Mock + SystemAuthorizationPort systemAuthorizationPort; + JwtAuthenticationFilter jwtAuthenticationFilter; MockHttpServletRequest request; @@ -43,7 +48,7 @@ class JwtAuthenticationFilterTest { @BeforeEach void setUp() { - jwtAuthenticationFilter = new JwtAuthenticationFilter(jwtProvider); + jwtAuthenticationFilter = new JwtAuthenticationFilter(jwtProvider, systemAuthorizationPort); request = new MockHttpServletRequest(); response = new MockHttpServletResponse(); filterChain = new MockFilterChain(); @@ -58,7 +63,6 @@ private JwtProvider.Claims createValidClaims() { return new JwtProvider.Claims( MEMBER_ID, EMAIL, - ROLES, Instant.now().plusSeconds(3600) ); } @@ -73,6 +77,7 @@ void sets_authentication_in_security_context() throws Exception { request.addHeader("Authorization", "Bearer " + VALID_TOKEN); given(jwtProvider.isValid(VALID_TOKEN)).willReturn(true); given(jwtProvider.validateAndGetClaims(VALID_TOKEN)).willReturn(createValidClaims()); + given(systemAuthorizationPort.getRoles(MEMBER_ID)).willReturn(ROLES); jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); @@ -87,6 +92,7 @@ void custom_user_details_contains_correct_info() throws Exception { request.addHeader("Authorization", "Bearer " + VALID_TOKEN); given(jwtProvider.isValid(VALID_TOKEN)).willReturn(true); given(jwtProvider.validateAndGetClaims(VALID_TOKEN)).willReturn(createValidClaims()); + given(systemAuthorizationPort.getRoles(MEMBER_ID)).willReturn(ROLES); jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); @@ -106,6 +112,7 @@ void filter_chain_continues() throws Exception { request.addHeader("Authorization", "Bearer " + VALID_TOKEN); given(jwtProvider.isValid(VALID_TOKEN)).willReturn(true); given(jwtProvider.validateAndGetClaims(VALID_TOKEN)).willReturn(createValidClaims()); + given(systemAuthorizationPort.getRoles(MEMBER_ID)).willReturn(ROLES); jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); diff --git a/core/domain/src/main/java/me/chan99k/learningmanager/member/Member.java b/core/domain/src/main/java/me/chan99k/learningmanager/member/Member.java index 157a49d7..d09f868d 100644 --- a/core/domain/src/main/java/me/chan99k/learningmanager/member/Member.java +++ b/core/domain/src/main/java/me/chan99k/learningmanager/member/Member.java @@ -19,8 +19,6 @@ public class Member extends AbstractEntity { private Nickname nickname; - private SystemRole role; - private MemberStatus status; private String profileImageUrl; @@ -34,7 +32,6 @@ public static Member reconstitute( Long id, Email primaryEmail, Nickname nickname, - SystemRole role, MemberStatus status, String profileImageUrl, String selfIntroduction, @@ -49,7 +46,6 @@ public static Member reconstitute( member.setId(id); member.primaryEmail = primaryEmail; member.nickname = nickname; - member.role = role; member.status = status; member.profileImageUrl = profileImageUrl; member.selfIntroduction = selfIntroduction; @@ -65,7 +61,6 @@ public static Member reconstitute( /* 도메인 로직 */ public static Member registerDefault(NicknameGenerator nicknameGenerator) { Member member = new Member(); - member.role = SystemRole.MEMBER; member.status = MemberStatus.PENDING; member.nickname = Nickname.generateNickname(nicknameGenerator); @@ -134,16 +129,6 @@ public void updateProfile(String profileImageUrl, String selfIntroduction) { this.selfIntroduction = selfIntroduction; } - public void promoteToAdmin() { - state(this.role == SystemRole.MEMBER, MEMBER_NOT_GENERAL.getMessage()); - this.role = SystemRole.ADMIN; - } - - public void demoteToMember() { - state(this.role == SystemRole.ADMIN, MEMBER_NOT_ADMIN.getMessage()); - this.role = SystemRole.MEMBER; - } - public void deactivate() { state(this.status != MemberStatus.INACTIVE, MEMBER_ALREADY_INACTIVE.getMessage()); this.status = MemberStatus.INACTIVE; @@ -185,10 +170,6 @@ public Nickname getNickname() { return nickname; } - public SystemRole getRole() { - return role; - } - public MemberStatus getStatus() { return status; } diff --git a/core/domain/src/main/java/me/chan99k/learningmanager/member/SystemRole.java b/core/domain/src/main/java/me/chan99k/learningmanager/member/SystemRole.java index 22110a5f..eac1da2c 100644 --- a/core/domain/src/main/java/me/chan99k/learningmanager/member/SystemRole.java +++ b/core/domain/src/main/java/me/chan99k/learningmanager/member/SystemRole.java @@ -1,16 +1,19 @@ package me.chan99k.learningmanager.member; public enum SystemRole { - ADMIN("시스템 관리자"), - MEMBER("회원"), - SUPERVISOR("감독관"), - OPERATOR("운영자"), - REGISTRAR("학적 담당"), - AUDITOR("감사관"); + ADMIN("시스템 관리자", 3), + MEMBER("회원", 0), + SUPERVISOR("감독관", 2), + OPERATOR("운영자", 1), + REGISTRAR("학적 담당", 1), + AUDITOR("감사관", 1); - public final String value; + public final String description; - SystemRole(String value) { - this.value = value; + public final int level; + + SystemRole(String description, int level) { + this.description = description; + this.level = level; } } diff --git a/core/domain/src/main/java/me/chan99k/learningmanager/member/SystemRoleHierarchy.java b/core/domain/src/main/java/me/chan99k/learningmanager/member/SystemRoleHierarchy.java new file mode 100644 index 00000000..9b55baa2 --- /dev/null +++ b/core/domain/src/main/java/me/chan99k/learningmanager/member/SystemRoleHierarchy.java @@ -0,0 +1,62 @@ +package me.chan99k.learningmanager.member; + +import java.util.Arrays; +import java.util.EnumSet; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 시스템 역할의 계층 구조를 정의. + * + *
+ * 계층 구조 (높은 레벨 → 낮은 레벨):
+ *
+ * Level 3: ADMIN (전체 시스템 권한)
+ *            |
+ * Level 2: SUPERVISOR (감독/모니터링)
+ *            |
+ * Level 1: OPERATOR | REGISTRAR | AUDITOR (동일 레벨, 다른 책임)
+ *            |
+ * Level 0: MEMBER (기본 사용자)
+ * 
+ * + *

상위 역할은 자기 자신과 더 낮은 레벨의 모든 역할 권한을 포함함.

+ */ +public class SystemRoleHierarchy { + + public int getLevel(SystemRole role) { + return role.level; + } + + public boolean isHigherOrEqual(SystemRole role, SystemRole minimumRole) { + return role.level >= minimumRole.level; + } + + public boolean isHigher(SystemRole role, SystemRole otherRole) { + return role.level > otherRole.level; + } + + public Set getIncludedRoles(SystemRole role) { + return Arrays.stream(SystemRole.values()) + .filter(r -> r.level <= role.level) + .collect(Collectors.toCollection(() -> EnumSet.noneOf(SystemRole.class))); + } + + /** + * 여러 역할이 포함하는 모든 하위 역할 반환. + * + * @param roles 역할 집합 + * @return 포함되는 역할 집합 + */ + public Set getIncludedRoles(Set roles) { + int maxLevel = roles.stream() + .mapToInt(r -> r.level) + .max() + .orElse(0); + + return Arrays.stream(SystemRole.values()) + .filter(r -> r.level <= maxLevel) + .collect(Collectors.toCollection(() -> EnumSet.noneOf(SystemRole.class))); + } + +} diff --git a/core/domain/src/test/java/me/chan99k/learningmanager/member/MemberTest.java b/core/domain/src/test/java/me/chan99k/learningmanager/member/MemberTest.java index 0430fbc3..c347e727 100644 --- a/core/domain/src/test/java/me/chan99k/learningmanager/member/MemberTest.java +++ b/core/domain/src/test/java/me/chan99k/learningmanager/member/MemberTest.java @@ -23,7 +23,6 @@ private Member createTestMember() { Member testMember = new Member(); ReflectionTestUtils.setField(testMember, "nickname", new Nickname("testuser")); - ReflectionTestUtils.setField(testMember, "role", SystemRole.MEMBER); ReflectionTestUtils.setField(testMember, "status", MemberStatus.ACTIVE); ReflectionTestUtils.setField(testMember, "accounts", new ArrayList()); return testMember; @@ -70,7 +69,6 @@ void success_to_register_default_member() { assertThat(defaultMember).isNotNull(); assertThat(defaultMember.getNickname().value()).isEqualTo("defaultUser"); - assertThat(defaultMember.getRole()).isEqualTo(SystemRole.MEMBER); assertThat(defaultMember.getStatus()).isEqualTo(MemberStatus.PENDING); } } @@ -174,43 +172,6 @@ void unban_fails_if_not_banned() { } } - @Nested - @DisplayName("회원 권한 테스트") - class RoleTest { - @Test - @DisplayName("[Success] 관리자 승급에 성공한다.") - void success_to_promote_admin() { - member.promoteToAdmin(); - assertThat(member.getRole()).isEqualTo(SystemRole.ADMIN); - } - - @Test - @DisplayName("[Failure] 이미 관리자인 경우, 관리자 승급에 실패한다.") - void fail_to_promote_admin() { - member.promoteToAdmin(); - assertThat(member.getRole()).isEqualTo(SystemRole.ADMIN); - assertThatThrownBy(() -> member.promoteToAdmin()).isInstanceOf(IllegalStateException.class) - .hasMessage(MEMBER_NOT_GENERAL.getMessage()); - } - - @Test - @DisplayName("[Success] 회원등급 강등에 성공한다.") - void success_to_demote_member() { - member.promoteToAdmin(); - assertThat(member.getRole()).isEqualTo(SystemRole.ADMIN); - - member.demoteToMember(); - assertThat(member.getRole()).isEqualTo(SystemRole.MEMBER); - } - - @Test - @DisplayName("[Failure] 더 강등할 등급이 없는 경우, 회원 등급 강등에 실패한다..") - void fail_to_demote_member() { - assertThatThrownBy(() -> member.demoteToMember()).isInstanceOf(IllegalStateException.class) - .hasMessage(MEMBER_NOT_ADMIN.getMessage()); - } - } - @Nested @DisplayName("계정 정보 관리 테스트") class AccountManagementTest { diff --git a/core/domain/src/test/java/me/chan99k/learningmanager/member/SystemRoleHierarchyTest.java b/core/domain/src/test/java/me/chan99k/learningmanager/member/SystemRoleHierarchyTest.java new file mode 100644 index 00000000..d964f448 --- /dev/null +++ b/core/domain/src/test/java/me/chan99k/learningmanager/member/SystemRoleHierarchyTest.java @@ -0,0 +1,239 @@ +package me.chan99k.learningmanager.member; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class SystemRoleHierarchyTest { + + private SystemRoleHierarchy hierarchy; + + @BeforeEach + void setUp() { + hierarchy = new SystemRoleHierarchy(); + } + + @Nested + @DisplayName("getLevel") + class GetLevelTest { + + @Test + @DisplayName("ADMIN의 레벨은 3이다") + void admin_level_is_3() { + assertThat(hierarchy.getLevel(SystemRole.ADMIN)).isEqualTo(3); + } + + @Test + @DisplayName("SUPERVISOR의 레벨은 2이다") + void supervisor_level_is_2() { + assertThat(hierarchy.getLevel(SystemRole.SUPERVISOR)).isEqualTo(2); + } + + @Test + @DisplayName("OPERATOR, REGISTRAR, AUDITOR의 레벨은 1이다") + void level1_roles() { + assertThat(hierarchy.getLevel(SystemRole.OPERATOR)).isEqualTo(1); + assertThat(hierarchy.getLevel(SystemRole.REGISTRAR)).isEqualTo(1); + assertThat(hierarchy.getLevel(SystemRole.AUDITOR)).isEqualTo(1); + } + + @Test + @DisplayName("MEMBER의 레벨은 0이다") + void member_level_is_0() { + assertThat(hierarchy.getLevel(SystemRole.MEMBER)).isEqualTo(0); + } + } + + @Nested + @DisplayName("isHigherOrEqual") + class IsHigherOrEqualTest { + + @Test + @DisplayName("ADMIN은 모든 역할보다 높거나 같다") + void admin_is_higher_or_equal_to_all() { + for (SystemRole role : SystemRole.values()) { + assertThat(hierarchy.isHigherOrEqual(SystemRole.ADMIN, role)) + .as("ADMIN >= %s", role) + .isTrue(); + } + } + + @Test + @DisplayName("MEMBER는 MEMBER보다만 높거나 같다") + void member_is_only_higher_or_equal_to_member() { + assertThat(hierarchy.isHigherOrEqual(SystemRole.MEMBER, SystemRole.MEMBER)).isTrue(); + assertThat(hierarchy.isHigherOrEqual(SystemRole.MEMBER, SystemRole.OPERATOR)).isFalse(); + assertThat(hierarchy.isHigherOrEqual(SystemRole.MEMBER, SystemRole.SUPERVISOR)).isFalse(); + assertThat(hierarchy.isHigherOrEqual(SystemRole.MEMBER, SystemRole.ADMIN)).isFalse(); + } + + @Test + @DisplayName("동일 레벨의 역할끼리는 서로 높거나 같다") + void same_level_roles_are_equal() { + assertThat(hierarchy.isHigherOrEqual(SystemRole.OPERATOR, SystemRole.REGISTRAR)).isTrue(); + assertThat(hierarchy.isHigherOrEqual(SystemRole.REGISTRAR, SystemRole.AUDITOR)).isTrue(); + assertThat(hierarchy.isHigherOrEqual(SystemRole.AUDITOR, SystemRole.OPERATOR)).isTrue(); + } + + @Test + @DisplayName("SUPERVISOR는 ADMIN보다 낮다") + void supervisor_is_lower_than_admin() { + assertThat(hierarchy.isHigherOrEqual(SystemRole.SUPERVISOR, SystemRole.ADMIN)).isFalse(); + } + } + + @Nested + @DisplayName("isHigher") + class IsHigherTest { + + @Test + @DisplayName("ADMIN은 다른 모든 역할보다 높다") + void admin_is_higher_than_others() { + assertThat(hierarchy.isHigher(SystemRole.ADMIN, SystemRole.SUPERVISOR)).isTrue(); + assertThat(hierarchy.isHigher(SystemRole.ADMIN, SystemRole.OPERATOR)).isTrue(); + assertThat(hierarchy.isHigher(SystemRole.ADMIN, SystemRole.MEMBER)).isTrue(); + } + + @Test + @DisplayName("동일한 역할은 자기 자신보다 높지 않다") + void same_role_is_not_higher() { + for (SystemRole role : SystemRole.values()) { + assertThat(hierarchy.isHigher(role, role)) + .as("%s is not higher than itself", role) + .isFalse(); + } + } + + @Test + @DisplayName("동일 레벨의 역할끼리는 서로 높지 않다") + void same_level_roles_are_not_higher() { + assertThat(hierarchy.isHigher(SystemRole.OPERATOR, SystemRole.REGISTRAR)).isFalse(); + assertThat(hierarchy.isHigher(SystemRole.REGISTRAR, SystemRole.AUDITOR)).isFalse(); + assertThat(hierarchy.isHigher(SystemRole.AUDITOR, SystemRole.OPERATOR)).isFalse(); + } + + @Test + @DisplayName("하위 역할은 상위 역할보다 높지 않다") + void lower_role_is_not_higher() { + assertThat(hierarchy.isHigher(SystemRole.MEMBER, SystemRole.ADMIN)).isFalse(); + assertThat(hierarchy.isHigher(SystemRole.OPERATOR, SystemRole.SUPERVISOR)).isFalse(); + } + } + + @Nested + @DisplayName("getIncludedRoles (단일 역할)") + class GetIncludedRolesSingleTest { + + @Test + @DisplayName("ADMIN은 모든 역할의 권한을 포함한다") + void admin_includes_all_roles() { + Set included = hierarchy.getIncludedRoles(SystemRole.ADMIN); + + assertThat(included).containsExactlyInAnyOrder( + SystemRole.ADMIN, + SystemRole.SUPERVISOR, + SystemRole.OPERATOR, + SystemRole.REGISTRAR, + SystemRole.AUDITOR, + SystemRole.MEMBER + ); + } + + @Test + @DisplayName("SUPERVISOR는 레벨 2 이하의 역할을 포함한다") + void supervisor_includes_level2_and_below() { + Set included = hierarchy.getIncludedRoles(SystemRole.SUPERVISOR); + + assertThat(included).containsExactlyInAnyOrder( + SystemRole.SUPERVISOR, + SystemRole.OPERATOR, + SystemRole.REGISTRAR, + SystemRole.AUDITOR, + SystemRole.MEMBER + ); + assertThat(included).doesNotContain(SystemRole.ADMIN); + } + + @Test + @DisplayName("OPERATOR는 레벨 1 이하의 역할을 포함한다") + void operator_includes_level1_and_below() { + Set included = hierarchy.getIncludedRoles(SystemRole.OPERATOR); + + assertThat(included).containsExactlyInAnyOrder( + SystemRole.OPERATOR, + SystemRole.REGISTRAR, + SystemRole.AUDITOR, + SystemRole.MEMBER + ); + assertThat(included).doesNotContain(SystemRole.ADMIN, SystemRole.SUPERVISOR); + } + + @Test + @DisplayName("MEMBER는 자기 자신만 포함한다") + void member_includes_only_itself() { + Set included = hierarchy.getIncludedRoles(SystemRole.MEMBER); + + assertThat(included).containsExactly(SystemRole.MEMBER); + } + } + + @Nested + @DisplayName("getIncludedRoles (역할 집합)") + class GetIncludedRolesSetTest { + + @Test + @DisplayName("빈 집합은 MEMBER 권한만 포함한다") + void empty_set_includes_member() { + Set included = hierarchy.getIncludedRoles(Set.of()); + + assertThat(included).containsExactly(SystemRole.MEMBER); + } + + @Test + @DisplayName("여러 역할 중 최고 레벨 기준으로 포함 역할이 결정된다") + void multiple_roles_use_max_level() { + Set included = hierarchy.getIncludedRoles( + Set.of(SystemRole.OPERATOR, SystemRole.MEMBER) + ); + + assertThat(included).containsExactlyInAnyOrder( + SystemRole.OPERATOR, + SystemRole.REGISTRAR, + SystemRole.AUDITOR, + SystemRole.MEMBER + ); + } + + @Test + @DisplayName("SUPERVISOR와 OPERATOR가 있으면 SUPERVISOR 기준으로 역할을 포함한다") + void supervisor_and_operator_uses_supervisor_level() { + Set included = hierarchy.getIncludedRoles( + Set.of(SystemRole.SUPERVISOR, SystemRole.OPERATOR) + ); + + assertThat(included).containsExactlyInAnyOrder( + SystemRole.SUPERVISOR, + SystemRole.OPERATOR, + SystemRole.REGISTRAR, + SystemRole.AUDITOR, + SystemRole.MEMBER + ); + assertThat(included).doesNotContain(SystemRole.ADMIN); + } + + @Test + @DisplayName("ADMIN이 포함되면 모든 역할의 권한을 갖는다") + void admin_in_set_includes_all() { + Set included = hierarchy.getIncludedRoles( + Set.of(SystemRole.ADMIN, SystemRole.MEMBER) + ); + + assertThat(included).containsExactlyInAnyOrder(SystemRole.values()); + } + } +} diff --git a/core/requires/src/main/java/me/chan99k/learningmanager/authentication/JwtProvider.java b/core/requires/src/main/java/me/chan99k/learningmanager/authentication/JwtProvider.java index 1cc5d8de..6a6c78f7 100644 --- a/core/requires/src/main/java/me/chan99k/learningmanager/authentication/JwtProvider.java +++ b/core/requires/src/main/java/me/chan99k/learningmanager/authentication/JwtProvider.java @@ -1,23 +1,24 @@ package me.chan99k.learningmanager.authentication; import java.time.Instant; -import java.util.List; public interface JwtProvider { - String createAccessToken(Long memberId, String email, List roles); + /** + * Access Token 생성 - 신원 정보만 포함, 역할은 런타임에 조회 + */ + String createAccessToken(Long memberId, String email); /** * 토큰을 검증하고 클레임을 추출 * * @param token JWT 문자열 * @return 클레임 정보 - * @throws me.chan99k.learningmanager.exception.DomainException 토큰이 유효하지 않은 경우 */ Claims validateAndGetClaims(String token); /** - * 토큰 유효성만 검사 (예외 발생 X) + * 토큰 유효성만 검사 - 예외 발생 X */ boolean isValid(String token); @@ -26,8 +27,8 @@ public interface JwtProvider { record Claims( Long memberId, String email, - List roles, Instant expiresAt ) { } + } diff --git a/core/requires/src/main/java/me/chan99k/learningmanager/authorization/SystemAuthorizationPort.java b/core/requires/src/main/java/me/chan99k/learningmanager/authorization/SystemAuthorizationPort.java new file mode 100644 index 00000000..ed195edc --- /dev/null +++ b/core/requires/src/main/java/me/chan99k/learningmanager/authorization/SystemAuthorizationPort.java @@ -0,0 +1,32 @@ +package me.chan99k.learningmanager.authorization; + +import java.util.Set; + +import me.chan99k.learningmanager.member.SystemRole; + +/** + * 시스템 레벨 역할의 인가를 위한 포트. + */ +public interface SystemAuthorizationPort { + + boolean hasRole(Long memberId, SystemRole role); + + boolean hasAnyRole(Long memberId, Set roles); + + Set getRoles(Long memberId); + + void grantRole(Long memberId, SystemRole role); + + void revokeRole(Long memberId, SystemRole role); + + /** + * 회원이 지정된 최소 계층 이상의 역할을 가지고 있는지 확인. + * 역할 계층: ADMIN(3) > SUPERVISOR(2) > OPERATOR,REGISTRAR,AUDITOR(1) > MEMBER(0) + * + * @param memberId 회원 ID + * @param minimumRole 최소 요구 역할 + * @return 해당 계층 이상 역할 보유 시 true + */ + boolean hasRoleOrHigher(Long memberId, SystemRole minimumRole); + +} diff --git a/core/service/src/main/java/me/chan99k/learningmanager/authentication/IssueTokenService.java b/core/service/src/main/java/me/chan99k/learningmanager/authentication/IssueTokenService.java index c73ca473..374dcc7f 100644 --- a/core/service/src/main/java/me/chan99k/learningmanager/authentication/IssueTokenService.java +++ b/core/service/src/main/java/me/chan99k/learningmanager/authentication/IssueTokenService.java @@ -4,7 +4,6 @@ import static me.chan99k.learningmanager.member.CredentialType.*; import java.time.Duration; -import java.util.List; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -43,29 +42,27 @@ public IssueTokenService(MemberQueryRepository accountQueryRepository, PasswordE @Override @Transactional(readOnly = true) public Response issueToken(Request request) { - // 1. 이메일로 Member 조회 + // 이메일로 Member 조회 Email email = Email.of(request.email()); Member member = memberQueryRepository.findByEmail(email) .orElseThrow(() -> new DomainException(INVALID_CREDENTIALS)); - // 2. Account에서 PASSWORD 타입 Credential 조회 + // Account에서 PASSWORD 타입 Credential 조회 Account account = member.findAccountByEmail(email); Credential credential = account.findCredentialByType(PASSWORD); - // 3. 비밀번호 검증 + // 비밀번호 검증 if (!passwordEncoder.matches(request.password(), credential.getSecret())) { throw new DomainException(INVALID_CREDENTIALS); } - // 4. Access Token 발급 - List roles = List.of(member.getRole().name()); + // Access Token 발급 String accessToken = jwtProvider.createAccessToken( member.getId(), - account.getEmail().address(), - roles + account.getEmail().address() ); - // 5. Refresh Token 발급 및 저장 + // Refresh Token 발급 및 저장 RefreshToken refreshToken = RefreshToken.create(member.getId(), refreshTokenTtlHours); refreshTokenRepository.save(refreshToken); diff --git a/core/service/src/main/java/me/chan99k/learningmanager/authentication/RefreshAccessTokenService.java b/core/service/src/main/java/me/chan99k/learningmanager/authentication/RefreshAccessTokenService.java index 02af8d1c..9c3c3285 100644 --- a/core/service/src/main/java/me/chan99k/learningmanager/authentication/RefreshAccessTokenService.java +++ b/core/service/src/main/java/me/chan99k/learningmanager/authentication/RefreshAccessTokenService.java @@ -3,7 +3,6 @@ import static me.chan99k.learningmanager.authentication.AuthProblemCode.*; import java.time.Duration; -import java.util.List; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -33,11 +32,11 @@ public RefreshAccessTokenService(RefreshTokenRepository refreshTokenRepository, @Override public Response refresh(Request request) { - // 1. Refresh Token 조회 + // Refresh Token 조회 RefreshToken refreshToken = refreshTokenRepository.findByToken(request.refreshToken()) .orElseThrow(() -> new DomainException(TOKEN_NOT_FOUND)); - // 2. 토큰 상태 검증 + // 토큰 상태 검증 if (refreshToken.isRevoked()) { throw new DomainException(REVOKED_TOKEN); } @@ -45,23 +44,21 @@ public Response refresh(Request request) { throw new DomainException(EXPIRED_TOKEN); } - // 3. Member 조회 + // Member 조회 Member member = memberQueryRepository.findById(refreshToken.getMemberId()) .orElseThrow(() -> new DomainException(INVALID_TOKEN)); - // 4. 기존 Refresh Token 폐기 (Rotation) + // 기존 Refresh Token 폐기 (Rotation) refreshTokenRepository.revokeByToken(request.refreshToken()); - // 5. 새 Access Token 발급 + // 새 Access Token 발급 (Minimal JWT: 역할은 런타임에 조회) String email = member.getPrimaryEmail().address(); - List roles = List.of(member.getRole().name()); String newAccessToken = jwtProvider.createAccessToken( member.getId(), - email, - roles + email ); - // 6. 새 Refresh Token 발급 + // 새 Refresh Token 발급 RefreshToken newRefreshToken = RefreshToken.create(member.getId(), refreshTokenTtl); refreshTokenRepository.save(newRefreshToken); diff --git a/core/service/src/main/java/me/chan99k/learningmanager/course/CourseCreationService.java b/core/service/src/main/java/me/chan99k/learningmanager/course/CourseCreationService.java index 8d5d2fa2..b58a79ab 100644 --- a/core/service/src/main/java/me/chan99k/learningmanager/course/CourseCreationService.java +++ b/core/service/src/main/java/me/chan99k/learningmanager/course/CourseCreationService.java @@ -3,30 +3,28 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import me.chan99k.learningmanager.authorization.SystemAuthorizationPort; import me.chan99k.learningmanager.exception.DomainException; -import me.chan99k.learningmanager.member.Member; -import me.chan99k.learningmanager.member.MemberProblemCode; -import me.chan99k.learningmanager.member.MemberQueryRepository; import me.chan99k.learningmanager.member.SystemRole; @Service @Transactional public class CourseCreationService implements CourseCreation { + private final CourseCommandRepository commandRepository; - private final MemberQueryRepository memberQueryRepository; + private final SystemAuthorizationPort systemAuthorizationPort; - public CourseCreationService(CourseCommandRepository commandRepository, - MemberQueryRepository memberQueryRepository) { + public CourseCreationService( + CourseCommandRepository commandRepository, + SystemAuthorizationPort systemAuthorizationPort + ) { this.commandRepository = commandRepository; - this.memberQueryRepository = memberQueryRepository; + this.systemAuthorizationPort = systemAuthorizationPort; } @Override public Response createCourse(Long requestedBy, Request request) { - Member member = memberQueryRepository.findById(requestedBy) - .orElseThrow(() -> new DomainException(MemberProblemCode.MEMBER_NOT_FOUND)); - - if (!member.getRole().equals(SystemRole.ADMIN)) { // 인가 - 권한 확인 + if (!systemAuthorizationPort.hasRole(requestedBy, SystemRole.ADMIN)) { throw new DomainException(CourseProblemCode.ADMIN_ONLY_COURSE_CREATION); } @@ -37,4 +35,5 @@ public Response createCourse(Long requestedBy, Request request) { return new CourseCreation.Response(savedCourse.getId()); } + } diff --git a/core/service/src/main/java/me/chan99k/learningmanager/member/MemberStatusChangeService.java b/core/service/src/main/java/me/chan99k/learningmanager/member/MemberStatusChangeService.java index f4517765..5911b979 100644 --- a/core/service/src/main/java/me/chan99k/learningmanager/member/MemberStatusChangeService.java +++ b/core/service/src/main/java/me/chan99k/learningmanager/member/MemberStatusChangeService.java @@ -3,6 +3,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import me.chan99k.learningmanager.authorization.SystemAuthorizationPort; import me.chan99k.learningmanager.exception.DomainException; @Service @@ -11,20 +12,21 @@ public class MemberStatusChangeService implements MemberStatusChange { private final MemberQueryRepository memberQueryRepository; private final MemberCommandRepository memberCommandRepository; + private final SystemAuthorizationPort systemAuthorizationPort; public MemberStatusChangeService( MemberQueryRepository memberQueryRepository, - MemberCommandRepository memberCommandRepository) { + MemberCommandRepository memberCommandRepository, + SystemAuthorizationPort systemAuthorizationPort + ) { this.memberQueryRepository = memberQueryRepository; this.memberCommandRepository = memberCommandRepository; + this.systemAuthorizationPort = systemAuthorizationPort; } @Override public void changeStatus(Long requestedBy, Request request) { - Member currentMember = memberQueryRepository.findById(requestedBy) - .orElseThrow(() -> new DomainException(MemberProblemCode.MEMBER_NOT_FOUND)); - - if (currentMember.getRole() != SystemRole.ADMIN) { + if (!systemAuthorizationPort.hasRole(requestedBy, SystemRole.ADMIN)) { throw new DomainException(MemberProblemCode.ADMIN_ONLY_ACTION); } @@ -42,4 +44,5 @@ public void changeStatus(Long requestedBy, Request request) { memberCommandRepository.save(targetMember); } + } \ No newline at end of file diff --git a/core/service/src/main/java/me/chan99k/learningmanager/session/SessionCreationService.java b/core/service/src/main/java/me/chan99k/learningmanager/session/SessionCreationService.java index aef5f74a..5b0af5a4 100644 --- a/core/service/src/main/java/me/chan99k/learningmanager/session/SessionCreationService.java +++ b/core/service/src/main/java/me/chan99k/learningmanager/session/SessionCreationService.java @@ -5,33 +5,35 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import me.chan99k.learningmanager.authorization.SystemAuthorizationPort; import me.chan99k.learningmanager.course.Course; import me.chan99k.learningmanager.course.CourseProblemCode; import me.chan99k.learningmanager.course.CourseQueryRepository; import me.chan99k.learningmanager.exception.DomainException; -import me.chan99k.learningmanager.member.Member; import me.chan99k.learningmanager.member.MemberProblemCode; -import me.chan99k.learningmanager.member.MemberQueryRepository; import me.chan99k.learningmanager.member.SystemRole; @Service @Transactional public class SessionCreationService implements SessionCreation { + private final SessionQueryRepository sessionQueryRepository; private final SessionCommandRepository sessionCommandRepository; private final CourseQueryRepository courseQueryRepository; - private final MemberQueryRepository memberQueryRepository; + private final SystemAuthorizationPort systemAuthorizationPort; private final Clock clock; - public SessionCreationService(SessionQueryRepository sessionQueryRepository, + public SessionCreationService( + SessionQueryRepository sessionQueryRepository, SessionCommandRepository sessionCommandRepository, CourseQueryRepository courseQueryRepository, - MemberQueryRepository memberQueryRepository, - Clock clock) { + SystemAuthorizationPort systemAuthorizationPort, + Clock clock + ) { this.sessionQueryRepository = sessionQueryRepository; this.sessionCommandRepository = sessionCommandRepository; this.courseQueryRepository = courseQueryRepository; - this.memberQueryRepository = memberQueryRepository; + this.systemAuthorizationPort = systemAuthorizationPort; this.clock = clock; } @@ -44,12 +46,9 @@ public Session createSession(Request request) { } private void validatePermission(Request request, Long memberId) { - Member member = memberQueryRepository.findById(memberId) - .orElseThrow(() -> new DomainException(MemberProblemCode.MEMBER_NOT_FOUND)); - // 단독 세션은 시스템 관리자만 생성 가능 if (isStandaloneSession(request)) { - if (!member.getRole().equals(SystemRole.ADMIN)) { + if (!systemAuthorizationPort.hasRole(memberId, SystemRole.ADMIN)) { throw new DomainException(MemberProblemCode.ADMIN_ONLY_ACTION); } return; diff --git a/core/service/src/main/java/me/chan99k/learningmanager/session/SessionDeletionService.java b/core/service/src/main/java/me/chan99k/learningmanager/session/SessionDeletionService.java index 260e830c..9b482c97 100644 --- a/core/service/src/main/java/me/chan99k/learningmanager/session/SessionDeletionService.java +++ b/core/service/src/main/java/me/chan99k/learningmanager/session/SessionDeletionService.java @@ -3,30 +3,32 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import me.chan99k.learningmanager.authorization.SystemAuthorizationPort; import me.chan99k.learningmanager.course.CourseProblemCode; import me.chan99k.learningmanager.course.CourseQueryRepository; import me.chan99k.learningmanager.exception.DomainException; -import me.chan99k.learningmanager.member.Member; import me.chan99k.learningmanager.member.MemberProblemCode; -import me.chan99k.learningmanager.member.MemberQueryRepository; import me.chan99k.learningmanager.member.SystemRole; @Service @Transactional public class SessionDeletionService implements SessionDeletion { + private final SessionQueryRepository sessionQueryRepository; private final SessionCommandRepository sessionCommandRepository; private final CourseQueryRepository courseQueryRepository; - private final MemberQueryRepository memberQueryRepository; + private final SystemAuthorizationPort systemAuthorizationPort; - public SessionDeletionService(SessionQueryRepository sessionQueryRepository, + public SessionDeletionService( + SessionQueryRepository sessionQueryRepository, SessionCommandRepository sessionCommandRepository, CourseQueryRepository courseQueryRepository, - MemberQueryRepository memberQueryRepository) { + SystemAuthorizationPort systemAuthorizationPort + ) { this.sessionQueryRepository = sessionQueryRepository; this.sessionCommandRepository = sessionCommandRepository; this.courseQueryRepository = courseQueryRepository; - this.memberQueryRepository = memberQueryRepository; + this.systemAuthorizationPort = systemAuthorizationPort; } @Override @@ -45,12 +47,9 @@ private Session getSessionById(Long sessionId) { } private void validateDeletionPermission(Session session, Long memberId) { - Member member = memberQueryRepository.findById(memberId) - .orElseThrow(() -> new DomainException(MemberProblemCode.MEMBER_NOT_FOUND)); - // 단독 세션은 시스템 관리자만 삭제 가능 if (isStandaloneSession(session)) { - if (!member.getRole().equals(SystemRole.ADMIN)) { + if (!systemAuthorizationPort.hasRole(memberId, SystemRole.ADMIN)) { throw new DomainException(MemberProblemCode.ADMIN_ONLY_ACTION); } return; diff --git a/core/service/src/main/java/me/chan99k/learningmanager/session/SessionUpdateService.java b/core/service/src/main/java/me/chan99k/learningmanager/session/SessionUpdateService.java index 169e8615..9c6999b5 100644 --- a/core/service/src/main/java/me/chan99k/learningmanager/session/SessionUpdateService.java +++ b/core/service/src/main/java/me/chan99k/learningmanager/session/SessionUpdateService.java @@ -5,32 +5,34 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import me.chan99k.learningmanager.authorization.SystemAuthorizationPort; import me.chan99k.learningmanager.course.CourseProblemCode; import me.chan99k.learningmanager.course.CourseQueryRepository; import me.chan99k.learningmanager.exception.DomainException; -import me.chan99k.learningmanager.member.Member; import me.chan99k.learningmanager.member.MemberProblemCode; -import me.chan99k.learningmanager.member.MemberQueryRepository; import me.chan99k.learningmanager.member.SystemRole; @Service @Transactional public class SessionUpdateService implements SessionUpdate { + private final SessionQueryRepository sessionQueryRepository; private final SessionCommandRepository sessionCommandRepository; private final CourseQueryRepository courseQueryRepository; - private final MemberQueryRepository memberQueryRepository; + private final SystemAuthorizationPort systemAuthorizationPort; private final Clock clock; - public SessionUpdateService(SessionQueryRepository sessionQueryRepository, + public SessionUpdateService( + SessionQueryRepository sessionQueryRepository, SessionCommandRepository sessionCommandRepository, CourseQueryRepository courseQueryRepository, - MemberQueryRepository memberQueryRepository, - Clock clock) { + SystemAuthorizationPort systemAuthorizationPort, + Clock clock + ) { this.sessionQueryRepository = sessionQueryRepository; this.sessionCommandRepository = sessionCommandRepository; this.courseQueryRepository = courseQueryRepository; - this.memberQueryRepository = memberQueryRepository; + this.systemAuthorizationPort = systemAuthorizationPort; this.clock = clock; } @@ -51,12 +53,9 @@ private Session getSessionById(Long sessionId) { } private void validateUpdatePermission(Session session, Long memberId) { - Member member = memberQueryRepository.findById(memberId) - .orElseThrow(() -> new DomainException(MemberProblemCode.MEMBER_NOT_FOUND)); - // 단독 세션은 시스템 관리자가 수정 가능 if (isStandaloneSession(session)) { - if (!member.getRole().equals(SystemRole.ADMIN)) { + if (!systemAuthorizationPort.hasRole(memberId, SystemRole.ADMIN)) { throw new DomainException(MemberProblemCode.ADMIN_ONLY_ACTION); } return; diff --git a/core/service/src/test/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionApprovalServiceTest.java b/core/service/src/test/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionApprovalServiceTest.java index 5856e081..50dac760 100644 --- a/core/service/src/test/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionApprovalServiceTest.java +++ b/core/service/src/test/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionApprovalServiceTest.java @@ -66,6 +66,36 @@ void approve_fail_if_attendance_not_found() { .hasFieldOrPropertyWithValue("problemCode", AttendanceProblemCode.ATTENDANCE_NOT_FOUND); } + @Test + @DisplayName("[Failure] 대기 중인 수정 요청이 없으면 예외가 발생한다") + void approve_fail_if_no_pending_request() { + Attendance attendance = createAttendanceWithoutPendingRequest(); + when(attendanceQueryRepository.findById(ATTENDANCE_ID)).thenReturn(Optional.of(attendance)); + + AttendanceCorrectionApproval.Request request = new AttendanceCorrectionApproval.Request(ATTENDANCE_ID); + + assertThatThrownBy(() -> service.approve(APPROVER_ID, request)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("대기 중인 수정 요청이 없습니다"); + + verify(attendanceCommandRepository, never()).save(any()); + } + + @Test + @DisplayName("[Failure] 이미 처리된 요청을 다시 승인하면 예외가 발생한다") + void approve_fail_if_already_processed() { + Attendance attendance = createAttendanceWithAlreadyApprovedRequest(); + when(attendanceQueryRepository.findById(ATTENDANCE_ID)).thenReturn(Optional.of(attendance)); + + AttendanceCorrectionApproval.Request request = new AttendanceCorrectionApproval.Request(ATTENDANCE_ID); + + assertThatThrownBy(() -> service.approve(APPROVER_ID, request)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("대기 중인 수정 요청이 없습니다"); + + verify(attendanceCommandRepository, never()).save(any()); + } + private Attendance createAttendanceWithPendingRequest() { Attendance attendance = Attendance.create(SESSION_ID, MEMBER_ID); attendance.setId(ATTENDANCE_ID); @@ -73,4 +103,17 @@ private Attendance createAttendanceWithPendingRequest() { attendance.requestCorrection(AttendanceStatus.LATE, "지각 처리", 200L, Clock.systemUTC()); return attendance; } + + private Attendance createAttendanceWithoutPendingRequest() { + Attendance attendance = Attendance.create(SESSION_ID, MEMBER_ID); + attendance.setId(ATTENDANCE_ID); + attendance.checkIn(Clock.systemUTC()); + return attendance; + } + + private Attendance createAttendanceWithAlreadyApprovedRequest() { + Attendance attendance = createAttendanceWithPendingRequest(); + attendance.approveCorrection(APPROVER_ID, Clock.systemUTC()); + return attendance; + } } diff --git a/core/service/src/test/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionRejectionServiceTest.java b/core/service/src/test/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionRejectionServiceTest.java index 7d405282..57e77947 100644 --- a/core/service/src/test/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionRejectionServiceTest.java +++ b/core/service/src/test/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionRejectionServiceTest.java @@ -69,6 +69,40 @@ void reject_fail_if_attendance_not_found() { .hasFieldOrPropertyWithValue("problemCode", AttendanceProblemCode.ATTENDANCE_NOT_FOUND); } + @Test + @DisplayName("[Failure] 대기 중인 수정 요청이 없으면 예외가 발생한다") + void reject_fail_if_no_pending_request() { + Attendance attendance = createAttendanceWithoutPendingRequest(); + when(attendanceQueryRepository.findById(ATTENDANCE_ID)).thenReturn(Optional.of(attendance)); + + AttendanceCorrectionRejection.Request request = new AttendanceCorrectionRejection.Request( + ATTENDANCE_ID, "거절 사유" + ); + + assertThatThrownBy(() -> service.reject(REJECTER_ID, request)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("대기 중인 수정 요청이 없습니다"); + + verify(attendanceCommandRepository, never()).save(any()); + } + + @Test + @DisplayName("[Failure] 이미 거절된 요청을 다시 거절하면 예외가 발생한다") + void reject_fail_if_already_rejected() { + Attendance attendance = createAttendanceWithAlreadyRejectedRequest(); + when(attendanceQueryRepository.findById(ATTENDANCE_ID)).thenReturn(Optional.of(attendance)); + + AttendanceCorrectionRejection.Request request = new AttendanceCorrectionRejection.Request( + ATTENDANCE_ID, "다시 거절" + ); + + assertThatThrownBy(() -> service.reject(REJECTER_ID, request)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("대기 중인 수정 요청이 없습니다"); + + verify(attendanceCommandRepository, never()).save(any()); + } + private Attendance createAttendanceWithPendingRequest() { Attendance attendance = Attendance.create(SESSION_ID, MEMBER_ID); attendance.setId(ATTENDANCE_ID); @@ -76,4 +110,17 @@ private Attendance createAttendanceWithPendingRequest() { attendance.requestCorrection(AttendanceStatus.LATE, "지각 처리", 200L, Clock.systemUTC()); return attendance; } + + private Attendance createAttendanceWithoutPendingRequest() { + Attendance attendance = Attendance.create(SESSION_ID, MEMBER_ID); + attendance.setId(ATTENDANCE_ID); + attendance.checkIn(Clock.systemUTC()); + return attendance; + } + + private Attendance createAttendanceWithAlreadyRejectedRequest() { + Attendance attendance = createAttendanceWithPendingRequest(); + attendance.rejectCorrection("이전 거절 사유", REJECTER_ID, Clock.systemUTC()); + return attendance; + } } diff --git a/core/service/src/test/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionRequestServiceTest.java b/core/service/src/test/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionRequestServiceTest.java index 45e52381..b5b3d48c 100644 --- a/core/service/src/test/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionRequestServiceTest.java +++ b/core/service/src/test/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionRequestServiceTest.java @@ -70,10 +70,51 @@ void request_fail_if_attendance_not_found() { .hasFieldOrPropertyWithValue("problemCode", AttendanceProblemCode.ATTENDANCE_NOT_FOUND); } + @Test + @DisplayName("[Failure] 이미 대기 중인 수정 요청이 있으면 예외가 발생한다") + void request_fail_if_pending_request_exists() { + Attendance attendance = createAttendanceWithPendingRequest(); + when(attendanceQueryRepository.findById(ATTENDANCE_ID)).thenReturn(Optional.of(attendance)); + + AttendanceCorrectionRequest.Request request = new AttendanceCorrectionRequest.Request( + ATTENDANCE_ID, AttendanceStatus.ABSENT, "결석 처리 요청" + ); + + assertThatThrownBy(() -> service.request(REQUESTER_ID, request)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("대기 중인 수정 요청"); + + verify(attendanceCommandRepository, never()).save(any()); + } + + @Test + @DisplayName("[Failure] 현재 상태와 동일한 상태로 변경 요청하면 예외가 발생한다") + void request_fail_if_same_status() { + Attendance attendance = createAttendanceWithCheckIn(); + when(attendanceQueryRepository.findById(ATTENDANCE_ID)).thenReturn(Optional.of(attendance)); + + // 현재 상태가 PRESENT인데 PRESENT로 변경 요청 + AttendanceCorrectionRequest.Request request = new AttendanceCorrectionRequest.Request( + ATTENDANCE_ID, AttendanceStatus.PRESENT, "변경 없음" + ); + + assertThatThrownBy(() -> service.request(REQUESTER_ID, request)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("동일한 상태"); + + verify(attendanceCommandRepository, never()).save(any()); + } + private Attendance createAttendanceWithCheckIn() { Attendance attendance = Attendance.create(SESSION_ID, MEMBER_ID); attendance.setId(ATTENDANCE_ID); attendance.checkIn(Clock.systemUTC()); return attendance; } + + private Attendance createAttendanceWithPendingRequest() { + Attendance attendance = createAttendanceWithCheckIn(); + attendance.requestCorrection(AttendanceStatus.LATE, "지각 처리", REQUESTER_ID, Clock.systemUTC()); + return attendance; + } } diff --git a/core/service/src/test/java/me/chan99k/learningmanager/authentication/IssueTokenServiceTest.java b/core/service/src/test/java/me/chan99k/learningmanager/authentication/IssueTokenServiceTest.java index 289258f1..f90dd6c0 100644 --- a/core/service/src/test/java/me/chan99k/learningmanager/authentication/IssueTokenServiceTest.java +++ b/core/service/src/test/java/me/chan99k/learningmanager/authentication/IssueTokenServiceTest.java @@ -25,7 +25,6 @@ import me.chan99k.learningmanager.member.MemberQueryRepository; import me.chan99k.learningmanager.member.MemberStatus; import me.chan99k.learningmanager.member.Nickname; -import me.chan99k.learningmanager.member.SystemRole; @ExtendWith(MockitoExtension.class) class IssueTokenServiceTest { @@ -78,7 +77,6 @@ private Member createTestMember() { MEMBER_ID, Email.of(TEST_EMAIL), Nickname.of("TestUser"), - SystemRole.MEMBER, MemberStatus.ACTIVE, null, null, @@ -103,7 +101,7 @@ void issues_token_with_valid_credentials() { .willReturn(Optional.of(member)); given(passwordEncoder.matches(TEST_PASSWORD, HASHED_PASSWORD)) .willReturn(true); - given(jwtProvider.createAccessToken(eq(MEMBER_ID), eq(TEST_EMAIL), anyList())) + given(jwtProvider.createAccessToken(eq(MEMBER_ID), eq(TEST_EMAIL))) .willReturn(ACCESS_TOKEN); given(jwtProvider.getAccessTokenExpirationSeconds()) .willReturn(ACCESS_TOKEN_EXPIRATION_SECONDS); @@ -154,7 +152,7 @@ void saves_refresh_token_to_repository() { .willReturn(Optional.of(member)); given(passwordEncoder.matches(TEST_PASSWORD, HASHED_PASSWORD)) .willReturn(true); - given(jwtProvider.createAccessToken(eq(MEMBER_ID), eq(TEST_EMAIL), anyList())) + given(jwtProvider.createAccessToken(eq(MEMBER_ID), eq(TEST_EMAIL))) .willReturn(ACCESS_TOKEN); given(jwtProvider.getAccessTokenExpirationSeconds()) .willReturn(ACCESS_TOKEN_EXPIRATION_SECONDS); @@ -178,7 +176,7 @@ void response_contains_bearer_token_type() { .willReturn(Optional.of(member)); given(passwordEncoder.matches(TEST_PASSWORD, HASHED_PASSWORD)) .willReturn(true); - given(jwtProvider.createAccessToken(eq(MEMBER_ID), eq(TEST_EMAIL), anyList())) + given(jwtProvider.createAccessToken(eq(MEMBER_ID), eq(TEST_EMAIL))) .willReturn(ACCESS_TOKEN); given(jwtProvider.getAccessTokenExpirationSeconds()) .willReturn(ACCESS_TOKEN_EXPIRATION_SECONDS); @@ -197,7 +195,7 @@ void response_contains_expires_in() { .willReturn(Optional.of(member)); given(passwordEncoder.matches(TEST_PASSWORD, HASHED_PASSWORD)) .willReturn(true); - given(jwtProvider.createAccessToken(eq(MEMBER_ID), eq(TEST_EMAIL), anyList())) + given(jwtProvider.createAccessToken(eq(MEMBER_ID), eq(TEST_EMAIL))) .willReturn(ACCESS_TOKEN); given(jwtProvider.getAccessTokenExpirationSeconds()) .willReturn(ACCESS_TOKEN_EXPIRATION_SECONDS); diff --git a/core/service/src/test/java/me/chan99k/learningmanager/authentication/RefreshAccessTokenServiceTest.java b/core/service/src/test/java/me/chan99k/learningmanager/authentication/RefreshAccessTokenServiceTest.java index c0fd3569..87e9842b 100644 --- a/core/service/src/test/java/me/chan99k/learningmanager/authentication/RefreshAccessTokenServiceTest.java +++ b/core/service/src/test/java/me/chan99k/learningmanager/authentication/RefreshAccessTokenServiceTest.java @@ -25,7 +25,6 @@ import me.chan99k.learningmanager.member.MemberQueryRepository; import me.chan99k.learningmanager.member.MemberStatus; import me.chan99k.learningmanager.member.Nickname; -import me.chan99k.learningmanager.member.SystemRole; @ExtendWith(MockitoExtension.class) class RefreshAccessTokenServiceTest { @@ -106,7 +105,6 @@ private Member createTestMember() { MEMBER_ID, Email.of(TEST_EMAIL), Nickname.of("TestUser"), - SystemRole.MEMBER, MemberStatus.ACTIVE, null, null, @@ -132,7 +130,7 @@ void issues_new_tokens_with_valid_refresh_token() { .willReturn(Optional.of(validToken)); given(memberQueryRepository.findById(MEMBER_ID)) .willReturn(Optional.of(member)); - given(jwtProvider.createAccessToken(eq(MEMBER_ID), eq(TEST_EMAIL), anyList())) + given(jwtProvider.createAccessToken(eq(MEMBER_ID), eq(TEST_EMAIL))) .willReturn(NEW_ACCESS_TOKEN); given(jwtProvider.getAccessTokenExpirationSeconds()) .willReturn(ACCESS_TOKEN_EXPIRATION_SECONDS); @@ -216,7 +214,7 @@ void revokes_original_token_on_refresh() { .willReturn(Optional.of(validToken)); given(memberQueryRepository.findById(MEMBER_ID)) .willReturn(Optional.of(member)); - given(jwtProvider.createAccessToken(eq(MEMBER_ID), eq(TEST_EMAIL), anyList())) + given(jwtProvider.createAccessToken(eq(MEMBER_ID), eq(TEST_EMAIL))) .willReturn(NEW_ACCESS_TOKEN); given(jwtProvider.getAccessTokenExpirationSeconds()) .willReturn(ACCESS_TOKEN_EXPIRATION_SECONDS); @@ -236,7 +234,7 @@ void saves_new_refresh_token() { .willReturn(Optional.of(validToken)); given(memberQueryRepository.findById(MEMBER_ID)) .willReturn(Optional.of(member)); - given(jwtProvider.createAccessToken(eq(MEMBER_ID), eq(TEST_EMAIL), anyList())) + given(jwtProvider.createAccessToken(eq(MEMBER_ID), eq(TEST_EMAIL))) .willReturn(NEW_ACCESS_TOKEN); given(jwtProvider.getAccessTokenExpirationSeconds()) .willReturn(ACCESS_TOKEN_EXPIRATION_SECONDS); diff --git a/core/service/src/test/java/me/chan99k/learningmanager/course/CourseCreationServiceTest.java b/core/service/src/test/java/me/chan99k/learningmanager/course/CourseCreationServiceTest.java index 66f7e034..0a892b51 100644 --- a/core/service/src/test/java/me/chan99k/learningmanager/course/CourseCreationServiceTest.java +++ b/core/service/src/test/java/me/chan99k/learningmanager/course/CourseCreationServiceTest.java @@ -4,8 +4,6 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; -import java.util.Optional; - import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -13,15 +11,13 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import me.chan99k.learningmanager.authorization.SystemAuthorizationPort; import me.chan99k.learningmanager.course.Course; import me.chan99k.learningmanager.course.CourseCommandRepository; import me.chan99k.learningmanager.course.CourseCreation; import me.chan99k.learningmanager.course.CourseCreationService; import me.chan99k.learningmanager.course.CourseProblemCode; import me.chan99k.learningmanager.exception.DomainException; -import me.chan99k.learningmanager.member.Member; -import me.chan99k.learningmanager.member.MemberProblemCode; -import me.chan99k.learningmanager.member.MemberQueryRepository; import me.chan99k.learningmanager.member.SystemRole; @ExtendWith(MockitoExtension.class) @@ -31,7 +27,7 @@ class CourseCreationServiceTest { private CourseCommandRepository commandRepository; @Mock - private MemberQueryRepository memberQueryRepository; + private SystemAuthorizationPort systemAuthorizationPort; @InjectMocks private CourseCreationService courseCreationService; @@ -41,9 +37,7 @@ class CourseCreationServiceTest { void test01() { // given Long adminId = 1L; - Member admin = mock(Member.class); - when(admin.getRole()).thenReturn(SystemRole.ADMIN); - when(memberQueryRepository.findById(adminId)).thenReturn(Optional.of(admin)); + when(systemAuthorizationPort.hasRole(adminId, SystemRole.ADMIN)).thenReturn(true); Course mockCourse = mock(Course.class); when(mockCourse.getId()).thenReturn(100L); @@ -64,9 +58,7 @@ void test01() { void test02() { // given Long memberId = 1L; - Member member = mock(Member.class); - when(member.getRole()).thenReturn(SystemRole.MEMBER); - when(memberQueryRepository.findById(memberId)).thenReturn(Optional.of(member)); + when(systemAuthorizationPort.hasRole(memberId, SystemRole.ADMIN)).thenReturn(false); CourseCreation.Request request = new CourseCreation.Request("Test Course", "Test Description"); @@ -75,19 +67,4 @@ void test02() { .isInstanceOf(DomainException.class) .hasFieldOrPropertyWithValue("problemCode", CourseProblemCode.ADMIN_ONLY_COURSE_CREATION); } - - @Test - @DisplayName("[Failure] 가입되지 않은 사용자는 과정 생성 시도에 실패한다.") - void test04() { - // given - Long nonExistentMemberId = 999L; - when(memberQueryRepository.findById(nonExistentMemberId)).thenReturn(Optional.empty()); - - CourseCreation.Request request = new CourseCreation.Request("Test Course", "Test Description"); - - // when & then - assertThatThrownBy(() -> courseCreationService.createCourse(nonExistentMemberId, request)) - .isInstanceOf(DomainException.class) - .hasFieldOrPropertyWithValue("problemCode", MemberProblemCode.MEMBER_NOT_FOUND); - } } diff --git a/core/service/src/test/java/me/chan99k/learningmanager/member/MemberStatusChangeServiceTest.java b/core/service/src/test/java/me/chan99k/learningmanager/member/MemberStatusChangeServiceTest.java index 34cbb537..a5f62dbd 100644 --- a/core/service/src/test/java/me/chan99k/learningmanager/member/MemberStatusChangeServiceTest.java +++ b/core/service/src/test/java/me/chan99k/learningmanager/member/MemberStatusChangeServiceTest.java @@ -13,6 +13,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import me.chan99k.learningmanager.authorization.SystemAuthorizationPort; import me.chan99k.learningmanager.exception.DomainException; import me.chan99k.learningmanager.member.Member; import me.chan99k.learningmanager.member.MemberCommandRepository; @@ -36,7 +37,7 @@ class MemberStatusChangeServiceTest { private MemberCommandRepository memberCommandRepository; @Mock - private Member adminMember; + private SystemAuthorizationPort systemAuthorizationPort; @Mock private Member targetMember; @@ -47,9 +48,8 @@ void changeStatus_Success_BanMember() { // given Long adminId = 1L; Long targetMemberId = 2L; - when(memberQueryRepository.findById(adminId)).thenReturn(Optional.of(adminMember)); + when(systemAuthorizationPort.hasRole(adminId, SystemRole.ADMIN)).thenReturn(true); when(memberQueryRepository.findById(targetMemberId)).thenReturn(Optional.of(targetMember)); - when(adminMember.getRole()).thenReturn(SystemRole.ADMIN); MemberStatusChange.Request request = new MemberStatusChange.Request(targetMemberId, MemberStatus.BANNED); @@ -67,9 +67,8 @@ void changeStatus_Success_ActivateMember() { // given Long adminId = 1L; Long targetMemberId = 2L; - when(memberQueryRepository.findById(adminId)).thenReturn(Optional.of(adminMember)); + when(systemAuthorizationPort.hasRole(adminId, SystemRole.ADMIN)).thenReturn(true); when(memberQueryRepository.findById(targetMemberId)).thenReturn(Optional.of(targetMember)); - when(adminMember.getRole()).thenReturn(SystemRole.ADMIN); MemberStatusChange.Request request = new MemberStatusChange.Request(targetMemberId, MemberStatus.ACTIVE); @@ -86,8 +85,7 @@ void changeStatus_Success_ActivateMember() { void changeStatus_Fail_NotAdmin() { // given Long memberId = 1L; - when(memberQueryRepository.findById(memberId)).thenReturn(Optional.of(adminMember)); - when(adminMember.getRole()).thenReturn(SystemRole.MEMBER); + when(systemAuthorizationPort.hasRole(memberId, SystemRole.ADMIN)).thenReturn(false); MemberStatusChange.Request request = new MemberStatusChange.Request(2L, MemberStatus.BANNED); @@ -105,9 +103,8 @@ void changeStatus_Fail_TargetMemberNotFound() { // given Long adminId = 1L; Long targetMemberId = 999L; - when(memberQueryRepository.findById(adminId)).thenReturn(Optional.of(adminMember)); + when(systemAuthorizationPort.hasRole(adminId, SystemRole.ADMIN)).thenReturn(true); when(memberQueryRepository.findById(targetMemberId)).thenReturn(Optional.empty()); - when(adminMember.getRole()).thenReturn(SystemRole.ADMIN); MemberStatusChange.Request request = new MemberStatusChange.Request(targetMemberId, MemberStatus.BANNED); diff --git a/core/service/src/test/java/me/chan99k/learningmanager/session/SessionCreationServiceTest.java b/core/service/src/test/java/me/chan99k/learningmanager/session/SessionCreationServiceTest.java index 7fdbeca0..cf715923 100644 --- a/core/service/src/test/java/me/chan99k/learningmanager/session/SessionCreationServiceTest.java +++ b/core/service/src/test/java/me/chan99k/learningmanager/session/SessionCreationServiceTest.java @@ -19,14 +19,13 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import me.chan99k.learningmanager.authorization.SystemAuthorizationPort; import me.chan99k.learningmanager.course.Course; import me.chan99k.learningmanager.course.CourseProblemCode; import me.chan99k.learningmanager.course.CourseQueryRepository; import me.chan99k.learningmanager.course.Curriculum; import me.chan99k.learningmanager.exception.DomainException; -import me.chan99k.learningmanager.member.Member; import me.chan99k.learningmanager.member.MemberProblemCode; -import me.chan99k.learningmanager.member.MemberQueryRepository; import me.chan99k.learningmanager.member.SystemRole; import me.chan99k.learningmanager.session.Session; import me.chan99k.learningmanager.session.SessionCommandRepository; @@ -53,7 +52,7 @@ class SessionCreationServiceTest { private CourseQueryRepository courseQueryRepository; @Mock - private MemberQueryRepository memberQueryRepository; + private SystemAuthorizationPort systemAuthorizationPort; @InjectMocks private SessionCreationService sessionCreationService; @@ -67,9 +66,7 @@ void createStandaloneSession_Success() { lenient().when(clock.getZone()).thenReturn(ZoneId.of("Asia/Seoul")); Long adminId = 1L; - Member admin = mock(Member.class); - when(admin.getRole()).thenReturn(SystemRole.ADMIN); - when(memberQueryRepository.findById(adminId)).thenReturn(Optional.of(admin)); + when(systemAuthorizationPort.hasRole(adminId, SystemRole.ADMIN)).thenReturn(true); Instant startTime = fixedTime.plusSeconds(86400); // +1 day Instant endTime = fixedTime.plusSeconds(86400 + 7200); // +1 day +2 hours @@ -99,9 +96,7 @@ void createStandaloneSession_Success() { void createStandaloneSession_AuthorizationFail() { // given Long userId = 1L; - Member user = mock(Member.class); - when(user.getRole()).thenReturn(SystemRole.MEMBER); - when(memberQueryRepository.findById(userId)).thenReturn(Optional.of(user)); + when(systemAuthorizationPort.hasRole(userId, SystemRole.ADMIN)).thenReturn(false); SessionCreation.Request request = new SessionCreation.Request( userId, @@ -128,10 +123,8 @@ void createCourseSession_Success() { Long managerId = 1L; Long courseId = 1L; - Member manager = mock(Member.class); Course course = mock(Course.class); - when(memberQueryRepository.findById(managerId)).thenReturn(Optional.of(manager)); when(courseQueryRepository.findManagedCourseById(courseId, managerId)).thenReturn(Optional.of(course)); Instant startTime = fixedTime.plusSeconds(86400); // +1 day @@ -164,9 +157,7 @@ void createCourseSession_AuthorizationFail() { // given Long userId = 1L; Long courseId = 1L; - Member user = mock(Member.class); - when(memberQueryRepository.findById(userId)).thenReturn(Optional.of(user)); when(courseQueryRepository.findManagedCourseById(courseId, userId)).thenReturn(Optional.empty()); SessionCreation.Request request = new SessionCreation.Request( @@ -195,13 +186,11 @@ void createCurriculumSession_Success() { Long managerId = 1L; Long courseId = 1L; Long curriculumId = 1L; - Member manager = mock(Member.class); Course course = mock(Course.class); Curriculum curriculum = mock(Curriculum.class); when(curriculum.getId()).thenReturn(curriculumId); when(course.getCurriculumList()).thenReturn(List.of(curriculum)); - when(memberQueryRepository.findById(managerId)).thenReturn(Optional.of(manager)); when(courseQueryRepository.findManagedCourseById(courseId, managerId)).thenReturn(Optional.of(course)); Instant startTime = fixedTime.plusSeconds(86400); // +1 day @@ -235,11 +224,9 @@ void createCurriculumSession_CurriculumNotFound() { Long managerId = 1L; Long courseId = 1L; Long invalidCurriculumId = 999L; - Member manager = mock(Member.class); Course course = mock(Course.class); when(course.getCurriculumList()).thenReturn(List.of()); - when(memberQueryRepository.findById(managerId)).thenReturn(Optional.of(manager)); when(courseQueryRepository.findManagedCourseById(courseId, managerId)).thenReturn(Optional.of(course)); SessionCreation.Request request = new SessionCreation.Request( @@ -265,11 +252,9 @@ void createChildSession_Success() { // given Long managerId = 1L; Long parentSessionId = 1L; - Member manager = mock(Member.class); Session parentSession = mock(Session.class); Session childSession = mock(Session.class); - when(memberQueryRepository.findById(managerId)).thenReturn(Optional.of(manager)); when(sessionQueryRepository.findById(parentSessionId)).thenReturn(Optional.of(parentSession)); when(parentSession.createChildSession(anyString(), any(Instant.class), any(Instant.class), any(SessionType.class), any(SessionLocation.class), anyString(), any(Clock.class))).thenReturn( @@ -303,9 +288,7 @@ void createChildSession_Success() { void createChildSession_ParentNotFound() { Long managerId = 1L; Long invalidParentSessionId = 999L; - Member manager = mock(Member.class); - when(memberQueryRepository.findById(managerId)).thenReturn(Optional.of(manager)); when(sessionQueryRepository.findById(invalidParentSessionId)).thenReturn(Optional.empty()); SessionCreation.Request request = new SessionCreation.Request( @@ -324,25 +307,4 @@ void createChildSession_ParentNotFound() { .isInstanceOf(DomainException.class) .hasFieldOrPropertyWithValue("problemCode", SessionProblemCode.SESSION_NOT_FOUND); } - - @Test - @DisplayName("[Failure] 존재하지 않는 사용자의 세션 생성 시 도메인 예외 발생") - void createSession_MemberNotFound() { - Long invalidMemberId = 999L; - when(memberQueryRepository.findById(invalidMemberId)).thenReturn(Optional.empty()); - - SessionCreation.Request request = new SessionCreation.Request( - invalidMemberId, - null, null, null, - "테스트 세션", - LocalDateTime.now().plusDays(1).toInstant(ZoneOffset.UTC), - LocalDateTime.now().plusDays(1).plusHours(2).toInstant(ZoneOffset.UTC), - SessionType.ONLINE, SessionLocation.ZOOM, "Zoom 링크" - ); - - // when & then - assertThatThrownBy(() -> sessionCreationService.createSession(request)) - .isInstanceOf(DomainException.class) - .hasFieldOrPropertyWithValue("problemCode", MemberProblemCode.MEMBER_NOT_FOUND); - } -} \ No newline at end of file +} diff --git a/core/service/src/test/java/me/chan99k/learningmanager/session/SessionDeletionServiceTest.java b/core/service/src/test/java/me/chan99k/learningmanager/session/SessionDeletionServiceTest.java index fcbab641..303a504c 100644 --- a/core/service/src/test/java/me/chan99k/learningmanager/session/SessionDeletionServiceTest.java +++ b/core/service/src/test/java/me/chan99k/learningmanager/session/SessionDeletionServiceTest.java @@ -14,13 +14,12 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import me.chan99k.learningmanager.authorization.SystemAuthorizationPort; import me.chan99k.learningmanager.course.Course; import me.chan99k.learningmanager.course.CourseProblemCode; import me.chan99k.learningmanager.course.CourseQueryRepository; import me.chan99k.learningmanager.exception.DomainException; -import me.chan99k.learningmanager.member.Member; import me.chan99k.learningmanager.member.MemberProblemCode; -import me.chan99k.learningmanager.member.MemberQueryRepository; import me.chan99k.learningmanager.member.SystemRole; import me.chan99k.learningmanager.session.Session; import me.chan99k.learningmanager.session.SessionCommandRepository; @@ -44,7 +43,7 @@ class SessionDeletionServiceTest { private CourseQueryRepository courseQueryRepository; @Mock - private MemberQueryRepository memberQueryRepository; + private SystemAuthorizationPort systemAuthorizationPort; @Mock private Session session; @@ -52,9 +51,6 @@ class SessionDeletionServiceTest { @Mock private Course course; - @Mock - private Member member; - @Test @DisplayName("[Success] 과정 관리자가 과정 세션 삭제에 성공한다") void deleteSession_CourseSession_Success() { @@ -64,7 +60,6 @@ void deleteSession_CourseSession_Success() { long managerId = 100L; when(sessionQueryRepository.findById(sessionId)).thenReturn(Optional.of(session)); - when(memberQueryRepository.findById(managerId)).thenReturn(Optional.of(member)); when(session.getCourseId()).thenReturn(courseId); when(session.getChildren()).thenReturn(Collections.emptyList()); when(courseQueryRepository.findManagedCourseById(courseId, managerId)).thenReturn(Optional.of(course)); @@ -83,11 +78,10 @@ void deleteSession_StandaloneSession_Success() { long adminId = 100L; when(sessionQueryRepository.findById(sessionId)).thenReturn(Optional.of(session)); - when(memberQueryRepository.findById(adminId)).thenReturn(Optional.of(member)); when(session.getCourseId()).thenReturn(null); when(session.getCurriculumId()).thenReturn(null); when(session.getChildren()).thenReturn(Collections.emptyList()); - when(member.getRole()).thenReturn(SystemRole.ADMIN); + when(systemAuthorizationPort.hasRole(adminId, SystemRole.ADMIN)).thenReturn(true); // when sessionDeletionService.deleteSession(adminId, sessionId); @@ -121,7 +115,6 @@ void deleteSession_Fail_NotCourseManager() { // given when(sessionQueryRepository.findById(sessionId)).thenReturn(Optional.of(session)); - when(memberQueryRepository.findById(nonManagerId)).thenReturn(Optional.of(member)); when(session.getCourseId()).thenReturn(courseId); when(courseQueryRepository.findManagedCourseById(courseId, nonManagerId)).thenReturn(Optional.empty()); @@ -141,10 +134,9 @@ void deleteSession_Fail_StandaloneSessionNotAdmin() { // given when(sessionQueryRepository.findById(sessionId)).thenReturn(Optional.of(session)); - when(memberQueryRepository.findById(userId)).thenReturn(Optional.of(member)); when(session.getCourseId()).thenReturn(null); when(session.getCurriculumId()).thenReturn(null); - when(member.getRole()).thenReturn(SystemRole.MEMBER); + when(systemAuthorizationPort.hasRole(userId, SystemRole.ADMIN)).thenReturn(false); // when & then assertThatThrownBy(() -> sessionDeletionService.deleteSession(userId, sessionId)) @@ -164,7 +156,6 @@ void deleteSession_Fail_SessionWithChildren() { // given when(sessionQueryRepository.findById(sessionId)).thenReturn(Optional.of(session)); - when(memberQueryRepository.findById(managerId)).thenReturn(Optional.of(member)); when(session.getCourseId()).thenReturn(courseId); when(session.getChildren()).thenReturn(List.of(childSession)); when(courseQueryRepository.findManagedCourseById(courseId, managerId)).thenReturn(Optional.of(course)); @@ -177,24 +168,6 @@ void deleteSession_Fail_SessionWithChildren() { verify(sessionCommandRepository, never()).delete(any()); } - @Test - @DisplayName("[Failure] 존재하지 않는 사용자면 DomainException이 발생한다") - void deleteSession_Fail_MemberNotFound() { - long sessionId = 1L; - long invalidMemberId = 999L; - - // given - when(sessionQueryRepository.findById(sessionId)).thenReturn(Optional.of(session)); - when(memberQueryRepository.findById(invalidMemberId)).thenReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> sessionDeletionService.deleteSession(invalidMemberId, sessionId)) - .isInstanceOf(DomainException.class) - .hasFieldOrPropertyWithValue("problemCode", MemberProblemCode.MEMBER_NOT_FOUND); - - verify(sessionCommandRepository, never()).delete(any()); - } - @Test @DisplayName("[Behavior] sessionCommandRepository.delete()가 호출되는지 확인한다") void deleteSession_VerifyRepositoryDelete() { @@ -204,7 +177,6 @@ void deleteSession_VerifyRepositoryDelete() { // given when(sessionQueryRepository.findById(sessionId)).thenReturn(Optional.of(session)); - when(memberQueryRepository.findById(managerId)).thenReturn(Optional.of(member)); when(session.getCourseId()).thenReturn(courseId); when(session.getChildren()).thenReturn(Collections.emptyList()); when(courseQueryRepository.findManagedCourseById(courseId, managerId)).thenReturn(Optional.of(course)); diff --git a/core/service/src/test/java/me/chan99k/learningmanager/session/SessionUpdateServiceTest.java b/core/service/src/test/java/me/chan99k/learningmanager/session/SessionUpdateServiceTest.java index d438ee58..bbf0ee93 100644 --- a/core/service/src/test/java/me/chan99k/learningmanager/session/SessionUpdateServiceTest.java +++ b/core/service/src/test/java/me/chan99k/learningmanager/session/SessionUpdateServiceTest.java @@ -16,13 +16,12 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import me.chan99k.learningmanager.authorization.SystemAuthorizationPort; import me.chan99k.learningmanager.course.Course; import me.chan99k.learningmanager.course.CourseProblemCode; import me.chan99k.learningmanager.course.CourseQueryRepository; import me.chan99k.learningmanager.exception.DomainException; -import me.chan99k.learningmanager.member.Member; import me.chan99k.learningmanager.member.MemberProblemCode; -import me.chan99k.learningmanager.member.MemberQueryRepository; import me.chan99k.learningmanager.member.SystemRole; import me.chan99k.learningmanager.session.Session; import me.chan99k.learningmanager.session.SessionCommandRepository; @@ -52,7 +51,7 @@ class SessionUpdateServiceTest { private CourseQueryRepository courseQueryRepository; @Mock - private MemberQueryRepository memberQueryRepository; + private SystemAuthorizationPort systemAuthorizationPort; @Mock private Session session; @@ -60,9 +59,6 @@ class SessionUpdateServiceTest { @Mock private Course course; - @Mock - private Member member; - @Test @DisplayName("[Success] 과정 관리자가 과정 세션 수정에 성공한다") void updateSession_CourseSession_Success() { @@ -83,7 +79,6 @@ void updateSession_CourseSession_Success() { // given when(sessionQueryRepository.findById(sessionId)).thenReturn(Optional.of(session)); - when(memberQueryRepository.findById(managerId)).thenReturn(Optional.of(member)); when(session.getCourseId()).thenReturn(courseId); when(courseQueryRepository.findManagedCourseById(courseId, managerId)).thenReturn(Optional.of(course)); @@ -116,10 +111,9 @@ void updateSession_StandaloneSession_Success() { // given when(sessionQueryRepository.findById(sessionId)).thenReturn(Optional.of(session)); - when(memberQueryRepository.findById(adminId)).thenReturn(Optional.of(member)); when(session.getCourseId()).thenReturn(null); when(session.getCurriculumId()).thenReturn(null); - when(member.getRole()).thenReturn(SystemRole.ADMIN); + when(systemAuthorizationPort.hasRole(adminId, SystemRole.ADMIN)).thenReturn(true); // when sessionUpdateService.updateSession(adminId, sessionId, request); @@ -167,7 +161,6 @@ void updateSession_Fail_NotCourseManager() { // given when(sessionQueryRepository.findById(sessionId)).thenReturn(Optional.of(session)); - when(memberQueryRepository.findById(nonManagerId)).thenReturn(Optional.of(member)); when(session.getCourseId()).thenReturn(courseId); when(courseQueryRepository.findManagedCourseById(courseId, nonManagerId)).thenReturn(Optional.empty()); @@ -192,10 +185,9 @@ void updateSession_Fail_StandaloneSessionNotAdmin() { // given when(sessionQueryRepository.findById(sessionId)).thenReturn(Optional.of(session)); - when(memberQueryRepository.findById(userId)).thenReturn(Optional.of(member)); when(session.getCourseId()).thenReturn(null); when(session.getCurriculumId()).thenReturn(null); - when(member.getRole()).thenReturn(SystemRole.MEMBER); + when(systemAuthorizationPort.hasRole(userId, SystemRole.ADMIN)).thenReturn(false); // when & then assertThatThrownBy(() -> sessionUpdateService.updateSession(userId, sessionId, request)) @@ -205,29 +197,6 @@ void updateSession_Fail_StandaloneSessionNotAdmin() { verify(sessionCommandRepository, never()).save(any()); } - @Test - @DisplayName("[Failure] 존재하지 않는 사용자면 DomainException이 발생한다") - void updateSession_Fail_MemberNotFound() { - long sessionId = 1L; - long invalidMemberId = 999L; - SessionUpdate.Request request = new SessionUpdate.Request( - "Title", LocalDateTime.now().plusDays(1).toInstant(ZoneOffset.UTC), - LocalDateTime.now().plusDays(1).plusHours(1).toInstant(ZoneOffset.UTC), - SessionType.ONLINE, SessionLocation.ZOOM, null - ); - - // given - when(sessionQueryRepository.findById(sessionId)).thenReturn(Optional.of(session)); - when(memberQueryRepository.findById(invalidMemberId)).thenReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> sessionUpdateService.updateSession(invalidMemberId, sessionId, request)) - .isInstanceOf(DomainException.class) - .hasFieldOrPropertyWithValue("problemCode", MemberProblemCode.MEMBER_NOT_FOUND); - - verify(sessionCommandRepository, never()).save(any()); - } - @Test @DisplayName("[Behavior] sessionCommandRepository.update()가 호출되는지 확인한다") void updateSession_VerifyRepositoryUpdate() { @@ -242,7 +211,6 @@ void updateSession_VerifyRepositoryUpdate() { // given when(sessionQueryRepository.findById(sessionId)).thenReturn(Optional.of(session)); - when(memberQueryRepository.findById(managerId)).thenReturn(Optional.of(member)); when(session.getCourseId()).thenReturn(courseId); when(courseQueryRepository.findManagedCourseById(courseId, managerId)).thenReturn(Optional.of(course));