diff --git a/src/main/java/gg/agit/konect/domain/club/event/SheetSyncFailedEvent.java b/src/main/java/gg/agit/konect/domain/club/event/SheetSyncFailedEvent.java new file mode 100644 index 000000000..76749c328 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/event/SheetSyncFailedEvent.java @@ -0,0 +1,28 @@ +package gg.agit.konect.domain.club.event; + +import java.time.LocalDateTime; + +public record SheetSyncFailedEvent( + Integer clubId, + String spreadsheetId, + boolean accessDenied, + String reason, + LocalDateTime occurredAt +) { + + public static SheetSyncFailedEvent accessDenied( + Integer clubId, + String spreadsheetId, + String reason + ) { + return new SheetSyncFailedEvent(clubId, spreadsheetId, true, reason, LocalDateTime.now()); + } + + public static SheetSyncFailedEvent unexpected( + Integer clubId, + String spreadsheetId, + String reason + ) { + return new SheetSyncFailedEvent(clubId, spreadsheetId, false, reason, LocalDateTime.now()); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedService.java index d6b8ac7c1..0f02bdb11 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedService.java @@ -10,6 +10,7 @@ public class ClubSheetIntegratedService { private final ClubPermissionValidator clubPermissionValidator; + private final GoogleSheetPermissionService googleSheetPermissionService; private final SheetHeaderMapper sheetHeaderMapper; private final ClubMemberSheetService clubMemberSheetService; private final SheetImportService sheetImportService; @@ -22,6 +23,9 @@ public SheetImportResponse analyzeAndImportPreMembers( clubPermissionValidator.validateManagerAccess(clubId, requesterId); String spreadsheetId = SpreadsheetUrlParser.extractId(spreadsheetUrl); + // Best-effort: OAuth 미연결/권한 부여 실패여도 이미 수동 공유된 시트는 그대로 읽을 수 있다. + googleSheetPermissionService.tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId); + SheetHeaderMapper.SheetAnalysisResult analysis = sheetHeaderMapper.analyzeAllSheets(spreadsheetId); diff --git a/src/main/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelper.java b/src/main/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelper.java new file mode 100644 index 000000000..8f41fc16d --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelper.java @@ -0,0 +1,34 @@ +package gg.agit.konect.domain.club.service; + +final class GoogleDrivePermissionHelper { + + private static final int ROLE_RANK_NONE = 0; + private static final int ROLE_RANK_READER = 1; + private static final int ROLE_RANK_COMMENTER = 2; + private static final int ROLE_RANK_WRITER = 3; + + private GoogleDrivePermissionHelper() {} + + enum PermissionApplyStatus { + CREATED, + UPGRADED, + UNCHANGED + } + + static boolean hasRequiredRole(String currentRole, String targetRole) { + return roleRank(currentRole) >= roleRank(targetRole); + } + + private static int roleRank(String role) { + if (role == null) { + return ROLE_RANK_NONE; + } + + return switch (role) { + case "reader" -> ROLE_RANK_READER; + case "commenter" -> ROLE_RANK_COMMENTER; + case "writer", "fileOrganizer", "organizer", "owner" -> ROLE_RANK_WRITER; + default -> ROLE_RANK_NONE; + }; + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetApiExceptionHelper.java b/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetApiExceptionHelper.java new file mode 100644 index 000000000..e7a650ea2 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetApiExceptionHelper.java @@ -0,0 +1,95 @@ +package gg.agit.konect.domain.club.service; + +import java.io.IOException; +import java.util.List; +import java.util.Set; + +import com.google.api.client.googleapis.json.GoogleJsonError; +import com.google.api.client.googleapis.json.GoogleJsonResponseException; +import com.google.api.client.http.HttpResponseException; + +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; + +public final class GoogleSheetApiExceptionHelper { + + private static final Set ACCESS_DENIED_REASONS = Set.of( + "accessDenied", + "forbidden", + "insufficientFilePermissions", + "insufficientPermissions", + "notAuthorized", + "required" + ); + private static final Set AUTH_FAILURE_REASONS = Set.of( + "authError", + "invalidCredentials", + "unauthorized" + ); + private static final int HTTP_STATUS_FORBIDDEN = 403; + private static final int HTTP_STATUS_UNAUTHORIZED = 401; + private static final int HTTP_STATUS_NOT_FOUND = 404; + + private GoogleSheetApiExceptionHelper() {} + + public static boolean isAccessDenied(IOException exception) { + if (exception instanceof GoogleJsonResponseException responseException) { + if (responseException.getStatusCode() != HTTP_STATUS_FORBIDDEN) { + return false; + } + return hasReason(responseException, ACCESS_DENIED_REASONS); + } + return getStatusCode(exception) == HTTP_STATUS_FORBIDDEN; + } + + public static boolean isAuthFailure(IOException exception) { + if (exception instanceof GoogleJsonResponseException responseException) { + if (responseException.getStatusCode() != HTTP_STATUS_UNAUTHORIZED) { + return false; + } + return hasReason(responseException, AUTH_FAILURE_REASONS) + || !hasKnownReasons(responseException); + } + return getStatusCode(exception) == HTTP_STATUS_UNAUTHORIZED; + } + + public static boolean isNotFound(IOException exception) { + return getStatusCode(exception) == HTTP_STATUS_NOT_FOUND; + } + + public static CustomException accessDenied() { + return CustomException.of(ApiResponseCode.FORBIDDEN_GOOGLE_SHEET_ACCESS); + } + + private static boolean hasReason( + GoogleJsonResponseException exception, + Set expectedReasons + ) { + return getReasons(exception).stream() + .anyMatch(expectedReasons::contains); + } + + private static boolean hasKnownReasons(GoogleJsonResponseException exception) { + return !getReasons(exception).isEmpty(); + } + + private static List getReasons(GoogleJsonResponseException exception) { + if (exception.getDetails() == null || exception.getDetails().getErrors() == null) { + return List.of(); + } + return exception.getDetails().getErrors().stream() + .map(GoogleJsonError.ErrorInfo::getReason) + .filter(reason -> reason != null && !reason.isBlank()) + .toList(); + } + + private static int getStatusCode(IOException exception) { + if (exception instanceof GoogleJsonResponseException responseException) { + return responseException.getStatusCode(); + } + if (exception instanceof HttpResponseException responseException) { + return responseException.getStatusCode(); + } + return -1; + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionService.java b/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionService.java new file mode 100644 index 000000000..33906432c --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionService.java @@ -0,0 +1,222 @@ +package gg.agit.konect.domain.club.service; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import com.google.api.services.drive.Drive; +import com.google.api.services.drive.model.Permission; +import com.google.auth.oauth2.ServiceAccountCredentials; + +import gg.agit.konect.domain.user.enums.Provider; +import gg.agit.konect.domain.user.repository.UserOAuthAccountRepository; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.infrastructure.googlesheets.GoogleSheetsConfig; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GoogleSheetPermissionService { + + private static final int PERMISSION_APPLY_MAX_ATTEMPTS = 2; + + private final ServiceAccountCredentials serviceAccountCredentials; + private final GoogleSheetsConfig googleSheetsConfig; + private final UserOAuthAccountRepository userOAuthAccountRepository; + + public boolean tryGrantServiceAccountWriterAccess(Integer requesterId, String spreadsheetId) { + String refreshToken = userOAuthAccountRepository + .findByUserIdAndProvider(requesterId, Provider.GOOGLE) + .map(account -> account.getGoogleDriveRefreshToken()) + .filter(StringUtils::hasText) + .orElse(null); + + if (!StringUtils.hasText(refreshToken)) { + log.warn( + "Skipping service account auto-share because Google Drive OAuth is not connected. requesterId={}", + requesterId + ); + return false; + } + + Drive userDriveService; + try { + userDriveService = googleSheetsConfig.buildUserDriveService(refreshToken); + } catch (IOException | GeneralSecurityException e) { + log.error("Failed to build user Drive service. requesterId={}", requesterId, e); + throw CustomException.of(ApiResponseCode.FAILED_INIT_GOOGLE_DRIVE); + } + + try { + ensureServiceAccountPermission(userDriveService, spreadsheetId, "writer"); + return true; + } catch (IOException e) { + if (GoogleSheetApiExceptionHelper.isAccessDenied(e) + || GoogleSheetApiExceptionHelper.isAuthFailure(e) + || GoogleSheetApiExceptionHelper.isNotFound(e)) { + log.warn( + "Failed to auto-share spreadsheet with service account. requesterId={}, spreadsheetId={}, cause={}", + requesterId, + spreadsheetId, + e.getMessage() + ); + return false; + } + + log.error( + "Unexpected error while auto-sharing spreadsheet. requesterId={}, spreadsheetId={}", + requesterId, + spreadsheetId, + e + ); + throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); + } + } + + private void ensureServiceAccountPermission( + Drive userDriveService, + String fileId, + String targetRole + ) throws IOException { + String serviceAccountEmail = getServiceAccountEmail(); + + for (int attempt = 1; attempt <= PERMISSION_APPLY_MAX_ATTEMPTS; attempt++) { + try { + applyServiceAccountPermission( + userDriveService, + fileId, + targetRole, + serviceAccountEmail + ); + return; + } catch (IOException e) { + if (hasRequiredPermission( + userDriveService, + fileId, + serviceAccountEmail, + targetRole + )) { + log.info( + "Service account permission reached target role after retry. fileId={}, role={}, email={}", + fileId, + targetRole, + serviceAccountEmail + ); + return; + } + + if (attempt == PERMISSION_APPLY_MAX_ATTEMPTS) { + throw e; + } + } + } + } + + private void applyServiceAccountPermission( + Drive userDriveService, + String fileId, + String targetRole, + String serviceAccountEmail + ) throws IOException { + Permission existingPermission = findServiceAccountPermission( + userDriveService, + fileId, + serviceAccountEmail + ); + + if (existingPermission == null) { + Permission permission = new Permission() + .setType("user") + .setRole(targetRole) + .setEmailAddress(serviceAccountEmail); + + userDriveService.permissions().create(fileId, permission) + .setSendNotificationEmail(false) + .execute(); + log.info( + "Service account access granted. fileId={}, role={}, email={}", + fileId, + targetRole, + serviceAccountEmail + ); + return; + } + + String currentRole = existingPermission.getRole(); + if (GoogleDrivePermissionHelper.hasRequiredRole(currentRole, targetRole)) { + log.info( + "Service account permission already satisfies requested role. fileId={}, role={}, email={}", + fileId, + currentRole, + serviceAccountEmail + ); + return; + } + + Permission updatedPermission = new Permission().setRole(targetRole); + userDriveService.permissions().update(fileId, existingPermission.getId(), updatedPermission) + .execute(); + log.info( + "Service account permission upgraded. fileId={}, fromRole={}, toRole={}, email={}", + fileId, + currentRole, + targetRole, + serviceAccountEmail + ); + } + + private boolean hasRequiredPermission( + Drive userDriveService, + String fileId, + String serviceAccountEmail, + String targetRole + ) { + try { + Permission currentPermission = findServiceAccountPermission( + userDriveService, + fileId, + serviceAccountEmail + ); + return currentPermission != null + && GoogleDrivePermissionHelper.hasRequiredRole(currentPermission.getRole(), targetRole); + } catch (IOException e) { + log.debug( + "Failed to re-check service account permission. fileId={}, email={}, cause={}", + fileId, + serviceAccountEmail, + e.getMessage() + ); + return false; + } + } + + private Permission findServiceAccountPermission( + Drive userDriveService, + String fileId, + String serviceAccountEmail + ) throws IOException { + List permissions = userDriveService.permissions().list(fileId) + .setFields("permissions(id,emailAddress,role)") + .execute() + .getPermissions(); + + if (permissions == null) { + return null; + } + + return permissions.stream() + .filter(permission -> serviceAccountEmail.equals(permission.getEmailAddress())) + .findFirst() + .orElse(null); + } + + private String getServiceAccountEmail() { + return serviceAccountCredentials.getClientEmail(); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java b/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java index 5b10c997b..44c33c309 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java @@ -19,6 +19,7 @@ import com.google.api.services.sheets.v4.model.ValueRange; import gg.agit.konect.domain.club.model.SheetColumnMapping; +import gg.agit.konect.global.exception.CustomException; import gg.agit.konect.infrastructure.claude.config.ClaudeProperties; import lombok.extern.slf4j.Slf4j; @@ -66,6 +67,8 @@ public SheetAnalysisResult analyzeAllSheets(String spreadsheetId) { try { return inferAllMappings(spreadsheetId, sheets); + } catch (CustomException e) { + throw e; } catch (Exception e) { log.warn("Sheet analysis failed, using default. cause={}", e.getMessage()); return new SheetAnalysisResult(SheetColumnMapping.defaultMapping(), null, null); @@ -90,6 +93,14 @@ private List readAllSheets(String spreadsheetId) { return result; } catch (IOException e) { + if (GoogleSheetApiExceptionHelper.isAccessDenied(e)) { + log.warn( + "Google Sheets access denied while reading spreadsheet info. spreadsheetId={}, cause={}", + spreadsheetId, + e.getMessage() + ); + throw GoogleSheetApiExceptionHelper.accessDenied(); + } log.error("Failed to read spreadsheet info. spreadsheetId={}", spreadsheetId, e); return List.of(); } @@ -118,6 +129,15 @@ private List> readSheetRows(String spreadsheetId, String sheetTitle return rows; } catch (IOException e) { + if (GoogleSheetApiExceptionHelper.isAccessDenied(e)) { + log.warn( + "Google Sheets access denied while reading sheet rows. spreadsheetId={}, sheetTitle={}, cause={}", + spreadsheetId, + sheetTitle, + e.getMessage() + ); + throw GoogleSheetApiExceptionHelper.accessDenied(); + } log.warn("Failed to read rows from sheet '{}'. cause={}", sheetTitle, e.getMessage()); return List.of(); } diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java b/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java index d81b43905..45e10e63d 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java @@ -243,6 +243,14 @@ private List> readDataRows(String spreadsheetId, SheetColumnMapping return values != null ? values : List.of(); } catch (IOException e) { + if (GoogleSheetApiExceptionHelper.isAccessDenied(e)) { + log.warn( + "Google Sheets access denied while reading sheet data. spreadsheetId={}, cause={}", + spreadsheetId, + e.getMessage() + ); + throw GoogleSheetApiExceptionHelper.accessDenied(); + } log.error("Failed to read sheet data. spreadsheetId={}", spreadsheetId, e); throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); } diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java b/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java index c94393097..616d3873f 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java @@ -22,7 +22,6 @@ import com.google.api.services.drive.model.Permission; import com.google.api.services.sheets.v4.Sheets; import com.google.api.services.sheets.v4.model.ValueRange; -import com.google.auth.oauth2.GoogleCredentials; import com.google.auth.oauth2.ServiceAccountCredentials; import gg.agit.konect.domain.club.model.Club; @@ -53,7 +52,7 @@ public class SheetMigrationService { private String defaultTemplateSpreadsheetId; private final Sheets googleSheetsService; - private final GoogleCredentials googleCredentials; + private final ServiceAccountCredentials serviceAccountCredentials; private final SheetHeaderMapper sheetHeaderMapper; private final ClubRepository clubRepository; private final UserOAuthAccountRepository userOAuthAccountRepository; @@ -96,9 +95,12 @@ public String migrateToTemplate( String folderId = resolveFolderId(userDriveService, sourceSpreadsheetUrl, sourceSpreadsheetId); // 소스 파일에 서비스 계정 reader 권한을 먼저 부여해야 readAllData()가 성공함 - grantServiceAccountReadAccess(userDriveService, sourceSpreadsheetId); - // 트랜잭션 실패 / 완료 후 소스 파일 서비스 계정 권한 제거 (보상 처리) - registerSourceFilePermissionCleanup(userDriveService, sourceSpreadsheetId); + GoogleDrivePermissionHelper.PermissionApplyStatus sourcePermissionStatus = + grantServiceAccountReadAccess(userDriveService, sourceSpreadsheetId); + // 트랜잭션 실패 / 완료 후 이번 요청에서 추가한 소스 파일 권한만 정리한다. + if (sourcePermissionStatus == GoogleDrivePermissionHelper.PermissionApplyStatus.CREATED) { + registerSourceFilePermissionCleanup(userDriveService, sourceSpreadsheetId); + } String newSpreadsheetId = copyTemplate(userDriveService, templateId, club.getName(), folderId); registerDriveRollback(userDriveService, newSpreadsheetId); @@ -141,8 +143,11 @@ public String migrateToTemplate( * 소스 파일에 서비스 계정 reader 권한을 부여합니다. * migrate 시 서비스 계정 Sheets API로 소스 데이터를 읽어야 하므로 필요합니다. */ - private void grantServiceAccountReadAccess(Drive userDriveService, String fileId) { - grantServiceAccountPermission(userDriveService, fileId, "reader"); + private GoogleDrivePermissionHelper.PermissionApplyStatus grantServiceAccountReadAccess( + Drive userDriveService, + String fileId + ) { + return grantServiceAccountPermission(userDriveService, fileId, "reader"); } /** @@ -159,10 +164,7 @@ public void afterCompletion(int status) { } private void removeServiceAccountPermission(Drive driveService, String fileId) { - if (!(googleCredentials instanceof ServiceAccountCredentials sac)) { - return; - } - String serviceAccountEmail = sac.getClientEmail(); + String serviceAccountEmail = serviceAccountCredentials.getClientEmail(); try { // permissionId 조회 후 삭제 (getPermissions()는 빈 경우 null 반환 가능) List permissions = @@ -205,33 +207,114 @@ private void grantServiceAccountAccess(Drive userDriveService, String fileId) { /** * 서비스 계정에 지정된 role로 Drive 접근 권한을 부여하는 공통 메서드입니다. */ - private void grantServiceAccountPermission(Drive userDriveService, String fileId, String role) { - if (!(googleCredentials instanceof ServiceAccountCredentials sac)) { - throw new IllegalStateException( - "Google credentials is not a ServiceAccountCredentials. actual type=" - + googleCredentials.getClass().getName() + private GoogleDrivePermissionHelper.PermissionApplyStatus grantServiceAccountPermission( + Drive userDriveService, + String fileId, + String role + ) { + try { + return ensureServiceAccountPermission(userDriveService, fileId, role); + } catch (IOException e) { + if (GoogleSheetApiExceptionHelper.isAuthFailure(e)) { + log.warn( + "Google Drive auth failed while granting service account permission. " + + "fileId={}, role={}, cause={}", + fileId, + role, + e.getMessage() + ); + throw CustomException.of(ApiResponseCode.FAILED_INIT_GOOGLE_DRIVE); + } + if (GoogleSheetApiExceptionHelper.isAccessDenied(e)) { + log.warn( + "Google Sheets access denied while granting service account permission. " + + "fileId={}, role={}, cause={}", + fileId, + role, + e.getMessage() + ); + throw GoogleSheetApiExceptionHelper.accessDenied(); + } + log.error( + "Failed to grant service account {} access. fileId={}, cause={}", + role, fileId, e.getMessage(), e ); + throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); } - String serviceAccountEmail = sac.getClientEmail(); - try { + } + + private GoogleDrivePermissionHelper.PermissionApplyStatus ensureServiceAccountPermission( + Drive userDriveService, + String fileId, + String targetRole + ) throws IOException { + String serviceAccountEmail = serviceAccountCredentials.getClientEmail(); + Permission existingPermission = findServiceAccountPermission( + userDriveService, + fileId, + serviceAccountEmail + ); + + if (existingPermission == null) { Permission permission = new Permission() .setType("user") - .setRole(role) + .setRole(targetRole) .setEmailAddress(serviceAccountEmail); userDriveService.permissions().create(fileId, permission) .setSendNotificationEmail(false) .execute(); log.info( "Service account {} access granted. fileId={}, email={}", - role, fileId, serviceAccountEmail + targetRole, + fileId, + serviceAccountEmail ); - } catch (IOException e) { - log.error( - "Failed to grant service account {} access. fileId={}, cause={}", - role, fileId, e.getMessage(), e + return GoogleDrivePermissionHelper.PermissionApplyStatus.CREATED; + } + + String currentRole = existingPermission.getRole(); + if (GoogleDrivePermissionHelper.hasRequiredRole(currentRole, targetRole)) { + log.info( + "Service account permission already satisfies requested role. fileId={}, role={}, email={}", + fileId, + currentRole, + serviceAccountEmail ); - throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); + return GoogleDrivePermissionHelper.PermissionApplyStatus.UNCHANGED; } + + Permission updatedPermission = new Permission().setRole(targetRole); + userDriveService.permissions().update(fileId, existingPermission.getId(), updatedPermission) + .execute(); + log.info( + "Service account permission upgraded. fileId={}, fromRole={}, toRole={}, email={}", + fileId, + currentRole, + targetRole, + serviceAccountEmail + ); + return GoogleDrivePermissionHelper.PermissionApplyStatus.UPGRADED; + } + + private Permission findServiceAccountPermission( + Drive userDriveService, + String fileId, + String serviceAccountEmail + ) throws IOException { + List permissions = userDriveService.permissions().list(fileId) + .setFields("permissions(id,type,emailAddress,role)") + .execute() + .getPermissions(); + + if (permissions == null) { + return null; + } + + return permissions.stream() + .filter(permission -> "user".equals(permission.getType())) + .filter(permission -> serviceAccountEmail.equals(permission.getEmailAddress())) + .findFirst() + .orElse(null); } private void registerDriveRollback(Drive driveService, String fileId) { @@ -331,6 +414,24 @@ private String copyTemplate(Drive driveService, String templateId, String clubNa if (newFileId != null) { deleteFile(driveService, newFileId); } + if (GoogleSheetApiExceptionHelper.isAuthFailure(e)) { + log.warn( + "Google Drive auth failed while copying template. templateId={}, targetFolderId={}, cause={}", + templateId, + targetFolderId, + e.getMessage() + ); + throw CustomException.of(ApiResponseCode.FAILED_INIT_GOOGLE_DRIVE); + } + if (GoogleSheetApiExceptionHelper.isAccessDenied(e)) { + log.warn( + "Google Sheets access denied while copying template. templateId={}, targetFolderId={}, cause={}", + templateId, + targetFolderId, + e.getMessage() + ); + throw GoogleSheetApiExceptionHelper.accessDenied(); + } log.error("Failed to copy template. cause={}", e.getMessage(), e); throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); } @@ -352,6 +453,14 @@ private List> readAllData( return values != null ? values : List.of(); } catch (IOException e) { + if (GoogleSheetApiExceptionHelper.isAccessDenied(e)) { + log.warn( + "Google Sheets access denied while reading source data. spreadsheetId={}, cause={}", + spreadsheetId, + e.getMessage() + ); + throw GoogleSheetApiExceptionHelper.accessDenied(); + } log.error("Failed to read source data. cause={}", e.getMessage(), e); throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); } @@ -395,6 +504,14 @@ private void writeToTemplate( ); } catch (IOException e) { + if (GoogleSheetApiExceptionHelper.isAccessDenied(e)) { + log.warn( + "Google Sheets access denied while writing template data. spreadsheetId={}, cause={}", + newSpreadsheetId, + e.getMessage() + ); + throw GoogleSheetApiExceptionHelper.accessDenied(); + } log.error("Failed to write data to template. cause={}", e.getMessage(), e); throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); } diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java index 5b58d63d8..e36759cdb 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java @@ -8,6 +8,7 @@ import java.util.List; import java.util.Map; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -28,6 +29,7 @@ import com.google.api.services.sheets.v4.model.UpdateSheetPropertiesRequest; import com.google.api.services.sheets.v4.model.ValueRange; +import gg.agit.konect.domain.club.event.SheetSyncFailedEvent; import gg.agit.konect.domain.club.enums.ClubSheetSortKey; import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.ClubMember; @@ -56,6 +58,7 @@ public class SheetSyncExecutor { private final ClubRepository clubRepository; private final ClubMemberRepository clubMemberRepository; private final ObjectMapper objectMapper; + private final ApplicationEventPublisher applicationEventPublisher; @Async("sheetSyncTaskExecutor") @Transactional(readOnly = true) @@ -79,10 +82,25 @@ public void executeWithSort(Integer clubId, ClubSheetSortKey sortKey, boolean as } log.info("Sheet sync done. clubId={}, members={}", clubId, members.size()); } catch (IOException e) { + if (GoogleSheetApiExceptionHelper.isAccessDenied(e)) { + log.warn( + "Google Sheets access denied during sheet sync. clubId={}, spreadsheetId={}, cause={}", + clubId, + spreadsheetId, + e.getMessage() + ); + applicationEventPublisher.publishEvent( + SheetSyncFailedEvent.accessDenied(clubId, spreadsheetId, e.getMessage()) + ); + return; + } log.error( "Sheet sync failed. clubId={}, spreadsheetId={}, cause={}", clubId, spreadsheetId, e.getMessage(), e ); + applicationEventPublisher.publishEvent( + SheetSyncFailedEvent.unexpected(clubId, spreadsheetId, e.getMessage()) + ); } } diff --git a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java index a39b6598c..f07ebadf5 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -68,6 +68,8 @@ public enum ApiResponseCode { FORBIDDEN_MEMBER_POSITION_CHANGE(HttpStatus.FORBIDDEN, "회원 직책 변경 권한이 없습니다."), FORBIDDEN_POSITION_NAME_CHANGE(HttpStatus.FORBIDDEN, "해당 직책의 이름은 변경할 수 없습니다."), FORBIDDEN_ROLE_ACCESS(HttpStatus.FORBIDDEN, "접근 권한이 없습니다."), + FORBIDDEN_GOOGLE_SHEET_ACCESS(HttpStatus.FORBIDDEN, + "구글 스프레드시트 접근 권한이 없습니다. 서비스 계정을 공유하거나 Google Drive 권한을 연결한 뒤 다시 시도해 주세요."), FORBIDDEN_ORIGIN_ACCESS(HttpStatus.FORBIDDEN, "허용되지 않은 Origin 입니다."), // 404 Not Found (리소스를 찾을 수 없음) diff --git a/src/main/java/gg/agit/konect/global/exception/CustomException.java b/src/main/java/gg/agit/konect/global/exception/CustomException.java index 777c17211..ee19d0a19 100644 --- a/src/main/java/gg/agit/konect/global/exception/CustomException.java +++ b/src/main/java/gg/agit/konect/global/exception/CustomException.java @@ -27,7 +27,7 @@ public static CustomException of(ApiResponseCode errorCode, String detail) { } public String getFullMessage() { - if (StringUtils.hasText(detail)) { + if (!StringUtils.hasText(detail)) { return super.getMessage(); } return String.format("%s: %s", getMessage(), detail); diff --git a/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java index 1e4ed4269..52cee7d50 100644 --- a/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java +++ b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java @@ -10,6 +10,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; @@ -21,6 +22,7 @@ import com.google.api.services.sheets.v4.SheetsScopes; import com.google.auth.http.HttpCredentialsAdapter; import com.google.auth.oauth2.GoogleCredentials; +import com.google.auth.oauth2.ServiceAccountCredentials; import com.google.auth.oauth2.UserCredentials; import lombok.RequiredArgsConstructor; @@ -33,16 +35,30 @@ public class GoogleSheetsConfig { private final ResourceLoader resourceLoader; @Bean - public GoogleCredentials googleCredentials() throws IOException { + public ServiceAccountCredentials serviceAccountCredentials() throws IOException { try (InputStream in = openCredentialsStream()) { - return GoogleCredentials.fromStream(in) - .createScoped(Arrays.asList( - SheetsScopes.SPREADSHEETS, - DriveScopes.DRIVE - )); + GoogleCredentials credentials = GoogleCredentials.fromStream(in); + if (!(credentials instanceof ServiceAccountCredentials serviceAccountCredentials)) { + throw new IllegalStateException( + "Google credentials must be ServiceAccountCredentials. actual type=" + + credentials.getClass().getName() + ); + } + return serviceAccountCredentials; } } + @Bean + @Primary + public GoogleCredentials googleCredentials( + ServiceAccountCredentials serviceAccountCredentials + ) { + return serviceAccountCredentials.createScoped(Arrays.asList( + SheetsScopes.SPREADSHEETS, + DriveScopes.DRIVE + )); + } + @Bean public Sheets googleSheetsService( GoogleCredentials googleCredentials diff --git a/src/test/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedServiceTest.java b/src/test/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedServiceTest.java index 873403e9a..700737c68 100644 --- a/src/test/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedServiceTest.java +++ b/src/test/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedServiceTest.java @@ -1,8 +1,10 @@ package gg.agit.konect.domain.club.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.verifyNoInteractions; import java.util.List; @@ -14,6 +16,8 @@ import gg.agit.konect.domain.club.dto.SheetImportResponse; import gg.agit.konect.domain.club.model.SheetColumnMapping; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; import gg.agit.konect.support.ServiceTestSupport; class ClubSheetIntegratedServiceTest extends ServiceTestSupport { @@ -24,6 +28,9 @@ class ClubSheetIntegratedServiceTest extends ServiceTestSupport { @Mock private SheetHeaderMapper sheetHeaderMapper; + @Mock + private GoogleSheetPermissionService googleSheetPermissionService; + @Mock private ClubMemberSheetService clubMemberSheetService; @@ -46,6 +53,8 @@ void analyzeAndImportPreMembersSuccess() { new SheetHeaderMapper.SheetAnalysisResult(SheetColumnMapping.defaultMapping(), null, null); SheetImportResponse expected = SheetImportResponse.of(3, 1, List.of("warn")); + given(googleSheetPermissionService.tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId)) + .willReturn(true); given(sheetHeaderMapper.analyzeAllSheets(spreadsheetId)).willReturn(analysis); given(sheetImportService.importPreMembersFromSheet( clubId, @@ -65,11 +74,14 @@ void analyzeAndImportPreMembersSuccess() { // then InOrder inOrder = inOrder( clubPermissionValidator, + googleSheetPermissionService, sheetHeaderMapper, clubMemberSheetService, sheetImportService ); inOrder.verify(clubPermissionValidator).validateManagerAccess(clubId, requesterId); + inOrder.verify(googleSheetPermissionService) + .tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId); inOrder.verify(sheetHeaderMapper).analyzeAllSheets(spreadsheetId); inOrder.verify(clubMemberSheetService).updateSheetId( clubId, @@ -85,4 +97,86 @@ void analyzeAndImportPreMembersSuccess() { ); assertThat(actual).isEqualTo(expected); } + + @Test + @DisplayName("자동 권한 부여가 실패해도 기존 공유 권한으로 가져오기를 계속 시도한다") + void analyzeAndImportPreMembersContinuesWhenAutoGrantFails() { + // given + Integer clubId = 1; + Integer requesterId = 2; + String spreadsheetUrl = + "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms/edit"; + String spreadsheetId = "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms"; + SheetHeaderMapper.SheetAnalysisResult analysis = + new SheetHeaderMapper.SheetAnalysisResult(SheetColumnMapping.defaultMapping(), null, null); + SheetImportResponse expected = SheetImportResponse.of(1, 0, List.of()); + + given(googleSheetPermissionService.tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId)) + .willReturn(false); + given(sheetHeaderMapper.analyzeAllSheets(spreadsheetId)).willReturn(analysis); + given(sheetImportService.importPreMembersFromSheet( + clubId, + requesterId, + spreadsheetId, + analysis.memberListMapping() + )) + .willReturn(expected); + + // when + SheetImportResponse actual = clubSheetIntegratedService.analyzeAndImportPreMembers( + clubId, + requesterId, + spreadsheetUrl + ); + + // then + InOrder inOrder = inOrder( + clubPermissionValidator, + googleSheetPermissionService, + sheetHeaderMapper, + clubMemberSheetService, + sheetImportService + ); + inOrder.verify(clubPermissionValidator).validateManagerAccess(clubId, requesterId); + inOrder.verify(googleSheetPermissionService) + .tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId); + inOrder.verify(sheetHeaderMapper).analyzeAllSheets(spreadsheetId); + inOrder.verify(clubMemberSheetService).updateSheetId( + clubId, + requesterId, + spreadsheetId, + analysis + ); + inOrder.verify(sheetImportService).importPreMembersFromSheet( + clubId, + requesterId, + spreadsheetId, + analysis.memberListMapping() + ); + assertThat(actual).isEqualTo(expected); + } + + @Test + @DisplayName("자동 권한 부여 중 예외가 발생하면 후속 시트 작업을 진행하지 않는다") + void analyzeAndImportPreMembersStopsWhenAutoGrantThrowsException() { + // given + Integer clubId = 1; + Integer requesterId = 2; + String spreadsheetUrl = + "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms/edit"; + String spreadsheetId = "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms"; + CustomException expected = CustomException.of(ApiResponseCode.FAILED_INIT_GOOGLE_DRIVE); + + given(googleSheetPermissionService.tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId)) + .willThrow(expected); + + // when & then + assertThatThrownBy(() -> clubSheetIntegratedService.analyzeAndImportPreMembers( + clubId, + requesterId, + spreadsheetUrl + )) + .isSameAs(expected); + verifyNoInteractions(sheetHeaderMapper, clubMemberSheetService, sheetImportService); + } } diff --git a/src/test/java/gg/agit/konect/domain/club/service/GoogleApiTestUtils.java b/src/test/java/gg/agit/konect/domain/club/service/GoogleApiTestUtils.java new file mode 100644 index 000000000..8cb42187a --- /dev/null +++ b/src/test/java/gg/agit/konect/domain/club/service/GoogleApiTestUtils.java @@ -0,0 +1,38 @@ +package gg.agit.konect.domain.club.service; + +import java.util.List; + +import com.google.api.client.googleapis.json.GoogleJsonError; +import com.google.api.client.googleapis.json.GoogleJsonResponseException; +import com.google.api.client.http.HttpHeaders; +import com.google.api.client.http.HttpResponseException; + +final class GoogleApiTestUtils { + + private GoogleApiTestUtils() {} + + static GoogleJsonResponseException googleException(int statusCode, String reason) { + GoogleJsonError.ErrorInfo errorInfo = new GoogleJsonError.ErrorInfo(); + errorInfo.setReason(reason); + + GoogleJsonError error = new GoogleJsonError(); + error.setCode(statusCode); + error.setErrors(List.of(errorInfo)); + + HttpResponseException.Builder builder = new HttpResponseException.Builder( + statusCode, + null, + new HttpHeaders() + ); + + return new GoogleJsonResponseException(builder, error); + } + + static HttpResponseException httpResponseException(int statusCode) { + return new HttpResponseException.Builder( + statusCode, + null, + new HttpHeaders() + ).build(); + } +} diff --git a/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetApiExceptionHelperTest.java b/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetApiExceptionHelperTest.java new file mode 100644 index 000000000..644b6da78 --- /dev/null +++ b/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetApiExceptionHelperTest.java @@ -0,0 +1,84 @@ +package gg.agit.konect.domain.club.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static gg.agit.konect.domain.club.service.GoogleApiTestUtils.googleException; +import static gg.agit.konect.domain.club.service.GoogleApiTestUtils.httpResponseException; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.google.api.client.googleapis.json.GoogleJsonError; +import com.google.api.client.googleapis.json.GoogleJsonResponseException; +import com.google.api.client.http.HttpHeaders; +import com.google.api.client.http.HttpResponseException; + +class GoogleSheetApiExceptionHelperTest { + + @Test + @DisplayName("classifies only permission-related 403 reasons as access denied") + void isAccessDeniedReturnsTrueOnlyForPermissionReasons() { + GoogleJsonResponseException permissionDenied = + googleException(403, "insufficientPermissions"); + GoogleJsonResponseException accessDenied = + googleException(403, "accessDenied"); + GoogleJsonResponseException forbidden = + googleException(403, "forbidden"); + GoogleJsonResponseException quotaExceeded = + googleException(403, "quotaExceeded"); + + assertThat(GoogleSheetApiExceptionHelper.isAccessDenied(permissionDenied)).isTrue(); + assertThat(GoogleSheetApiExceptionHelper.isAccessDenied(accessDenied)).isTrue(); + assertThat(GoogleSheetApiExceptionHelper.isAccessDenied(forbidden)).isTrue(); + assertThat(GoogleSheetApiExceptionHelper.isAccessDenied(quotaExceeded)).isFalse(); + } + + @Test + @DisplayName("returns false for 403 responses without a reason") + void isAccessDeniedReturnsFalseWhenReasonIsMissing() { + GoogleJsonError error = new GoogleJsonError(); + error.setCode(403); + error.setErrors(List.of()); + + HttpResponseException.Builder builder = new HttpResponseException.Builder( + 403, + null, + new HttpHeaders() + ); + + GoogleJsonResponseException exception = new GoogleJsonResponseException(builder, error); + + assertThat(GoogleSheetApiExceptionHelper.isAccessDenied(exception)).isFalse(); + } + + @Test + @DisplayName("classifies auth-related 401 as auth failure") + void isAuthFailureReturnsTrueForAuthReasons() { + GoogleJsonResponseException authFailure = + googleException(401, "authError"); + + assertThat(GoogleSheetApiExceptionHelper.isAuthFailure(authFailure)).isTrue(); + } + + @Test + @DisplayName("classifies 404 as not found") + void isNotFoundReturnsTrueFor404() { + GoogleJsonResponseException notFound = + googleException(404, "notFound"); + + assertThat(GoogleSheetApiExceptionHelper.isNotFound(notFound)).isTrue(); + } + + @Test + @DisplayName("classifies non-json http response exceptions with their status code") + void classifiesNonJsonHttpResponseExceptionByStatusCode() { + HttpResponseException forbidden = httpResponseException(403); + HttpResponseException unauthorized = httpResponseException(401); + HttpResponseException notFound = httpResponseException(404); + + assertThat(GoogleSheetApiExceptionHelper.isAccessDenied(forbidden)).isTrue(); + assertThat(GoogleSheetApiExceptionHelper.isAuthFailure(unauthorized)).isTrue(); + assertThat(GoogleSheetApiExceptionHelper.isNotFound(notFound)).isTrue(); + } +} diff --git a/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionServiceTest.java b/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionServiceTest.java new file mode 100644 index 000000000..552aa8952 --- /dev/null +++ b/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionServiceTest.java @@ -0,0 +1,219 @@ +package gg.agit.konect.domain.club.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static gg.agit.konect.domain.club.service.GoogleApiTestUtils.googleException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import com.google.api.services.drive.Drive; +import com.google.api.services.drive.model.Permission; +import com.google.api.services.drive.model.PermissionList; +import com.google.auth.oauth2.ServiceAccountCredentials; + +import gg.agit.konect.domain.user.enums.Provider; +import gg.agit.konect.domain.user.model.UserOAuthAccount; +import gg.agit.konect.domain.user.repository.UserOAuthAccountRepository; +import gg.agit.konect.infrastructure.googlesheets.GoogleSheetsConfig; +import gg.agit.konect.support.ServiceTestSupport; + +class GoogleSheetPermissionServiceTest extends ServiceTestSupport { + + private static final Integer REQUESTER_ID = 1; + private static final String FILE_ID = "spreadsheet-id"; + private static final String REFRESH_TOKEN = "refresh-token"; + private static final String SERVICE_ACCOUNT_EMAIL = "service-account@konect.iam.gserviceaccount.com"; + + @Mock + private ServiceAccountCredentials serviceAccountCredentials; + + @Mock + private GoogleSheetsConfig googleSheetsConfig; + + @Mock + private UserOAuthAccountRepository userOAuthAccountRepository; + + @Mock + private UserOAuthAccount userOAuthAccount; + + @Mock + private Drive userDriveService; + + @Mock + private Drive.Permissions permissions; + + @Mock + private Drive.Permissions.List listRequest; + + @Mock + private Drive.Permissions.Create createRequest; + + @Mock + private Drive.Permissions.Update updateRequest; + + @InjectMocks + private GoogleSheetPermissionService googleSheetPermissionService; + + @Test + @DisplayName("returns false when the requester has no Google Drive OAuth account") + void tryGrantServiceAccountWriterAccessReturnsFalseWhenOAuthAccountIsMissing() { + // given + given(userOAuthAccountRepository.findByUserIdAndProvider(REQUESTER_ID, Provider.GOOGLE)) + .willReturn(Optional.empty()); + + // when + boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ); + + // then + assertThat(granted).isFalse(); + } + + @Test + @DisplayName("returns true without creating when the service account already has writer access") + void tryGrantServiceAccountWriterAccessReturnsTrueWhenPermissionAlreadyExists() + throws IOException, GeneralSecurityException { + // given + mockConnectedDriveAccount(); + given(permissions.list(FILE_ID)).willReturn(listRequest); + given(listRequest.setFields("permissions(id,emailAddress,role)")).willReturn(listRequest); + given(listRequest.execute()).willReturn(permissionList(permission("perm-1", "writer"))); + + // when + boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ); + + // then + assertThat(granted).isTrue(); + verify(permissions, never()).create(eq(FILE_ID), any(Permission.class)); + verify(permissions, never()).update(eq(FILE_ID), eq("perm-1"), any(Permission.class)); + } + + @Test + @DisplayName("returns true when create fails but the permission is visible on re-check") + void tryGrantServiceAccountWriterAccessReturnsTrueAfterConcurrentGrant() + throws IOException, GeneralSecurityException { + // given + mockConnectedDriveAccount(); + given(permissions.list(FILE_ID)).willReturn(listRequest); + given(listRequest.setFields("permissions(id,emailAddress,role)")).willReturn(listRequest); + given(listRequest.execute()).willReturn( + permissionList(), + permissionList(permission("perm-1", "writer")) + ); + given(permissions.create(eq(FILE_ID), any(Permission.class))).willReturn(createRequest); + given(createRequest.setSendNotificationEmail(false)).willReturn(createRequest); + given(createRequest.execute()).willThrow(new IOException("already granted")); + + // when + boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ); + + // then + assertThat(granted).isTrue(); + verify(permissions).create(eq(FILE_ID), any(Permission.class)); + } + + @Test + @DisplayName("returns true when an existing permission needs to be upgraded to writer") + void tryGrantServiceAccountWriterAccessUpgradesExistingPermission() + throws IOException, GeneralSecurityException { + // given + mockConnectedDriveAccount(); + given(permissions.list(FILE_ID)).willReturn(listRequest); + given(listRequest.setFields("permissions(id,emailAddress,role)")).willReturn(listRequest); + given(listRequest.execute()).willReturn(permissionList(permission("perm-x", "reader"))); + given(permissions.update(eq(FILE_ID), eq("perm-x"), any(Permission.class))).willReturn(updateRequest); + given(updateRequest.execute()).willReturn(permission("perm-x", "writer")); + + // when + boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ); + + // then + assertThat(granted).isTrue(); + verify(permissions).update(eq(FILE_ID), eq("perm-x"), any(Permission.class)); + } + + @Test + @DisplayName("returns false when Google Drive auth fails during permission lookup") + void tryGrantServiceAccountWriterAccessReturnsFalseWhenAuthFails() + throws IOException, GeneralSecurityException { + // given + mockConnectedDriveAccount(); + given(permissions.list(FILE_ID)).willReturn(listRequest); + given(listRequest.setFields("permissions(id,emailAddress,role)")).willReturn(listRequest); + given(listRequest.execute()).willThrow(googleException(401, "authError")); + + // when + boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ); + + // then + assertThat(granted).isFalse(); + } + + @Test + @DisplayName("returns false when Google Drive reports access denied while listing permissions") + void tryGrantServiceAccountWriterAccessReturnsFalseWhenAccessIsDenied() + throws IOException, GeneralSecurityException { + // given + mockConnectedDriveAccount(); + given(permissions.list(FILE_ID)).willReturn(listRequest); + given(listRequest.setFields("permissions(id,emailAddress,role)")).willReturn(listRequest); + given(listRequest.execute()).willThrow(googleException(403, "forbidden")); + + // when + boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ); + + // then + assertThat(granted).isFalse(); + } + + private void mockConnectedDriveAccount() throws IOException, GeneralSecurityException { + given(userOAuthAccountRepository.findByUserIdAndProvider(REQUESTER_ID, Provider.GOOGLE)) + .willReturn(Optional.of(userOAuthAccount)); + given(userOAuthAccount.getGoogleDriveRefreshToken()).willReturn(REFRESH_TOKEN); + given(googleSheetsConfig.buildUserDriveService(REFRESH_TOKEN)).willReturn(userDriveService); + given(serviceAccountCredentials.getClientEmail()).willReturn(SERVICE_ACCOUNT_EMAIL); + given(userDriveService.permissions()).willReturn(permissions); + } + + private Permission permission(String id, String role) { + return new Permission() + .setId(id) + .setType("user") + .setEmailAddress(SERVICE_ACCOUNT_EMAIL) + .setRole(role); + } + + private PermissionList permissionList(Permission... permissions) { + return new PermissionList().setPermissions(List.of(permissions)); + } + +} diff --git a/src/test/java/gg/agit/konect/domain/club/service/SheetSyncExecutorTest.java b/src/test/java/gg/agit/konect/domain/club/service/SheetSyncExecutorTest.java new file mode 100644 index 000000000..1ac3d45f0 --- /dev/null +++ b/src/test/java/gg/agit/konect/domain/club/service/SheetSyncExecutorTest.java @@ -0,0 +1,108 @@ +package gg.agit.konect.domain.club.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.context.ApplicationEventPublisher; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.api.client.googleapis.json.GoogleJsonError; +import com.google.api.client.googleapis.json.GoogleJsonResponseException; +import com.google.api.client.http.HttpHeaders; +import com.google.api.client.http.HttpResponseException; +import com.google.api.services.sheets.v4.Sheets; +import com.google.api.services.sheets.v4.model.ClearValuesRequest; + +import gg.agit.konect.domain.club.enums.ClubSheetSortKey; +import gg.agit.konect.domain.club.event.SheetSyncFailedEvent; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.UniversityFixture; + +class SheetSyncExecutorTest extends ServiceTestSupport { + + @Mock + private Sheets googleSheetsService; + + @Mock + private Sheets.Spreadsheets spreadsheets; + + @Mock + private Sheets.Spreadsheets.Values values; + + @Mock + private Sheets.Spreadsheets.Values.Clear clearRequest; + + @Mock + private ClubRepository clubRepository; + + @Mock + private ClubMemberRepository clubMemberRepository; + + @Mock + private ObjectMapper objectMapper; + + @Mock + private ApplicationEventPublisher applicationEventPublisher; + + @InjectMocks + private SheetSyncExecutor sheetSyncExecutor; + + @Test + @DisplayName("시트 동기화 권한 오류는 실패 이벤트로 발행한다") + void executeWithSortPublishesFailureEventWhenAccessDenied() throws Exception { + // given + Integer clubId = 1; + String spreadsheetId = "spreadsheet-id"; + Club club = ClubFixture.create(UniversityFixture.create()); + club.updateGoogleSheetId(spreadsheetId); + + given(clubRepository.getById(clubId)).willReturn(club); + given(clubMemberRepository.findAllByClubId(clubId)).willReturn(List.of()); + given(googleSheetsService.spreadsheets()).willReturn(spreadsheets); + given(spreadsheets.values()).willReturn(values); + given(values.clear(eq(spreadsheetId), eq("A:F"), any(ClearValuesRequest.class))) + .willReturn(clearRequest); + given(clearRequest.execute()).willThrow(googleException(403, "accessDenied")); + + // when + sheetSyncExecutor.executeWithSort(clubId, ClubSheetSortKey.NAME, true); + + // then + verify(applicationEventPublisher).publishEvent(argThat((Object event) -> + event instanceof SheetSyncFailedEvent sheetSyncFailedEvent + && sheetSyncFailedEvent.clubId().equals(clubId) + && sheetSyncFailedEvent.spreadsheetId().equals(spreadsheetId) + && sheetSyncFailedEvent.accessDenied() + )); + } + + private GoogleJsonResponseException googleException(int statusCode, String reason) { + GoogleJsonError.ErrorInfo errorInfo = new GoogleJsonError.ErrorInfo(); + errorInfo.setReason(reason); + + GoogleJsonError error = new GoogleJsonError(); + error.setCode(statusCode); + error.setErrors(List.of(errorInfo)); + + HttpResponseException.Builder builder = new HttpResponseException.Builder( + statusCode, + null, + new HttpHeaders() + ); + + return new GoogleJsonResponseException(builder, error); + } +} diff --git a/src/test/java/gg/agit/konect/integration/domain/club/ClubSheetMigrationApiTest.java b/src/test/java/gg/agit/konect/integration/domain/club/ClubSheetMigrationApiTest.java index f906a6e33..6adff35e5 100644 --- a/src/test/java/gg/agit/konect/integration/domain/club/ClubSheetMigrationApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/club/ClubSheetMigrationApiTest.java @@ -16,6 +16,8 @@ import gg.agit.konect.domain.club.dto.SheetImportRequest; import gg.agit.konect.domain.club.dto.SheetImportResponse; import gg.agit.konect.domain.club.service.ClubSheetIntegratedService; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; import gg.agit.konect.support.IntegrationTestSupport; class ClubSheetMigrationApiTest extends IntegrationTestSupport { @@ -56,5 +58,26 @@ void analyzeAndImportPreMembersSuccess() throws Exception { .andExpect(jsonPath("$.autoRegisteredCount").value(1)) .andExpect(jsonPath("$.warnings[0]").value("전화번호 형식 경고")); } + + @Test + @DisplayName("구글 스프레드시트 403 오류를 response body로 반환한다") + void analyzeAndImportPreMembersForbiddenGoogleSheetAccess() throws Exception { + // given + given(clubSheetIntegratedService.analyzeAndImportPreMembers( + eq(CLUB_ID), + eq(REQUESTER_ID), + eq(SPREADSHEET_URL) + )).willThrow(CustomException.of(ApiResponseCode.FORBIDDEN_GOOGLE_SHEET_ACCESS)); + + SheetImportRequest request = new SheetImportRequest(SPREADSHEET_URL); + + // when & then + performPost("/clubs/" + CLUB_ID + "/sheet/import/integrated", request) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code") + .value(ApiResponseCode.FORBIDDEN_GOOGLE_SHEET_ACCESS.name())) + .andExpect(jsonPath("$.message") + .value(ApiResponseCode.FORBIDDEN_GOOGLE_SHEET_ACCESS.getMessage())); + } } } diff --git a/src/test/java/gg/agit/konect/support/IntegrationTestSupport.java b/src/test/java/gg/agit/konect/support/IntegrationTestSupport.java index 157a152d6..84b44df82 100644 --- a/src/test/java/gg/agit/konect/support/IntegrationTestSupport.java +++ b/src/test/java/gg/agit/konect/support/IntegrationTestSupport.java @@ -27,6 +27,7 @@ import com.google.api.services.drive.Drive; import com.google.api.services.sheets.v4.Sheets; import com.google.auth.oauth2.GoogleCredentials; +import com.google.auth.oauth2.ServiceAccountCredentials; import jakarta.persistence.EntityManager; @@ -77,6 +78,9 @@ public abstract class IntegrationTestSupport { @MockitoBean protected GoogleCredentials googleCredentials; + @MockitoBean + protected ServiceAccountCredentials serviceAccountCredentials; + @MockitoBean protected Sheets googleSheetsService;