From fbe30c6fc67333bb3bdbe72560cbb970f19a9d18 Mon Sep 17 00:00:00 2001 From: BcKmini <151009045+BcKmini@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:35:40 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EB=B6=80=EA=B0=80=20=EC=A0=95=EB=B3=B4(=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=9D=B4=EB=A0=A5=C2=B7=EB=B3=B4=EC=95=88=C2=B7?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=84=A4=EC=A0=95)=20=EC=8B=A4=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProfilePage에 표시되던 마지막 로그인 시각/기기, 계정 상태, 비밀번호 변경일, 업무 권한, 알림 수신 설정이 전부 profileData.ts의 고정값이었던 문제를 백엔드부터 해결한다. - user_login_event 테이블 신설, 로그인 성공 시 User-Agent 기반 기기 요약과 함께 기록. GET /auth/me/profile이 role/account_status/password_changed_at/ last_login_at/last_login_device/recent_device_count를 실제 값으로 반환. (last_login/work-context의 "담당 영역"·"문서 열람 범위"처럼 백엔드에 대응 개념이 아예 없는 항목은 만들어내지 않고 클라이언트에서 제거 예정) - user_account.password_changed_at 컬럼 추가, changePassword()에서 갱신. - notification_preference 테이블 + GET/PATCH /notifications/preferences 신설. 7개 알림 유형 기본값은 client의 기존 INITIAL_NOTIFICATION_PREFS와 동일하게 맞췄고, security-permission은 서버에서도 끌 수 없도록 강제. Part of the "no hardcoded profile data" cleanup requested after client#339. Co-Authored-By: Claude Sonnet 5 --- .../server/auth/api/AuthController.java | 11 ++- .../fowoco/server/auth/api/LoginRequest.java | 4 +- .../server/auth/api/ProfileResponse.java | 40 ++++++++- .../server/auth/application/AuthService.java | 38 ++++++++- .../server/auth/application/LoginCommand.java | 11 ++- .../auth/application/ProfileSnapshot.java | 18 +++++ .../port/UserLoginEventRepository.java | 16 ++++ .../server/auth/domain/UserAccount.java | 13 +++ .../JdbcUserLoginEventRepository.java | 60 ++++++++++++++ .../persistence/UserAccountJpaEntity.java | 8 ++ .../web/UserAgentDeviceSummarizer.java | 74 +++++++++++++++++ .../api/NotificationController.java | 69 ++++++++++++++++ .../api/NotificationPreferenceResponse.java | 23 ++++++ .../UpdateNotificationPreferenceRequest.java | 23 ++++++ .../NotificationPreferenceService.java | 72 +++++++++++++++++ .../error/NotificationErrorCode.java | 8 ++ .../NotificationPreferenceRepository.java | 21 +++++ .../domain/NotificationPreferenceKey.java | 59 ++++++++++++++ .../JdbcNotificationPreferenceRepository.java | 81 +++++++++++++++++++ ...__add_user_account_password_changed_at.sql | 4 + .../V49__create_user_login_event.sql | 16 ++++ .../V50__create_notification_preference.sql | 17 ++++ .../auth/AuthSecurityIntegrationTest.java | 35 ++++++++ .../web/UserAgentDeviceSummarizerTest.java | 46 +++++++++++ .../NotificationSecurityIntegrationTest.java | 79 ++++++++++++++++++ 25 files changed, 833 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/fowoco/server/auth/application/ProfileSnapshot.java create mode 100644 src/main/java/com/fowoco/server/auth/application/port/UserLoginEventRepository.java create mode 100644 src/main/java/com/fowoco/server/auth/infrastructure/persistence/JdbcUserLoginEventRepository.java create mode 100644 src/main/java/com/fowoco/server/auth/infrastructure/web/UserAgentDeviceSummarizer.java create mode 100644 src/main/java/com/fowoco/server/notification/api/NotificationPreferenceResponse.java create mode 100644 src/main/java/com/fowoco/server/notification/api/UpdateNotificationPreferenceRequest.java create mode 100644 src/main/java/com/fowoco/server/notification/application/NotificationPreferenceService.java create mode 100644 src/main/java/com/fowoco/server/notification/application/port/NotificationPreferenceRepository.java create mode 100644 src/main/java/com/fowoco/server/notification/domain/NotificationPreferenceKey.java create mode 100644 src/main/java/com/fowoco/server/notification/infrastructure/persistence/JdbcNotificationPreferenceRepository.java create mode 100644 src/main/resources/db/migration/V48__add_user_account_password_changed_at.sql create mode 100644 src/main/resources/db/migration/V49__create_user_login_event.sql create mode 100644 src/main/resources/db/migration/V50__create_notification_preference.sql create mode 100644 src/test/java/com/fowoco/server/auth/infrastructure/web/UserAgentDeviceSummarizerTest.java diff --git a/src/main/java/com/fowoco/server/auth/api/AuthController.java b/src/main/java/com/fowoco/server/auth/api/AuthController.java index 69e656de..04ffd340 100644 --- a/src/main/java/com/fowoco/server/auth/api/AuthController.java +++ b/src/main/java/com/fowoco/server/auth/api/AuthController.java @@ -8,6 +8,7 @@ import com.fowoco.server.auth.application.SignupService; import com.fowoco.server.auth.application.error.InvalidRefreshTokenException; import com.fowoco.server.auth.application.port.ActorContextProvider; +import com.fowoco.server.auth.infrastructure.web.UserAgentDeviceSummarizer; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.enums.ParameterIn; @@ -33,6 +34,7 @@ import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -216,8 +218,13 @@ public ResponseEntity completePasswordReset( consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE ) - public ResponseEntity login(@Valid @RequestBody LoginRequest request) { - LoginResult result = authService.login(request.toCommand()); + public ResponseEntity login( + @Valid @RequestBody LoginRequest request, + @RequestHeader(value = HttpHeaders.USER_AGENT, required = false) String userAgent + ) { + LoginResult result = authService.login( + request.toCommand(UserAgentDeviceSummarizer.summarize(userAgent)) + ); ResponseCookie refreshTokenCookie = refreshTokenCookieFactory.create(result.refreshToken()); return ResponseEntity.ok() diff --git a/src/main/java/com/fowoco/server/auth/api/LoginRequest.java b/src/main/java/com/fowoco/server/auth/api/LoginRequest.java index bce3a344..9d74b2b1 100644 --- a/src/main/java/com/fowoco/server/auth/api/LoginRequest.java +++ b/src/main/java/com/fowoco/server/auth/api/LoginRequest.java @@ -53,7 +53,7 @@ public String getPassword() { return password; } - public LoginCommand toCommand() { - return new LoginCommand(email, password); + public LoginCommand toCommand(String deviceSummary) { + return new LoginCommand(email, password, deviceSummary); } } diff --git a/src/main/java/com/fowoco/server/auth/api/ProfileResponse.java b/src/main/java/com/fowoco/server/auth/api/ProfileResponse.java index 68d04fb8..9cf28f96 100644 --- a/src/main/java/com/fowoco/server/auth/api/ProfileResponse.java +++ b/src/main/java/com/fowoco/server/auth/api/ProfileResponse.java @@ -1,8 +1,9 @@ package com.fowoco.server.auth.api; import com.fasterxml.jackson.annotation.JsonProperty; -import com.fowoco.server.auth.domain.UserAccount; +import com.fowoco.server.auth.application.ProfileSnapshot; import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; @Schema(name = "ProfileResponse", description = "현재 사용자의 개인 프로필") public record ProfileResponse( @@ -10,10 +11,41 @@ public record ProfileResponse( @Schema(name = "display_name", description = "화면 표시 이름") String displayName, @Schema(description = "연락처 (선택 입력, 미등록 시 null)", example = "010-1234-5678") - String phone + String phone, + @Schema(description = "사업장 내 역할", allowableValues = {"ADMIN", "HR", "VIEWER"}, example = "HR") + String role, + @JsonProperty("account_status") + @Schema( + name = "account_status", + description = "계정 상태", + allowableValues = {"ACTIVE", "SUSPENDED", "DISABLED"}, + example = "ACTIVE" + ) + String accountStatus, + @JsonProperty("password_changed_at") + @Schema(name = "password_changed_at", description = "마지막 비밀번호 변경 시각(가입 시 최초 설정 포함)") + Instant passwordChangedAt, + @JsonProperty("last_login_at") + @Schema(name = "last_login_at", description = "가장 최근 로그인 성공 시각") + Instant lastLoginAt, + @JsonProperty("last_login_device") + @Schema(name = "last_login_device", description = "가장 최근 로그인 기기 (User-Agent 기반 추정)", example = "Chrome · macOS") + String lastLoginDevice, + @JsonProperty("recent_device_count") + @Schema(name = "recent_device_count", description = "최근 로그인 이력에서 확인된 서로 다른 기기 수") + int recentDeviceCount ) { - public static ProfileResponse from(UserAccount userAccount) { - return new ProfileResponse(userAccount.displayName(), userAccount.phone()); + public static ProfileResponse from(ProfileSnapshot snapshot) { + return new ProfileResponse( + snapshot.account().displayName(), + snapshot.account().phone(), + snapshot.account().role().name(), + snapshot.account().status().name(), + snapshot.account().passwordChangedAt(), + snapshot.lastLoginAt(), + snapshot.lastLoginDevice(), + snapshot.recentDeviceCount() + ); } } diff --git a/src/main/java/com/fowoco/server/auth/application/AuthService.java b/src/main/java/com/fowoco/server/auth/application/AuthService.java index 65fef7e9..584454fb 100644 --- a/src/main/java/com/fowoco/server/auth/application/AuthService.java +++ b/src/main/java/com/fowoco/server/auth/application/AuthService.java @@ -10,6 +10,7 @@ import com.fowoco.server.auth.application.port.RefreshTokenHashPort; import com.fowoco.server.auth.application.port.RefreshTokenRepository; import com.fowoco.server.auth.application.port.UserAccountRepository; +import com.fowoco.server.auth.application.port.UserLoginEventRepository; import com.fowoco.server.auth.domain.RefreshToken; import com.fowoco.server.auth.domain.UserAccount; import com.fowoco.server.common.error.ApiException; @@ -19,6 +20,7 @@ import com.fowoco.server.company.application.CompanyAuthenticationSnapshot; import java.time.Clock; import java.time.Instant; +import java.util.List; import java.util.Optional; import java.util.UUID; import org.springframework.stereotype.Service; @@ -39,9 +41,12 @@ public class AuthService { private final RefreshTokenRotationTransaction refreshTokenRotationTransaction; private final RefreshTokenLogoutTransaction refreshTokenLogoutTransaction; private final AuthAuditPort authAuditPort; + private final UserLoginEventRepository userLoginEventRepository; private final UuidGenerator uuidGenerator; private final Clock clock; + private static final int LOGIN_HISTORY_LOOKBACK = 20; + public AuthService( UserAccountRepository userAccountRepository, AuthTenantBootstrap authTenantBootstrap, @@ -55,6 +60,7 @@ public AuthService( RefreshTokenRotationTransaction refreshTokenRotationTransaction, RefreshTokenLogoutTransaction refreshTokenLogoutTransaction, AuthAuditPort authAuditPort, + UserLoginEventRepository userLoginEventRepository, UuidGenerator uuidGenerator, Clock clock ) { @@ -70,6 +76,7 @@ public AuthService( this.refreshTokenRotationTransaction = refreshTokenRotationTransaction; this.refreshTokenLogoutTransaction = refreshTokenLogoutTransaction; this.authAuditPort = authAuditPort; + this.userLoginEventRepository = userLoginEventRepository; this.uuidGenerator = uuidGenerator; this.clock = clock; } @@ -126,6 +133,13 @@ public LoginResult login(LoginCommand command) { userAccount.companyId(), issuedAt )); + userLoginEventRepository.insert( + uuidGenerator.generate(), + userAccount.userId(), + userAccount.companyId(), + command.deviceSummary(), + issuedAt + ); return new LoginResult( userAccount.userId(), @@ -156,18 +170,34 @@ public RefreshResult refresh(String rawRefreshToken) { } @Transactional(readOnly = true) - public UserAccount currentProfile(UUID userId, UUID companyId) { - return userAccountRepository.findByUserIdAndCompanyId(userId, companyId) + public ProfileSnapshot currentProfile(UUID userId, UUID companyId) { + UserAccount account = userAccountRepository.findByUserIdAndCompanyId(userId, companyId) .orElseThrow(() -> new IllegalStateException("authenticated user account was not found")); + return withLoginHistory(account); } @Transactional - public UserAccount updateProfile(UUID userId, UUID companyId, String displayName, String phone) { + public ProfileSnapshot updateProfile(UUID userId, UUID companyId, String displayName, String phone) { UserAccount current = userAccountRepository.findByUserIdAndCompanyIdWithLock(userId, companyId) .orElseThrow(() -> new IllegalStateException("authenticated user account was not found")); UserAccount updated = current.updateProfile(displayName, phone, clock.instant()); userAccountRepository.update(updated); - return updated; + return withLoginHistory(updated); + } + + private ProfileSnapshot withLoginHistory(UserAccount account) { + List recentLogins = userLoginEventRepository.findRecent( + account.userId(), account.companyId(), LOGIN_HISTORY_LOOKBACK + ); + if (recentLogins.isEmpty()) { + return new ProfileSnapshot(account, null, null, 0); + } + UserLoginEventRepository.LoginEventRecord latest = recentLogins.get(0); + long distinctDevices = recentLogins.stream() + .map(UserLoginEventRepository.LoginEventRecord::deviceSummary) + .distinct() + .count(); + return new ProfileSnapshot(account, latest.loggedInAt(), latest.deviceSummary(), (int) distinctDevices); } public void logout(String rawRefreshToken) { diff --git a/src/main/java/com/fowoco/server/auth/application/LoginCommand.java b/src/main/java/com/fowoco/server/auth/application/LoginCommand.java index b737c24a..9617815f 100644 --- a/src/main/java/com/fowoco/server/auth/application/LoginCommand.java +++ b/src/main/java/com/fowoco/server/auth/application/LoginCommand.java @@ -9,8 +9,9 @@ public final class LoginCommand { private final String email; private final String password; + private final String deviceSummary; - public LoginCommand(String email, String password) { + public LoginCommand(String email, String password, String deviceSummary) { if (email == null || email.isBlank()) { throw new IllegalArgumentException("email must not be blank"); } @@ -26,8 +27,12 @@ public LoginCommand(String email, String password) { if (password.getBytes(StandardCharsets.UTF_8).length > 72) { throw new IllegalArgumentException("password must not exceed 72 UTF-8 bytes"); } + if (deviceSummary == null || deviceSummary.isBlank()) { + throw new IllegalArgumentException("deviceSummary must not be blank"); + } this.email = email; this.password = password; + this.deviceSummary = deviceSummary; } public String email() { @@ -37,4 +42,8 @@ public String email() { public String password() { return password; } + + public String deviceSummary() { + return deviceSummary; + } } diff --git a/src/main/java/com/fowoco/server/auth/application/ProfileSnapshot.java b/src/main/java/com/fowoco/server/auth/application/ProfileSnapshot.java new file mode 100644 index 00000000..f9a5a6f4 --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/application/ProfileSnapshot.java @@ -0,0 +1,18 @@ +package com.fowoco.server.auth.application; + +import com.fowoco.server.auth.domain.UserAccount; +import java.time.Instant; + +/** + * {@link UserAccount} plus login-history facts the auth module tracks separately + * (see {@link com.fowoco.server.auth.application.port.UserLoginEventRepository}). + * Both fields are null only if the account has never completed a login, which cannot happen for + * an authenticated caller (a Bearer token can only be issued by a successful login). + */ +public record ProfileSnapshot( + UserAccount account, + Instant lastLoginAt, + String lastLoginDevice, + int recentDeviceCount +) { +} diff --git a/src/main/java/com/fowoco/server/auth/application/port/UserLoginEventRepository.java b/src/main/java/com/fowoco/server/auth/application/port/UserLoginEventRepository.java new file mode 100644 index 00000000..531346aa --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/application/port/UserLoginEventRepository.java @@ -0,0 +1,16 @@ +package com.fowoco.server.auth.application.port; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +public interface UserLoginEventRepository { + + void insert(UUID loginEventId, UUID userId, UUID companyId, String deviceSummary, Instant loggedInAt); + + /** Most recent events first, at most {@code limit} rows. */ + List findRecent(UUID userId, UUID companyId, int limit); + + record LoginEventRecord(String deviceSummary, Instant loggedInAt) { + } +} diff --git a/src/main/java/com/fowoco/server/auth/domain/UserAccount.java b/src/main/java/com/fowoco/server/auth/domain/UserAccount.java index 7f6ce9e8..48674e4d 100644 --- a/src/main/java/com/fowoco/server/auth/domain/UserAccount.java +++ b/src/main/java/com/fowoco/server/auth/domain/UserAccount.java @@ -23,6 +23,7 @@ public final class UserAccount { private final AccountStatus status; private final Instant createdAt; private final Instant updatedAt; + private final Instant passwordChangedAt; private final long version; public UserAccount( @@ -37,6 +38,7 @@ public UserAccount( AccountStatus status, Instant createdAt, Instant updatedAt, + Instant passwordChangedAt, long version ) { this.userId = Objects.requireNonNull(userId, "userId must not be null"); @@ -57,6 +59,10 @@ public UserAccount( if (updatedAt.isBefore(createdAt)) { throw new IllegalArgumentException("updatedAt must not be before createdAt"); } + this.passwordChangedAt = Objects.requireNonNull(passwordChangedAt, "passwordChangedAt must not be null"); + if (passwordChangedAt.isBefore(createdAt)) { + throw new IllegalArgumentException("passwordChangedAt must not be before createdAt"); + } if (version < 0) { throw new IllegalArgumentException("version must not be negative"); } @@ -86,6 +92,7 @@ public static UserAccount create( AccountStatus.ACTIVE, now, now, + now, 0L ); } @@ -119,6 +126,7 @@ public UserAccount changePassword(String newPasswordHash, Instant now) { status, createdAt, now, + now, version + 1 ); } @@ -140,6 +148,7 @@ public UserAccount updateProfile(String newDisplayName, String newPhone, Instant status, createdAt, now, + passwordChangedAt, version + 1 ); } @@ -188,6 +197,10 @@ public Instant updatedAt() { return updatedAt; } + public Instant passwordChangedAt() { + return passwordChangedAt; + } + public long version() { return version; } diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/persistence/JdbcUserLoginEventRepository.java b/src/main/java/com/fowoco/server/auth/infrastructure/persistence/JdbcUserLoginEventRepository.java new file mode 100644 index 00000000..39dbbb15 --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/infrastructure/persistence/JdbcUserLoginEventRepository.java @@ -0,0 +1,60 @@ +package com.fowoco.server.auth.infrastructure.persistence; + +import com.fowoco.server.auth.application.port.UserLoginEventRepository; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@Repository +public class JdbcUserLoginEventRepository implements UserLoginEventRepository { + + private final JdbcTemplate jdbcTemplate; + + public JdbcUserLoginEventRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public void insert(UUID loginEventId, UUID userId, UUID companyId, String deviceSummary, Instant loggedInAt) { + jdbcTemplate.update( + """ + INSERT INTO user_login_event (login_event_id, user_id, company_id, device_summary, logged_in_at) + VALUES (?, ?, ?, ?, ?) + """, + loginEventId, + userId, + companyId, + deviceSummary, + Timestamp.from(loggedInAt) + ); + } + + @Override + public List findRecent(UUID userId, UUID companyId, int limit) { + return jdbcTemplate.query( + """ + SELECT device_summary, logged_in_at + FROM user_login_event + WHERE user_id = ? AND company_id = ? + ORDER BY logged_in_at DESC + LIMIT ? + """, + (resultSet, rowNum) -> mapRow(resultSet), + userId, + companyId, + limit + ); + } + + private LoginEventRecord mapRow(ResultSet resultSet) throws SQLException { + return new LoginEventRecord( + resultSet.getString("device_summary"), + resultSet.getTimestamp("logged_in_at").toInstant() + ); + } +} diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/persistence/UserAccountJpaEntity.java b/src/main/java/com/fowoco/server/auth/infrastructure/persistence/UserAccountJpaEntity.java index f3425733..2dcb00dd 100644 --- a/src/main/java/com/fowoco/server/auth/infrastructure/persistence/UserAccountJpaEntity.java +++ b/src/main/java/com/fowoco/server/auth/infrastructure/persistence/UserAccountJpaEntity.java @@ -67,6 +67,9 @@ public class UserAccountJpaEntity { @Column(name = "updated_at", nullable = false) private Instant updatedAt; + @Column(name = "password_changed_at", nullable = false) + private Instant passwordChangedAt; + @Version @Column(name = "version", nullable = false) private long version; @@ -86,6 +89,7 @@ private UserAccountJpaEntity( AccountStatus status, Instant createdAt, Instant updatedAt, + Instant passwordChangedAt, long version ) { this.userId = userId; @@ -99,6 +103,7 @@ private UserAccountJpaEntity( this.status = status; this.createdAt = createdAt; this.updatedAt = updatedAt; + this.passwordChangedAt = passwordChangedAt; this.version = version; } @@ -116,6 +121,7 @@ public static UserAccountJpaEntity fromDomain(UserAccount userAccount) { userAccount.status(), userAccount.createdAt(), userAccount.updatedAt(), + userAccount.passwordChangedAt(), userAccount.version() ); } @@ -133,6 +139,7 @@ public UserAccount toDomain() { status, createdAt, updatedAt, + passwordChangedAt, version ); } @@ -146,6 +153,7 @@ void applyState(UserAccount userAccount) { phone = userAccount.phone(); passwordHash = userAccount.passwordHash(); updatedAt = userAccount.updatedAt(); + passwordChangedAt = userAccount.passwordChangedAt(); } } diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/web/UserAgentDeviceSummarizer.java b/src/main/java/com/fowoco/server/auth/infrastructure/web/UserAgentDeviceSummarizer.java new file mode 100644 index 00000000..7538b333 --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/infrastructure/web/UserAgentDeviceSummarizer.java @@ -0,0 +1,74 @@ +package com.fowoco.server.auth.infrastructure.web; + +/** + * Best-effort "Browser · OS" label from a raw User-Agent header, for the login-history display + * on the profile page. Not a security control — only used for a human-readable device hint. + */ +public final class UserAgentDeviceSummarizer { + + private static final String UNKNOWN = "알 수 없는 기기"; + + private UserAgentDeviceSummarizer() { + } + + public static String summarize(String userAgent) { + if (userAgent == null || userAgent.isBlank()) { + return UNKNOWN; + } + String browser = browserOf(userAgent); + String os = osOf(userAgent); + if (browser == null && os == null) { + return UNKNOWN; + } + if (browser == null) { + return os; + } + if (os == null) { + return browser; + } + return browser + " · " + os; + } + + private static String browserOf(String userAgent) { + // Order matters: Edge/Chrome/Samsung UAs also contain "Safari", and Chrome-based + // Edge/Opera UAs also contain "Chrome". + if (userAgent.contains("Edg/") || userAgent.contains("EdgA/") || userAgent.contains("EdgiOS/")) { + return "Edge"; + } + if (userAgent.contains("OPR/") || userAgent.contains("Opera")) { + return "Opera"; + } + if (userAgent.contains("SamsungBrowser/")) { + return "Samsung Internet"; + } + if (userAgent.contains("Firefox/") && !userAgent.contains("Seamonkey/")) { + return "Firefox"; + } + if (userAgent.contains("Chrome/") || userAgent.contains("CriOS/")) { + return "Chrome"; + } + if (userAgent.contains("Safari/") && (userAgent.contains("Version/") || userAgent.contains("Mobile/"))) { + return "Safari"; + } + return null; + } + + private static String osOf(String userAgent) { + if (userAgent.contains("Windows")) { + return "Windows"; + } + if (userAgent.contains("iPhone") || userAgent.contains("iPad") || userAgent.contains("iPod")) { + return "iOS"; + } + if (userAgent.contains("Mac OS X") || userAgent.contains("Macintosh")) { + return "macOS"; + } + if (userAgent.contains("Android")) { + return "Android"; + } + if (userAgent.contains("Linux")) { + return "Linux"; + } + return null; + } +} diff --git a/src/main/java/com/fowoco/server/notification/api/NotificationController.java b/src/main/java/com/fowoco/server/notification/api/NotificationController.java index 0985a9b3..8338ca24 100644 --- a/src/main/java/com/fowoco/server/notification/api/NotificationController.java +++ b/src/main/java/com/fowoco/server/notification/api/NotificationController.java @@ -3,6 +3,7 @@ import com.fowoco.server.auth.application.ActorContext; import com.fowoco.server.auth.application.port.ActorContextProvider; import com.fowoco.server.notification.application.NotificationPageResult; +import com.fowoco.server.notification.application.NotificationPreferenceService; import com.fowoco.server.notification.application.NotificationService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -12,9 +13,11 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import java.time.Instant; +import java.util.List; import java.util.UUID; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -22,7 +25,9 @@ import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -35,13 +40,16 @@ public class NotificationController { private final NotificationService notificationService; + private final NotificationPreferenceService notificationPreferenceService; private final ActorContextProvider actorContextProvider; public NotificationController( NotificationService notificationService, + NotificationPreferenceService notificationPreferenceService, ActorContextProvider actorContextProvider ) { this.notificationService = notificationService; + this.notificationPreferenceService = notificationPreferenceService; this.actorContextProvider = actorContextProvider; } @@ -102,4 +110,65 @@ public ResponseEntity read( notificationService.markAsRead(notificationId, actor); return ResponseEntity.noContent().build(); } + + @Operation( + operationId = "listNotificationPreferences", + summary = "알림 수신 설정 조회", + description = "알림 유형별 수신 여부를 반환합니다. 저장된 값이 없으면 기본값을 반환합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = NotificationPreferenceResponse.class) + ) + ), + @ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"), + @ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden") + }) + @GetMapping(path = "/preferences", produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')") + public List listPreferences() { + ActorContext actor = actorContextProvider.requireCurrentActor(); + return notificationPreferenceService.list(actor).stream() + .map(NotificationPreferenceResponse::from) + .toList(); + } + + @Operation( + operationId = "updateNotificationPreference", + summary = "알림 수신 설정 변경", + description = "특정 알림 유형의 수신 여부를 변경합니다. 필수 알림은 끌 수 없습니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "변경된 전체 알림 수신 설정", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = NotificationPreferenceResponse.class) + ) + ), + @ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"), + @ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden"), + @ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound"), + @ApiResponse(responseCode = "422", ref = "#/components/responses/UnprocessableEntity") + }) + @PatchMapping( + path = "/preferences/{key}", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + @PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')") + public List updatePreference( + @Parameter(description = "알림 유형 key") @PathVariable String key, + @Valid @RequestBody UpdateNotificationPreferenceRequest request + ) { + ActorContext actor = actorContextProvider.requireCurrentActor(); + return notificationPreferenceService.update(actor, key, request.getEnabled()).stream() + .map(NotificationPreferenceResponse::from) + .toList(); + } } diff --git a/src/main/java/com/fowoco/server/notification/api/NotificationPreferenceResponse.java b/src/main/java/com/fowoco/server/notification/api/NotificationPreferenceResponse.java new file mode 100644 index 00000000..525373fc --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/api/NotificationPreferenceResponse.java @@ -0,0 +1,23 @@ +package com.fowoco.server.notification.api; + +import com.fowoco.server.notification.application.NotificationPreferenceService.PreferenceState; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "NotificationPreferenceResponse", description = "알림 유형별 수신 설정") +public record NotificationPreferenceResponse( + @Schema(description = "알림 유형 key", example = "due-soon") + String key, + @Schema(description = "수신 여부") + boolean enabled, + @Schema(description = "필수 알림 여부. true면 enabled를 false로 바꿀 수 없습니다.") + boolean required +) { + + public static NotificationPreferenceResponse from(PreferenceState state) { + return new NotificationPreferenceResponse( + state.key().key(), + state.enabled(), + state.key().required() + ); + } +} diff --git a/src/main/java/com/fowoco/server/notification/api/UpdateNotificationPreferenceRequest.java b/src/main/java/com/fowoco/server/notification/api/UpdateNotificationPreferenceRequest.java new file mode 100644 index 00000000..ce5c2686 --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/api/UpdateNotificationPreferenceRequest.java @@ -0,0 +1,23 @@ +package com.fowoco.server.notification.api; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(name = "UpdateNotificationPreferenceRequest", description = "알림 유형 수신 설정 변경 요청") +public final class UpdateNotificationPreferenceRequest { + + @Schema(description = "수신 여부", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "enabled 값을 입력해 주세요.") + private final Boolean enabled; + + @JsonCreator + public UpdateNotificationPreferenceRequest(@JsonProperty("enabled") Boolean enabled) { + this.enabled = enabled; + } + + public Boolean getEnabled() { + return enabled; + } +} diff --git a/src/main/java/com/fowoco/server/notification/application/NotificationPreferenceService.java b/src/main/java/com/fowoco/server/notification/application/NotificationPreferenceService.java new file mode 100644 index 00000000..e779337f --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/application/NotificationPreferenceService.java @@ -0,0 +1,72 @@ +package com.fowoco.server.notification.application; + +import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.common.error.ApiException; +import com.fowoco.server.common.id.UuidGenerator; +import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.notification.application.error.NotificationErrorCode; +import com.fowoco.server.notification.application.port.NotificationPreferenceRepository; +import com.fowoco.server.notification.domain.NotificationPreferenceKey; +import java.time.Clock; +import java.util.List; +import java.util.Map; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class NotificationPreferenceService { + + private final NotificationPreferenceRepository notificationPreferenceRepository; + private final TenantDatabaseContext tenantDatabaseContext; + private final UuidGenerator uuidGenerator; + private final Clock clock; + + public NotificationPreferenceService( + NotificationPreferenceRepository notificationPreferenceRepository, + TenantDatabaseContext tenantDatabaseContext, + UuidGenerator uuidGenerator, + Clock clock + ) { + this.notificationPreferenceRepository = notificationPreferenceRepository; + this.tenantDatabaseContext = tenantDatabaseContext; + this.uuidGenerator = uuidGenerator; + this.clock = clock; + } + + public record PreferenceState(NotificationPreferenceKey key, boolean enabled) { + } + + @Transactional(readOnly = true) + public List list(ActorContext actor) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); + Map overrides = + notificationPreferenceRepository.findOverrides(actor.actorId(), actor.companyId()); + return NotificationPreferenceKey.defaults().entrySet().stream() + .map(entry -> new PreferenceState( + entry.getKey(), + overrides.getOrDefault(entry.getKey(), entry.getValue()) + )) + .toList(); + } + + @Transactional + public List update(ActorContext actor, String rawKey, boolean enabled) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); + NotificationPreferenceKey key = NotificationPreferenceKey.fromKey(rawKey); + if (key == null) { + throw new ApiException(NotificationErrorCode.NOTIFICATION_PREFERENCE_NOT_FOUND); + } + if (key.required() && !enabled) { + throw new ApiException(NotificationErrorCode.NOTIFICATION_PREFERENCE_REQUIRED); + } + notificationPreferenceRepository.upsert( + uuidGenerator.generate(), + actor.actorId(), + actor.companyId(), + key, + enabled, + clock.instant() + ); + return list(actor); + } +} diff --git a/src/main/java/com/fowoco/server/notification/application/error/NotificationErrorCode.java b/src/main/java/com/fowoco/server/notification/application/error/NotificationErrorCode.java index 23ba7b16..33495669 100644 --- a/src/main/java/com/fowoco/server/notification/application/error/NotificationErrorCode.java +++ b/src/main/java/com/fowoco/server/notification/application/error/NotificationErrorCode.java @@ -7,6 +7,14 @@ public enum NotificationErrorCode implements ApiErrorCode { NOTIFICATION_NOT_FOUND( HttpStatus.NOT_FOUND, "알림을 찾을 수 없습니다." + ), + NOTIFICATION_PREFERENCE_NOT_FOUND( + HttpStatus.NOT_FOUND, + "알 수 없는 알림 유형입니다." + ), + NOTIFICATION_PREFERENCE_REQUIRED( + HttpStatus.UNPROCESSABLE_ENTITY, + "필수 알림은 끌 수 없습니다." ); private final HttpStatus status; diff --git a/src/main/java/com/fowoco/server/notification/application/port/NotificationPreferenceRepository.java b/src/main/java/com/fowoco/server/notification/application/port/NotificationPreferenceRepository.java new file mode 100644 index 00000000..d17fc002 --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/application/port/NotificationPreferenceRepository.java @@ -0,0 +1,21 @@ +package com.fowoco.server.notification.application.port; + +import com.fowoco.server.notification.domain.NotificationPreferenceKey; +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +public interface NotificationPreferenceRepository { + + /** Only rows the user has explicitly overridden from the default. */ + Map findOverrides(UUID userId, UUID companyId); + + void upsert( + UUID preferenceId, + UUID userId, + UUID companyId, + NotificationPreferenceKey key, + boolean enabled, + Instant updatedAt + ); +} diff --git a/src/main/java/com/fowoco/server/notification/domain/NotificationPreferenceKey.java b/src/main/java/com/fowoco/server/notification/domain/NotificationPreferenceKey.java new file mode 100644 index 00000000..8f10ce9b --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/domain/NotificationPreferenceKey.java @@ -0,0 +1,59 @@ +package com.fowoco.server.notification.domain; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Notification categories a user can opt in/out of. Keys and default states mirror + * fowoco/client's ProfilePage notification list, kept in one place so both sides agree on + * what "default on/off" and "required" mean. + */ +public enum NotificationPreferenceKey { + SECURITY_PERMISSION("security-permission", true, true), + APPROVAL_REQUEST("approval-request", true, false), + DOCUMENT_SUBMITTED("document-submitted", true, false), + DOCUMENT_NEEDS_FIX("document-needs-fix", true, false), + DUE_SOON("due-soon", true, false), + ASSIGNED("assigned", false, false), + AGENT_READY("agent-ready", true, false); + + private final String key; + private final boolean defaultEnabled; + private final boolean required; + + NotificationPreferenceKey(String key, boolean defaultEnabled, boolean required) { + this.key = key; + this.defaultEnabled = defaultEnabled; + this.required = required; + } + + public String key() { + return key; + } + + public boolean defaultEnabled() { + return defaultEnabled; + } + + /** Required preferences (security/permission alerts) can never be disabled. */ + public boolean required() { + return required; + } + + public static NotificationPreferenceKey fromKey(String key) { + for (NotificationPreferenceKey value : values()) { + if (value.key.equals(key)) { + return value; + } + } + return null; + } + + public static Map defaults() { + Map defaults = new LinkedHashMap<>(); + for (NotificationPreferenceKey value : values()) { + defaults.put(value, value.defaultEnabled); + } + return defaults; + } +} diff --git a/src/main/java/com/fowoco/server/notification/infrastructure/persistence/JdbcNotificationPreferenceRepository.java b/src/main/java/com/fowoco/server/notification/infrastructure/persistence/JdbcNotificationPreferenceRepository.java new file mode 100644 index 00000000..0e1698ee --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/infrastructure/persistence/JdbcNotificationPreferenceRepository.java @@ -0,0 +1,81 @@ +package com.fowoco.server.notification.infrastructure.persistence; + +import com.fowoco.server.notification.application.port.NotificationPreferenceRepository; +import com.fowoco.server.notification.domain.NotificationPreferenceKey; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.EnumMap; +import java.util.Map; +import java.util.UUID; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@Repository +public class JdbcNotificationPreferenceRepository implements NotificationPreferenceRepository { + + private final JdbcTemplate jdbcTemplate; + + public JdbcNotificationPreferenceRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public Map findOverrides(UUID userId, UUID companyId) { + Map overrides = new EnumMap<>(NotificationPreferenceKey.class); + jdbcTemplate.query( + """ + SELECT pref_key, enabled + FROM notification_preference + WHERE user_id = ? AND company_id = ? + """, + resultSet -> { + NotificationPreferenceKey key = + NotificationPreferenceKey.fromKey(resultSet.getString("pref_key")); + if (key != null) { + overrides.put(key, resultSet.getBoolean("enabled")); + } + }, + userId, + companyId + ); + return overrides; + } + + @Override + public void upsert( + UUID preferenceId, + UUID userId, + UUID companyId, + NotificationPreferenceKey key, + boolean enabled, + Instant updatedAt + ) { + int updated = jdbcTemplate.update( + """ + UPDATE notification_preference + SET enabled = ?, updated_at = ? + WHERE user_id = ? AND company_id = ? AND pref_key = ? + """, + enabled, + Timestamp.from(updatedAt), + userId, + companyId, + key.key() + ); + if (updated == 0) { + jdbcTemplate.update( + """ + INSERT INTO notification_preference + (notification_preference_id, user_id, company_id, pref_key, enabled, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + preferenceId, + userId, + companyId, + key.key(), + enabled, + Timestamp.from(updatedAt) + ); + } + } +} diff --git a/src/main/resources/db/migration/V48__add_user_account_password_changed_at.sql b/src/main/resources/db/migration/V48__add_user_account_password_changed_at.sql new file mode 100644 index 00000000..60ebdaea --- /dev/null +++ b/src/main/resources/db/migration/V48__add_user_account_password_changed_at.sql @@ -0,0 +1,4 @@ +ALTER TABLE user_account + ADD COLUMN password_changed_at TIMESTAMP(6) WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP; + +UPDATE user_account SET password_changed_at = created_at; diff --git a/src/main/resources/db/migration/V49__create_user_login_event.sql b/src/main/resources/db/migration/V49__create_user_login_event.sql new file mode 100644 index 00000000..2c4b903b --- /dev/null +++ b/src/main/resources/db/migration/V49__create_user_login_event.sql @@ -0,0 +1,16 @@ +CREATE TABLE user_login_event ( + login_event_id UUID NOT NULL, + user_id UUID NOT NULL, + company_id UUID NOT NULL, + device_summary VARCHAR(120) NOT NULL, + logged_in_at TIMESTAMP(6) WITH TIME ZONE NOT NULL, + CONSTRAINT pk_user_login_event PRIMARY KEY (login_event_id), + CONSTRAINT fk_user_login_event_user_company + FOREIGN KEY (user_id, company_id) + REFERENCES user_account (user_id, company_id) ON DELETE CASCADE, + CONSTRAINT ck_user_login_event_device_summary_not_blank + CHECK (CHAR_LENGTH(TRIM(device_summary)) > 0) +); + +CREATE INDEX idx_user_login_event_user_time + ON user_login_event (user_id, company_id, logged_in_at DESC); diff --git a/src/main/resources/db/migration/V50__create_notification_preference.sql b/src/main/resources/db/migration/V50__create_notification_preference.sql new file mode 100644 index 00000000..c67e4b72 --- /dev/null +++ b/src/main/resources/db/migration/V50__create_notification_preference.sql @@ -0,0 +1,17 @@ +CREATE TABLE notification_preference ( + notification_preference_id UUID NOT NULL, + user_id UUID NOT NULL, + company_id UUID NOT NULL, + pref_key VARCHAR(60) NOT NULL, + enabled BOOLEAN NOT NULL, + updated_at TIMESTAMP(6) WITH TIME ZONE NOT NULL, + CONSTRAINT pk_notification_preference PRIMARY KEY (notification_preference_id), + CONSTRAINT uq_notification_preference_user_key UNIQUE (user_id, company_id, pref_key), + CONSTRAINT fk_notification_preference_user_company + FOREIGN KEY (user_id, company_id) + REFERENCES user_account (user_id, company_id) ON DELETE CASCADE, + CONSTRAINT ck_notification_preference_key_not_blank CHECK (CHAR_LENGTH(TRIM(pref_key)) > 0) +); + +CREATE INDEX idx_notification_preference_user + ON notification_preference (user_id, company_id); diff --git a/src/test/java/com/fowoco/server/auth/AuthSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/auth/AuthSecurityIntegrationTest.java index bea525e9..46eeea57 100644 --- a/src/test/java/com/fowoco/server/auth/AuthSecurityIntegrationTest.java +++ b/src/test/java/com/fowoco/server/auth/AuthSecurityIntegrationTest.java @@ -85,6 +85,7 @@ void seedCompaniesAndUsers() { @BeforeEach void resetAuthenticationState() { jdbcTemplate.update("DELETE FROM refresh_token"); + jdbcTemplate.update("DELETE FROM user_login_event"); jdbcTemplate.update( "UPDATE user_account SET status = 'ACTIVE', updated_at = CURRENT_TIMESTAMP, version = version + 1" ); @@ -132,6 +133,27 @@ void loginTokenCreatesActorContextAndStoresOnlyRefreshTokenHash() throws Excepti assertThat(JsonPath.read(meResponse.body(), "$.roles[0]")).isEqualTo("VIEWER"); } + @Test + void getMyProfileReflectsAccountStateAndRecordsLoginDeviceHistory() throws Exception { + String chromeMacUserAgent = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"; + HttpResponse loginResponse = loginWithUserAgent(HR_A_EMAIL, PASSWORD, chromeMacUserAgent); + String accessToken = accessToken(loginResponse); + + HttpResponse profileResponse = authorizedGet("/api/v1/auth/me/profile", accessToken); + + assertThat(profileResponse.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(profileResponse.body(), "$.role")).isEqualTo("HR"); + assertThat(JsonPath.read(profileResponse.body(), "$.account_status")).isEqualTo("ACTIVE"); + assertThat(JsonPath.read(profileResponse.body(), "$.password_changed_at")).isNotBlank(); + assertThat(JsonPath.read(profileResponse.body(), "$.last_login_at")).isNotBlank(); + assertThat(JsonPath.read(profileResponse.body(), "$.last_login_device")) + .isEqualTo("Chrome · macOS"); + assertThat(JsonPath.read(profileResponse.body(), "$.recent_device_count").intValue()) + .isEqualTo(1); + } + @Test void refreshRotatesTokenAndPersistsTheReplacementChain() throws Exception { HttpResponse loginResponse = login(VIEWER_A_EMAIL, PASSWORD); @@ -423,6 +445,19 @@ private HttpResponse login(String email, String password) throws Excepti return postJson("/api/v1/auth/login", body, null); } + private HttpResponse loginWithUserAgent(String email, String password, String userAgent) + throws Exception { + String body = """ + {"email":"%s","password":"%s"} + """.formatted(email, password); + HttpRequest request = HttpRequest.newBuilder(uri("/api/v1/auth/login")) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .header(HttpHeaders.USER_AGENT, userAgent) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } + private HttpResponse refresh(String rawRefreshToken) throws Exception { return postWithRefreshTokenCookie("/api/v1/auth/refresh", rawRefreshToken); } diff --git a/src/test/java/com/fowoco/server/auth/infrastructure/web/UserAgentDeviceSummarizerTest.java b/src/test/java/com/fowoco/server/auth/infrastructure/web/UserAgentDeviceSummarizerTest.java new file mode 100644 index 00000000..c4a4eed0 --- /dev/null +++ b/src/test/java/com/fowoco/server/auth/infrastructure/web/UserAgentDeviceSummarizerTest.java @@ -0,0 +1,46 @@ +package com.fowoco.server.auth.infrastructure.web; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class UserAgentDeviceSummarizerTest { + + @Test + void recognizesChromeOnMac() { + String ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"; + assertThat(UserAgentDeviceSummarizer.summarize(ua)).isEqualTo("Chrome · macOS"); + } + + @Test + void recognizesSafariOnIphone() { + String ua = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 " + + "(KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"; + assertThat(UserAgentDeviceSummarizer.summarize(ua)).isEqualTo("Safari · iOS"); + } + + @Test + void recognizesEdgeOnWindowsAndDoesNotMisreadItAsChrome() { + String ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.0.0"; + assertThat(UserAgentDeviceSummarizer.summarize(ua)).isEqualTo("Edge · Windows"); + } + + @Test + void recognizesFirefoxOnLinux() { + String ua = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"; + assertThat(UserAgentDeviceSummarizer.summarize(ua)).isEqualTo("Firefox · Linux"); + } + + @Test + void fallsBackToUnknownWhenBlank() { + assertThat(UserAgentDeviceSummarizer.summarize(null)).isEqualTo("알 수 없는 기기"); + assertThat(UserAgentDeviceSummarizer.summarize(" ")).isEqualTo("알 수 없는 기기"); + } + + @Test + void fallsBackToUnknownWhenUnrecognized() { + assertThat(UserAgentDeviceSummarizer.summarize("curl/8.4.0")).isEqualTo("알 수 없는 기기"); + } +} diff --git a/src/test/java/com/fowoco/server/notification/NotificationSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/notification/NotificationSecurityIntegrationTest.java index f45e569d..1001409f 100644 --- a/src/test/java/com/fowoco/server/notification/NotificationSecurityIntegrationTest.java +++ b/src/test/java/com/fowoco/server/notification/NotificationSecurityIntegrationTest.java @@ -209,6 +209,76 @@ void cursorAndUnreadOnlyFiltersCanBeCombined() throws Exception { assertThat(JsonPath.>read(unreadPage.body(), "$.items")).hasSize(1); } + @Test + void listPreferencesReturnsDefaultsWhenNothingStored() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = authorizedGet("/api/v1/notifications/preferences", accessToken); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(response.body(), "$")).hasSize(7); + assertThat(preferenceEnabled(response.body(), "security-permission")).isTrue(); + assertThat(preferenceRequired(response.body(), "security-permission")).isTrue(); + assertThat(preferenceEnabled(response.body(), "assigned")).isFalse(); + } + + @Test + void updatePreferencePersistsAcrossRequests() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + + HttpResponse updateResponse = authorizedPatch( + "/api/v1/notifications/preferences/due-soon", "{\"enabled\":false}", accessToken + ); + assertThat(updateResponse.statusCode()).isEqualTo(200); + assertThat(preferenceEnabled(updateResponse.body(), "due-soon")).isFalse(); + + HttpResponse listResponse = authorizedGet("/api/v1/notifications/preferences", accessToken); + assertThat(preferenceEnabled(listResponse.body(), "due-soon")).isFalse(); + } + + @Test + void updatingRequiredPreferenceToDisabledIsRejected() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = authorizedPatch( + "/api/v1/notifications/preferences/security-permission", "{\"enabled\":false}", accessToken + ); + + assertThat(response.statusCode()).isEqualTo(422); + } + + @Test + void updatingUnknownPreferenceKeyReturnsNotFound() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = authorizedPatch( + "/api/v1/notifications/preferences/not-a-real-key", "{\"enabled\":false}", accessToken + ); + + assertThat(response.statusCode()).isEqualTo(404); + } + + @Test + void preferencesAreIsolatedPerUser() throws Exception { + String hrAToken = accessToken(login(HR_A_EMAIL)); + String hrA2Token = accessToken(login(HR_A2_EMAIL)); + authorizedPatch("/api/v1/notifications/preferences/agent-ready", "{\"enabled\":false}", hrAToken); + + HttpResponse otherUserList = authorizedGet("/api/v1/notifications/preferences", hrA2Token); + + assertThat(preferenceEnabled(otherUserList.body(), "agent-ready")).isTrue(); + } + + private boolean preferenceEnabled(String body, String key) { + java.util.List matches = JsonPath.read(body, "$[?(@.key=='" + key + "')].enabled"); + return matches.get(0); + } + + private boolean preferenceRequired(String body, String key) { + java.util.List matches = JsonPath.read(body, "$[?(@.key=='" + key + "')].required"); + return matches.get(0); + } + private UUID insertNotification(UUID companyId, String targetType, boolean read, Instant occurredAt) { return insertNotification(companyId, HR_A, targetType, read, occurredAt); } @@ -290,6 +360,15 @@ private HttpResponse postJson(String path, String body, String accessTok return httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()); } + private HttpResponse authorizedPatch(String path, String body, String accessToken) throws Exception { + HttpRequest request = HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .method("PATCH", HttpRequest.BodyPublishers.ofString(body)) + .build(); + return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } + private URI uri(String path) { return URI.create("http://localhost:" + port + path); } From c6bf9dcfc5c6cecc003e3e42f11cc2e04d95bbf9 Mon Sep 17 00:00:00 2001 From: BcKmini <151009045+BcKmini@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:19:18 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EC=A0=9C=ED=95=9C=20role=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B6=8C=ED=95=9C=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=97=90=20=EC=8B=A0=EA=B7=9C=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit user_login_event/notification_preference 테이블이 새로 생겼는데 PostgreSqlRestrictedRoleHttpEnvironment의 TABLE_PRIVILEGES 허용목록에 반영이 안 돼 있었다. 이 목록은 실제 fowoco_runtime role 권한을 테스트용 role에 그대로 재현하는 용도라, 로그인 시 user_login_event에 INSERT하는 새 로직이 테스트 환경에서만 42501(insufficient_privilege)로 막혀 로그인 자체가 500으로 실패하고 있었다 (실제 프로덕션은 migration role의 ALTER DEFAULT PRIVILEGES로 자동 커버되어 영향 없음, 이 테스트 role만 수동 허용목록이라 별도 반영 필요). 로컬에서 Postgres 16 컨테이너로 4개 테스트 전부 통과 확인함. --- ...stgreSqlRestrictedRoleHttpEnvironment.java | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/test/java/com/fowoco/server/common/security/PostgreSqlRestrictedRoleHttpEnvironment.java b/src/test/java/com/fowoco/server/common/security/PostgreSqlRestrictedRoleHttpEnvironment.java index bf8a0b3c..b3f583c2 100644 --- a/src/test/java/com/fowoco/server/common/security/PostgreSqlRestrictedRoleHttpEnvironment.java +++ b/src/test/java/com/fowoco/server/common/security/PostgreSqlRestrictedRoleHttpEnvironment.java @@ -21,17 +21,19 @@ final class PostgreSqlRestrictedRoleHttpEnvironment implements AutoCloseable { - private static final Map TABLE_PRIVILEGES = Map.of( - "company", "SELECT", - "user_account", "SELECT", - "refresh_token", "SELECT, INSERT, UPDATE", - "worker", "SELECT, INSERT, UPDATE", - "task", "SELECT", - "worker_link", "SELECT", - "worker_response", "SELECT, INSERT", - "document_request_draft", "SELECT", - "document_request_draft_type", "SELECT", - "audit_event", "SELECT, INSERT" + private static final Map TABLE_PRIVILEGES = Map.ofEntries( + Map.entry("company", "SELECT"), + Map.entry("user_account", "SELECT"), + Map.entry("refresh_token", "SELECT, INSERT, UPDATE"), + Map.entry("worker", "SELECT, INSERT, UPDATE"), + Map.entry("task", "SELECT"), + Map.entry("worker_link", "SELECT"), + Map.entry("worker_response", "SELECT, INSERT"), + Map.entry("document_request_draft", "SELECT"), + Map.entry("document_request_draft_type", "SELECT"), + Map.entry("audit_event", "SELECT, INSERT"), + Map.entry("user_login_event", "SELECT, INSERT"), + Map.entry("notification_preference", "SELECT, INSERT, UPDATE") ); private static final String[] BOOTSTRAP_FUNCTIONS = { "public.bootstrap_company_id_by_normalized_email(TEXT)",