From ee990de93c82697ebf4669b835ee0fa0b58c5f6a Mon Sep 17 00:00:00 2001 From: JK Date: Sat, 4 Apr 2026 00:14:33 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20GCP=20OAuth=20=EB=8F=99=EC=9D=98=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EA=B2=80=EC=A6=9D=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=EB=9E=9C=EB=94=A9=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EB=B0=8F=20=EC=82=AC=EC=9D=B4=ED=8A=B8=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - / 경로에 공개 랜딩 페이지 추가 (Sign In/Sign Up 버튼, 브랜드명 h1, 개인정보 처리방침 링크) - /about 페이지 h1을 동적 브랜드 이름으로 변경 (usePublicAuthConfig) - Google Site Verification HTML 파일 확인 방식 지원 (BrandingController) - system_settings에 google_site_verification 컬럼 추가 (Flyway) - 시스템 설정 일반 수정 API에 googleSiteVerification 필드 추가 - i18n: 랜딩 페이지 키 추가 (en/ko/ja) Co-Authored-By: Claude Opus 4.6 --- ...04040100__add_google_site_verification.sql | 1 + .../deck/iam/controller/BrandingController.kt | 21 ++++ .../iam/controller/SystemSettingController.kt | 2 + .../deck/iam/controller/SystemSettingDtos.kt | 2 + .../io/deck/iam/domain/SystemSettingEntity.kt | 5 + .../deck/iam/service/SystemSettingService.kt | 4 +- frontend/app/src/app/App.tsx | 2 + frontend/app/src/app/app.test.tsx | 3 + .../app/src/entities/system-settings/types.ts | 2 + frontend/app/src/pages/about/about.page.tsx | 16 ++- .../app/src/pages/landing/landing.page.tsx | 109 ++++++++++++++++++ .../src/shared/i18n/locales/en/common.json | 6 + .../src/shared/i18n/locales/ja/common.json | 6 + .../src/shared/i18n/locales/ko/common.json | 6 + 14 files changed, 180 insertions(+), 5 deletions(-) create mode 100644 backend/app/src/main/resources/db/migration/app/V202604040100__add_google_site_verification.sql create mode 100644 frontend/app/src/pages/landing/landing.page.tsx diff --git a/backend/app/src/main/resources/db/migration/app/V202604040100__add_google_site_verification.sql b/backend/app/src/main/resources/db/migration/app/V202604040100__add_google_site_verification.sql new file mode 100644 index 000000000..8a01fcb80 --- /dev/null +++ b/backend/app/src/main/resources/db/migration/app/V202604040100__add_google_site_verification.sql @@ -0,0 +1 @@ +ALTER TABLE system_settings ADD COLUMN google_site_verification VARCHAR(255); diff --git a/backend/iam/src/main/kotlin/io/deck/iam/controller/BrandingController.kt b/backend/iam/src/main/kotlin/io/deck/iam/controller/BrandingController.kt index 78aefa7f2..7b2173c95 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/controller/BrandingController.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/controller/BrandingController.kt @@ -7,6 +7,7 @@ import org.springframework.http.CacheControl import org.springframework.http.MediaType import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController import java.util.concurrent.TimeUnit @@ -31,6 +32,26 @@ class BrandingController( return ResponseEntity.ok(settings.toPublicBrandingDto()) } + /** + * Google Site Verification (HTML 파일 확인 방식) + * Google이 지정하는 /google{token}.html 경로에 인증 텍스트를 응답한다. + */ + @GetMapping("/{filename:google[a-f0-9]+\\.html}") + fun siteVerification( + @PathVariable filename: String, + ): ResponseEntity { + val token = systemSettingService.getSettings().googleSiteVerification + ?: return ResponseEntity.notFound().build() + val expectedFilename = "google$token.html" + if (filename != expectedFilename) { + return ResponseEntity.notFound().build() + } + return ResponseEntity + .ok() + .contentType(MediaType.TEXT_HTML) + .body("google-site-verification: $expectedFilename") + } + /** * 가로 로고 - Light (SVG) */ diff --git a/backend/iam/src/main/kotlin/io/deck/iam/controller/SystemSettingController.kt b/backend/iam/src/main/kotlin/io/deck/iam/controller/SystemSettingController.kt index c124f9857..541825e1e 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/controller/SystemSettingController.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/controller/SystemSettingController.kt @@ -57,6 +57,7 @@ class SystemSettingController( userId = userId, brandName = request.brandName, contactEmail = request.contactEmail, + googleSiteVerification = request.googleSiteVerification, ) return ResponseEntity.ok(settings.toDto(baseUrl)) } @@ -227,6 +228,7 @@ private fun SystemSettingEntity.toDto(baseUrl: String): SystemSettingDto = id = id, brandName = brandName, contactEmail = contactEmail, + googleSiteVerification = googleSiteVerification, logoHorizontalUrl = effectiveSystemLogoUrl(LogoType.HORIZONTAL), logoHorizontalDarkUrl = effectiveSystemLogoUrl(LogoType.HORIZONTAL, dark = true), logoPublicUrl = effectiveSystemLogoUrl(LogoType.PUBLIC), diff --git a/backend/iam/src/main/kotlin/io/deck/iam/controller/SystemSettingDtos.kt b/backend/iam/src/main/kotlin/io/deck/iam/controller/SystemSettingDtos.kt index bcae29ed0..b1d7ce2c8 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/controller/SystemSettingDtos.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/controller/SystemSettingDtos.kt @@ -9,6 +9,7 @@ data class SystemSettingDto( val id: UUID, val brandName: String, val contactEmail: String?, + val googleSiteVerification: String?, val logoHorizontalUrl: String?, val logoHorizontalDarkUrl: String?, val logoPublicUrl: String?, @@ -32,6 +33,7 @@ data class PublicBrandingDto( data class UpdateGeneralSettingsRequest( val brandName: String, val contactEmail: String? = null, + val googleSiteVerification: String? = null, ) data class UpdateWorkspacePolicyRequest( diff --git a/backend/iam/src/main/kotlin/io/deck/iam/domain/SystemSettingEntity.kt b/backend/iam/src/main/kotlin/io/deck/iam/domain/SystemSettingEntity.kt index 237c44cd7..1fc255800 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/domain/SystemSettingEntity.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/domain/SystemSettingEntity.kt @@ -89,6 +89,9 @@ class SystemSettingEntity( @Encrypted @Column(nullable = true, updatable = true, columnDefinition = "TEXT") var oktaClientSecret: String? = null, + /** Google Site Verification 토큰 (HTML 파일 확인 방식) */ + @Column(nullable = true, updatable = true, length = 255) + var googleSiteVerification: String? = null, @Embedded var workspacePolicy: WorkspacePolicy? = WorkspacePolicy(), @Embedded @@ -108,9 +111,11 @@ class SystemSettingEntity( fun update( brandName: String, contactEmail: String?, + googleSiteVerification: String? = null, ) { this.brandName = brandName this.contactEmail = contactEmail?.trim()?.ifBlank { null } + this.googleSiteVerification = googleSiteVerification?.trim()?.ifBlank { null } } fun updateWorkspacePolicy(workspacePolicy: WorkspacePolicy?) { diff --git a/backend/iam/src/main/kotlin/io/deck/iam/service/SystemSettingService.kt b/backend/iam/src/main/kotlin/io/deck/iam/service/SystemSettingService.kt index 128ea60fd..6453d02f2 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/service/SystemSettingService.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/service/SystemSettingService.kt @@ -226,10 +226,11 @@ class SystemSettingService( userId: UUID, brandName: String, contactEmail: String?, + googleSiteVerification: String? = null, ): SystemSettingEntity { requireOwner(userId) val settings = getSettings() - settings.update(brandName = brandName, contactEmail = contactEmail) + settings.update(brandName = brandName, contactEmail = contactEmail, googleSiteVerification = googleSiteVerification) val saved = systemSettingRepository.save(settings) publishActivity( eventType = IamActivityLogType.SYSTEM_SETTINGS_GENERAL_UPDATED, @@ -239,6 +240,7 @@ class SystemSettingService( mapOf( "brandName" to saved.brandName, "contactEmail" to saved.contactEmail, + "googleSiteVerification" to saved.googleSiteVerification, ), ) return saved diff --git a/frontend/app/src/app/App.tsx b/frontend/app/src/app/App.tsx index d8fdc9c2d..b736c1401 100644 --- a/frontend/app/src/app/App.tsx +++ b/frontend/app/src/app/App.tsx @@ -34,6 +34,7 @@ import { AuthorizationProvider } from '#app/shared/authorization'; import { auth } from '#app/features/auth'; import { OverlayProvider } from '#app/shared/overlay'; +const LandingPage = lazy(() => import('#app/pages/landing/landing.page')); const LoginPage = lazy(() => import('#app/pages/login/login.page')); const InvitePage = lazy(() => import('#app/pages/invite/invite.page')); const WorkspaceInvitePage = lazy(() => import('#app/pages/workspace-invite/workspace-invite.page')); @@ -149,6 +150,7 @@ function StandalonePageRoute({ pathname }: { pathname: string }) { } const standaloneRoutes: RouteObject[] = [ + { path: '/', element: withStandaloneFallback() }, { path: '/login/*', element: withStandaloneFallback() }, { path: '/invite/*', element: withStandaloneFallback() }, { path: '/workspace-invite/*', element: withStandaloneFallback() }, diff --git a/frontend/app/src/app/app.test.tsx b/frontend/app/src/app/app.test.tsx index bda89d9fb..3fe4953cf 100644 --- a/frontend/app/src/app/app.test.tsx +++ b/frontend/app/src/app/app.test.tsx @@ -132,6 +132,9 @@ describe('App overlay dismiss 통합', () => { sidebarCollapsed.set(false); isInitialized.set(true); isLoading.set(false); + + // / 경로는 LandingPage를 렌더링하므로 AppShell 테스트는 /dashboard에서 시작 + window.history.replaceState({}, '', '/dashboard'); }); afterEach(() => { diff --git a/frontend/app/src/entities/system-settings/types.ts b/frontend/app/src/entities/system-settings/types.ts index 45db73b45..d3f159991 100644 --- a/frontend/app/src/entities/system-settings/types.ts +++ b/frontend/app/src/entities/system-settings/types.ts @@ -28,6 +28,7 @@ export interface CurrencyPolicy { export interface SystemSettings { brandName: string; contactEmail?: string | null; + googleSiteVerification?: string | null; baseUrl: string; logoHorizontalUrl?: string; logoHorizontalDarkUrl?: string; @@ -63,6 +64,7 @@ export interface AuthResponse { export interface UpdateGeneralSettingsRequest { brandName: string; contactEmail?: string | null; + googleSiteVerification?: string | null; } export interface UpdateWorkspacePolicyRequest { diff --git a/frontend/app/src/pages/about/about.page.tsx b/frontend/app/src/pages/about/about.page.tsx index cd36380f0..77d2027b2 100644 --- a/frontend/app/src/pages/about/about.page.tsx +++ b/frontend/app/src/pages/about/about.page.tsx @@ -1,16 +1,18 @@ import { useTranslation } from '#app/shared/i18n'; +import { usePublicAuthConfig } from '#app/shared/public-auth-config'; import { LegalLinks } from '#app/shared/legal-links/legal-links'; import { StandaloneLayout } from '#app/layouts'; export function AboutPage() { const { t } = useTranslation('common'); + const { brandName } = usePublicAuthConfig(); return (

{t('legal.updated')}

-

{t('legal.about.title')}

+

{brandName}

{t('legal.about.summary')}

@@ -18,7 +20,9 @@ export function AboutPage() {
-

{t('legal.about.sections.identity.title')}

+

+ {t('legal.about.sections.identity.title')} +

{t('legal.about.sections.identity.body')}

@@ -30,13 +34,17 @@ export function AboutPage() {

-

{t('legal.about.sections.calendar.title')}

+

+ {t('legal.about.sections.calendar.title')} +

{t('legal.about.sections.calendar.body')}

-

{t('legal.about.sections.contact.title')}

+

+ {t('legal.about.sections.contact.title')} +

{t('legal.about.sections.contact.body')}

diff --git a/frontend/app/src/pages/landing/landing.page.tsx b/frontend/app/src/pages/landing/landing.page.tsx new file mode 100644 index 000000000..06128649d --- /dev/null +++ b/frontend/app/src/pages/landing/landing.page.tsx @@ -0,0 +1,109 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router'; +import { StandaloneLayout } from '#app/layouts'; +import { Button } from '#app/components/ui/button'; +import { usePublicAuthConfig } from '#app/shared/public-auth-config'; +import { useTranslation } from '#app/shared/i18n'; +import { LegalLinks } from '#app/shared/legal-links/legal-links'; +import { BrandLogo } from '#app/shared/branding'; +import { http } from '#app/shared/http-client'; +import type { CurrentUser } from '#app/entities/user'; + +export function LandingPage() { + const { t } = useTranslation('common'); + const { brandName } = usePublicAuthConfig(); + const navigate = useNavigate(); + const [checking, setChecking] = useState(true); + + useEffect(() => { + let cancelled = false; + + http + .get('/auth/me', { + skipAuthRedirect: true, + showToast: false, + showProgress: false, + }) + .then(() => { + if (!cancelled) navigate('/dashboard', { replace: true }); + }) + .catch(() => { + if (!cancelled) setChecking(false); + }); + + return () => { + cancelled = true; + }; + }, [navigate]); + + if (checking) return null; + + return ( + +
+ {/* Header */} +
+ +
+ + {/* Hero */} +
+
+
+

{brandName}

+

+ {t('legal.about.summary')} +

+ +
+ + {/* Feature grid */} +
+ {(['identity', 'oauth', 'calendar', 'contact'] as const).map((key) => ( +
+

+ {t(`legal.about.sections.${key}.title`)} +

+

+ {t(`legal.about.sections.${key}.body`)} +

+
+ ))} +
+
+
+ + {/* Footer */} +
+
+

+ © {new Date().getFullYear()} {brandName} +

+ +
+
+
+
+ ); +} + +export default LandingPage; diff --git a/frontend/app/src/shared/i18n/locales/en/common.json b/frontend/app/src/shared/i18n/locales/en/common.json index 48a1ae374..23d90daf6 100644 --- a/frontend/app/src/shared/i18n/locales/en/common.json +++ b/frontend/app/src/shared/i18n/locales/en/common.json @@ -338,5 +338,11 @@ "view": { "toggleMaximize": "Toggle Maximize", "maximizeRequiresTab": "Select a menu first" + }, + "landing": { + "signIn": "Sign In", + "signUp": "Sign Up", + "getStarted": "Get Started", + "learnMore": "Learn More" } } diff --git a/frontend/app/src/shared/i18n/locales/ja/common.json b/frontend/app/src/shared/i18n/locales/ja/common.json index 4a5165c11..42aad5e6e 100644 --- a/frontend/app/src/shared/i18n/locales/ja/common.json +++ b/frontend/app/src/shared/i18n/locales/ja/common.json @@ -338,5 +338,11 @@ "view": { "toggleMaximize": "最大化切り替え", "maximizeRequiresTab": "先にメニューを選択してください" + }, + "landing": { + "signIn": "ログイン", + "signUp": "新規登録", + "getStarted": "始める", + "learnMore": "詳しく見る" } } diff --git a/frontend/app/src/shared/i18n/locales/ko/common.json b/frontend/app/src/shared/i18n/locales/ko/common.json index e0b56b729..2f636e7a2 100644 --- a/frontend/app/src/shared/i18n/locales/ko/common.json +++ b/frontend/app/src/shared/i18n/locales/ko/common.json @@ -338,5 +338,11 @@ "view": { "toggleMaximize": "최대화 토글", "maximizeRequiresTab": "메뉴를 먼저 선택해주세요" + }, + "landing": { + "signIn": "로그인", + "signUp": "회원가입", + "getStarted": "시작하기", + "learnMore": "자세히 보기" } } From d91f239e4a6f597e2f0190e953b9855c13868144 Mon Sep 17 00:00:00 2001 From: JK Date: Sat, 4 Apr 2026 00:37:21 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=ED=86=A0=EB=A1=A0=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EA=B2=B0=EA=B3=BC=20=EB=B0=98=EC=98=81=20(?= =?UTF-8?q?=EB=9D=BC=EC=9A=B4=EB=93=9C=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isu_001 (backend/iam/src/main/kotlin/io/deck/iam/controller/BrandingController.kt:45): Google 파일 검증은 반환된 토큰 문자열 전체를 파일명으로 그대로 써야 하는데, 여기서는 저장값 앞뒤에 google 과 .html 을 다시 붙여 기대 파일명을 만듭니다. 그래서 운영자가 Google이 준 값(google12cfc68677988bb4.html 형태)을 그대로 저장하면 실제 요청 URL은 항상 404가 나고 사이트 검증이 실패합니다. - isu_002 (frontend/app/src/pages/landing/landing.page.tsx:55): 랜딩의 회원가입 버튼이 /invite 로 연결되는데, 이 페이지는 token 쿼리가 없으면 즉시 Invalid invitation link. 오류 화면으로 떨어집니다. 신규 사용자를 위한 CTA가 실제 가입 흐름이 아니라 깨진 초대 수락 페이지로 연결되어 첫 진입 경험이 막힙니다. - isu_003 (backend/iam/src/main/kotlin/io/deck/iam/domain/SystemSettingEntity.kt:118): update() 메서드에서 googleSiteVerification 파라미터의 기본값이 null이고, null이 전달되면 기존 값을 무조건 덮어씁니다. 현재 프론트엔드의 일반 설정 수정 폼에는 googleSiteVerification 필드가 없으므로(후속 PR 예정), 관리자가 브랜드명이나 연락처 이메일만 수정해도 요청 본문에 googleSiteVerification이 포함되지 않아 Jackson이 null로 역직렬화하고, 저장된 Google Site Verification 토큰이 삭제됩니다. --- .../deck/iam/controller/BrandingController.kt | 10 +- .../io/deck/iam/domain/SystemSettingEntity.kt | 18 +++- .../iam/controller/BrandingControllerTest.kt | 13 +++ .../iam/service/SystemSettingServiceTest.kt | 27 ++++++ .../src/pages/landing/landing.page.test.tsx | 97 +++++++++++++++++++ .../app/src/pages/landing/landing.page.tsx | 2 +- 6 files changed, 161 insertions(+), 6 deletions(-) create mode 100644 frontend/app/src/pages/landing/landing.page.test.tsx diff --git a/backend/iam/src/main/kotlin/io/deck/iam/controller/BrandingController.kt b/backend/iam/src/main/kotlin/io/deck/iam/controller/BrandingController.kt index 7b2173c95..e4328083b 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/controller/BrandingController.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/controller/BrandingController.kt @@ -2,6 +2,7 @@ package io.deck.iam.controller import io.deck.common.api.image.Base64ImageUtils import io.deck.iam.domain.LogoType +import io.deck.iam.domain.SystemSettingEntity import io.deck.iam.service.SystemSettingService import org.springframework.http.CacheControl import org.springframework.http.MediaType @@ -36,13 +37,14 @@ class BrandingController( * Google Site Verification (HTML 파일 확인 방식) * Google이 지정하는 /google{token}.html 경로에 인증 텍스트를 응답한다. */ - @GetMapping("/{filename:google[a-f0-9]+\\.html}") + @GetMapping("/{filename:google[^/]+\\.html}") fun siteVerification( @PathVariable filename: String, ): ResponseEntity { - val token = systemSettingService.getSettings().googleSiteVerification - ?: return ResponseEntity.notFound().build() - val expectedFilename = "google$token.html" + val expectedFilename = + SystemSettingEntity.normalizeGoogleSiteVerificationFilename( + systemSettingService.getSettings().googleSiteVerification, + ) ?: return ResponseEntity.notFound().build() if (filename != expectedFilename) { return ResponseEntity.notFound().build() } diff --git a/backend/iam/src/main/kotlin/io/deck/iam/domain/SystemSettingEntity.kt b/backend/iam/src/main/kotlin/io/deck/iam/domain/SystemSettingEntity.kt index 1fc255800..e149fc16a 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/domain/SystemSettingEntity.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/domain/SystemSettingEntity.kt @@ -105,6 +105,20 @@ class SystemSettingEntity( @Column(nullable = true, updatable = true) var updatedAt: Instant? = null, ) { + companion object { + fun normalizeGoogleSiteVerificationFilename(value: String?): String? { + val trimmed = value?.trim().orEmpty() + if (trimmed.isBlank()) { + return null + } + return if (trimmed.startsWith("google") && trimmed.endsWith(".html")) { + trimmed + } else { + "google$trimmed.html" + } + } + } + /** * 설정 정보 수정 (로고 제외) */ @@ -115,7 +129,9 @@ class SystemSettingEntity( ) { this.brandName = brandName this.contactEmail = contactEmail?.trim()?.ifBlank { null } - this.googleSiteVerification = googleSiteVerification?.trim()?.ifBlank { null } + if (googleSiteVerification != null) { + this.googleSiteVerification = normalizeGoogleSiteVerificationFilename(googleSiteVerification) + } } fun updateWorkspacePolicy(workspacePolicy: WorkspacePolicy?) { diff --git a/backend/iam/src/test/kotlin/io/deck/iam/controller/BrandingControllerTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/controller/BrandingControllerTest.kt index 116c2de92..5e65c5bbe 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/controller/BrandingControllerTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/controller/BrandingControllerTest.kt @@ -26,6 +26,19 @@ class BrandingControllerTest : } describe("브랜딩 로고 서빙") { + it("Google이 제공한 전체 파일명을 그대로 저장해도 사이트 검증 응답을 반환해야 한다") { + // given + every { systemSettingService.getSettings() } returns SystemSettingEntity(googleSiteVerification = "google12cfc68677988bb4.html") + + // when + val response = controller.siteVerification("google12cfc68677988bb4.html") + + // then + response.statusCode shouldBe HttpStatus.OK + response.headers.contentType shouldBe MediaType.TEXT_HTML + response.body shouldBe "google-site-verification: google12cfc68677988bb4.html" + } + it("public branding 메타는 현재 versioned 로고 URL을 내려줘야 한다") { // given val lightSvg = svgDataUri("") diff --git a/backend/iam/src/test/kotlin/io/deck/iam/service/SystemSettingServiceTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/service/SystemSettingServiceTest.kt index 40ee28e14..f9adaebd0 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/service/SystemSettingServiceTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/service/SystemSettingServiceTest.kt @@ -133,6 +133,33 @@ class SystemSettingServiceTest : verify { systemSettingRepository.save(existingSettings) } } + it("google site verification 값을 생략하면 기존 값을 유지해야 한다") { + // given + val ownerId = UUID.randomUUID() + val existingSettings = + SystemSettingEntity( + brandName = "Old Brand", + contactEmail = "old@deck.io", + googleSiteVerification = "google12cfc68677988bb4.html", + ) + + every { ownerService.isOwner(ownerId) } returns true + every { systemSettingRepository.findFirst() } returns existingSettings + every { systemSettingRepository.save(any()) } answers { firstArg() } + + // when + val result = + systemSettingService.updateGeneral( + userId = ownerId, + brandName = "New Brand", + contactEmail = "privacy@deck.io", + ) + + // then + result.googleSiteVerification shouldBe "google12cfc68677988bb4.html" + verify { systemSettingRepository.save(existingSettings) } + } + it("Owner가 아니면 수정을 거부해야 한다") { // given val userId = UUID.randomUUID() diff --git a/frontend/app/src/pages/landing/landing.page.test.tsx b/frontend/app/src/pages/landing/landing.page.test.tsx new file mode 100644 index 000000000..ae8b046a2 --- /dev/null +++ b/frontend/app/src/pages/landing/landing.page.test.tsx @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import type { ReactNode } from 'react'; +import { LandingPage } from './landing.page'; +import { http } from '#app/shared/http-client'; + +vi.mock('#app/shared/http-client', () => ({ + http: { + get: vi.fn(), + }, +})); + +vi.mock('#app/shared/public-auth-config', () => ({ + usePublicAuthConfig: () => ({ + brandName: 'Deck', + contactEmail: 'privacy@deck.io', + }), +})); + +vi.mock('#app/shared/i18n', () => ({ + useTranslation: () => ({ + t: (key: string) => + ( + { + 'landing.signIn': 'Sign In', + 'landing.signUp': 'Sign Up', + 'landing.getStarted': 'Get Started', + 'landing.learnMore': 'Learn More', + 'legal.about.summary': 'Deck summary', + 'legal.about.sections.identity.title': 'Identity', + 'legal.about.sections.identity.body': 'Identity body', + 'legal.about.sections.oauth.title': 'OAuth', + 'legal.about.sections.oauth.body': 'OAuth body', + 'legal.about.sections.calendar.title': 'Calendar', + 'legal.about.sections.calendar.body': 'Calendar body', + 'legal.about.sections.contact.title': 'Contact', + 'legal.about.sections.contact.body': 'Contact body', + } as Record + )[key] ?? key, + }), +})); + +vi.mock('#app/layouts', () => ({ + StandaloneLayout: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +vi.mock('#app/components/ui/button', async () => { + const { cloneElement, createElement: h, isValidElement } = await import('react'); + + return { + Button: function MockButton(props: Record) { + const buttonProps = props as React.HTMLAttributes & { + children?: ReactNode; + asChild?: boolean; + }; + const { children, asChild, ...rest } = buttonProps; + + if (asChild && isValidElement(children)) { + return cloneElement(children, rest); + } + + return h('button', rest, children); + }, + }; +}); + +vi.mock('#app/shared/legal-links/legal-links', () => ({ + LegalLinks: () =>
Legal Links
, +})); + +vi.mock('#app/shared/branding', () => ({ + BrandLogo: ({ className }: { className?: string }) => Brand logo, +})); + +describe('LandingPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + (http.get as Mock).mockRejectedValue(new Error('Unauthorized')); + }); + + afterEach(() => { + cleanup(); + }); + + it('회원가입 CTA는 깨진 초대 수락 페이지가 아닌 로그인 경로를 가리켜야 한다', async () => { + render( + + + + ); + + const signUpLink = await waitFor(() => screen.getByRole('link', { name: 'Sign Up' })); + + expect(signUpLink.getAttribute('href')).toBe('/login'); + }); +}); diff --git a/frontend/app/src/pages/landing/landing.page.tsx b/frontend/app/src/pages/landing/landing.page.tsx index 06128649d..a4434742b 100644 --- a/frontend/app/src/pages/landing/landing.page.tsx +++ b/frontend/app/src/pages/landing/landing.page.tsx @@ -52,7 +52,7 @@ export function LandingPage() { {t('landing.signIn')}
From 2a647c6f721640f8a1d008ac957b8251cbfbde45 Mon Sep 17 00:00:00 2001 From: JK Date: Sat, 4 Apr 2026 00:47:16 +0900 Subject: [PATCH 3/4] =?UTF-8?q?refactor(iam):=20Google=20Site=20Verificati?= =?UTF-8?q?on=20=EC=B1=85=EC=9E=84=EC=9D=84=20OAuthProviderConfig=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99=ED=95=A9=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SystemSettingEntity에서 googleSiteVerification 필드를 제거합니다 - OAuthProviderConfig.Google에 WebsiteVerification 데이터 클래스를 추가합니다 - BrandingController에서 분리하여 GoogleSiteVerificationController를 생성합니다 - OAuthProviderQuery에 getGoogleSiteVerification() 메서드를 추가합니다 - system_settings 테이블의 컬럼 migration을 삭제합니다 (oauth_providers.config JSON 활용) Co-Authored-By: Claude Opus 4.6 --- ...04040100__add_google_site_verification.sql | 1 - .../io/deck/iam/api/OAuthProviderQuery.kt | 7 ++ .../deck/iam/controller/BrandingController.kt | 23 ------ .../GoogleSiteVerificationController.kt | 41 ++++++++++ .../deck/iam/controller/OAuthProviderDtos.kt | 2 +- .../iam/controller/SystemSettingController.kt | 2 - .../deck/iam/controller/SystemSettingDtos.kt | 2 - .../io/deck/iam/domain/OAuthProviderConfig.kt | 11 ++- .../io/deck/iam/domain/SystemSettingEntity.kt | 21 ----- .../iam/service/OAuthProviderQueryImpl.kt | 10 +++ .../deck/iam/service/SystemSettingService.kt | 4 +- ...DynamicClientRegistrationRepositoryTest.kt | 2 +- .../iam/controller/BrandingControllerTest.kt | 13 ---- .../GoogleSiteVerificationControllerTest.kt | 78 +++++++++++++++++++ .../iam/service/SystemSettingServiceTest.kt | 27 ------- .../app/src/entities/system-settings/types.ts | 2 - 16 files changed, 148 insertions(+), 98 deletions(-) delete mode 100644 backend/app/src/main/resources/db/migration/app/V202604040100__add_google_site_verification.sql create mode 100644 backend/iam/src/main/kotlin/io/deck/iam/controller/GoogleSiteVerificationController.kt create mode 100644 backend/iam/src/test/kotlin/io/deck/iam/controller/GoogleSiteVerificationControllerTest.kt diff --git a/backend/app/src/main/resources/db/migration/app/V202604040100__add_google_site_verification.sql b/backend/app/src/main/resources/db/migration/app/V202604040100__add_google_site_verification.sql deleted file mode 100644 index 8a01fcb80..000000000 --- a/backend/app/src/main/resources/db/migration/app/V202604040100__add_google_site_verification.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE system_settings ADD COLUMN google_site_verification VARCHAR(255); diff --git a/backend/iam/src/main/kotlin/io/deck/iam/api/OAuthProviderQuery.kt b/backend/iam/src/main/kotlin/io/deck/iam/api/OAuthProviderQuery.kt index 109726666..f1d470f3a 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/api/OAuthProviderQuery.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/api/OAuthProviderQuery.kt @@ -2,4 +2,11 @@ package io.deck.iam.api interface OAuthProviderQuery { fun isProviderConfigured(providerType: String): Boolean + + fun getGoogleSiteVerification(): GoogleSiteVerificationSnapshot? + + data class GoogleSiteVerificationSnapshot( + val metaVerificationToken: String? = null, + val htmlVerificationFileName: String? = null, + ) } diff --git a/backend/iam/src/main/kotlin/io/deck/iam/controller/BrandingController.kt b/backend/iam/src/main/kotlin/io/deck/iam/controller/BrandingController.kt index e4328083b..78aefa7f2 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/controller/BrandingController.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/controller/BrandingController.kt @@ -2,13 +2,11 @@ package io.deck.iam.controller import io.deck.common.api.image.Base64ImageUtils import io.deck.iam.domain.LogoType -import io.deck.iam.domain.SystemSettingEntity import io.deck.iam.service.SystemSettingService import org.springframework.http.CacheControl import org.springframework.http.MediaType import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController import java.util.concurrent.TimeUnit @@ -33,27 +31,6 @@ class BrandingController( return ResponseEntity.ok(settings.toPublicBrandingDto()) } - /** - * Google Site Verification (HTML 파일 확인 방식) - * Google이 지정하는 /google{token}.html 경로에 인증 텍스트를 응답한다. - */ - @GetMapping("/{filename:google[^/]+\\.html}") - fun siteVerification( - @PathVariable filename: String, - ): ResponseEntity { - val expectedFilename = - SystemSettingEntity.normalizeGoogleSiteVerificationFilename( - systemSettingService.getSettings().googleSiteVerification, - ) ?: return ResponseEntity.notFound().build() - if (filename != expectedFilename) { - return ResponseEntity.notFound().build() - } - return ResponseEntity - .ok() - .contentType(MediaType.TEXT_HTML) - .body("google-site-verification: $expectedFilename") - } - /** * 가로 로고 - Light (SVG) */ diff --git a/backend/iam/src/main/kotlin/io/deck/iam/controller/GoogleSiteVerificationController.kt b/backend/iam/src/main/kotlin/io/deck/iam/controller/GoogleSiteVerificationController.kt new file mode 100644 index 000000000..df85b3d6b --- /dev/null +++ b/backend/iam/src/main/kotlin/io/deck/iam/controller/GoogleSiteVerificationController.kt @@ -0,0 +1,41 @@ +package io.deck.iam.controller + +import io.deck.iam.api.OAuthProviderQuery +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RestController + +/** + * Google Site Verification (HTML 파일 확인 방식) + * + * Google이 지정하는 /google{token}.html 경로에 인증 텍스트를 응답한다. + * 설정은 OAuthProviderConfig.Google.WebsiteVerification에서 관리한다. + */ +@RestController +@PreAuthorize("permitAll()") +class GoogleSiteVerificationController( + private val oauthProviderQuery: OAuthProviderQuery, +) { + @GetMapping("/{filename:google[a-z0-9]+\\.html}") + fun verify( + @PathVariable filename: String, + ): ResponseEntity { + val snapshot = oauthProviderQuery.getGoogleSiteVerification() + ?: return ResponseEntity.notFound().build() + + val expected = snapshot.htmlVerificationFileName + ?: return ResponseEntity.notFound().build() + + if (filename != expected) { + return ResponseEntity.notFound().build() + } + + return ResponseEntity + .ok() + .contentType(MediaType.TEXT_PLAIN) + .body("google-site-verification: $expected") + } +} diff --git a/backend/iam/src/main/kotlin/io/deck/iam/controller/OAuthProviderDtos.kt b/backend/iam/src/main/kotlin/io/deck/iam/controller/OAuthProviderDtos.kt index 02d6dbee0..d080977bc 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/controller/OAuthProviderDtos.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/controller/OAuthProviderDtos.kt @@ -58,7 +58,7 @@ fun OAuthProviderEntity.toDto(): OAuthProviderDto = fun UpdateOAuthProviderRequest.toConfig(provider: OAuthProviderType): OAuthProviderConfig? = when (provider) { - OAuthProviderType.GOOGLE -> OAuthProviderConfig.Google + OAuthProviderType.GOOGLE -> OAuthProviderConfig.Google() OAuthProviderType.NAVER -> OAuthProviderConfig.Naver OAuthProviderType.KAKAO -> OAuthProviderConfig.Kakao OAuthProviderType.OKTA -> domain?.let { OAuthProviderConfig.Okta(it) } diff --git a/backend/iam/src/main/kotlin/io/deck/iam/controller/SystemSettingController.kt b/backend/iam/src/main/kotlin/io/deck/iam/controller/SystemSettingController.kt index 541825e1e..c124f9857 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/controller/SystemSettingController.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/controller/SystemSettingController.kt @@ -57,7 +57,6 @@ class SystemSettingController( userId = userId, brandName = request.brandName, contactEmail = request.contactEmail, - googleSiteVerification = request.googleSiteVerification, ) return ResponseEntity.ok(settings.toDto(baseUrl)) } @@ -228,7 +227,6 @@ private fun SystemSettingEntity.toDto(baseUrl: String): SystemSettingDto = id = id, brandName = brandName, contactEmail = contactEmail, - googleSiteVerification = googleSiteVerification, logoHorizontalUrl = effectiveSystemLogoUrl(LogoType.HORIZONTAL), logoHorizontalDarkUrl = effectiveSystemLogoUrl(LogoType.HORIZONTAL, dark = true), logoPublicUrl = effectiveSystemLogoUrl(LogoType.PUBLIC), diff --git a/backend/iam/src/main/kotlin/io/deck/iam/controller/SystemSettingDtos.kt b/backend/iam/src/main/kotlin/io/deck/iam/controller/SystemSettingDtos.kt index b1d7ce2c8..bcae29ed0 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/controller/SystemSettingDtos.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/controller/SystemSettingDtos.kt @@ -9,7 +9,6 @@ data class SystemSettingDto( val id: UUID, val brandName: String, val contactEmail: String?, - val googleSiteVerification: String?, val logoHorizontalUrl: String?, val logoHorizontalDarkUrl: String?, val logoPublicUrl: String?, @@ -33,7 +32,6 @@ data class PublicBrandingDto( data class UpdateGeneralSettingsRequest( val brandName: String, val contactEmail: String? = null, - val googleSiteVerification: String? = null, ) data class UpdateWorkspacePolicyRequest( diff --git a/backend/iam/src/main/kotlin/io/deck/iam/domain/OAuthProviderConfig.kt b/backend/iam/src/main/kotlin/io/deck/iam/domain/OAuthProviderConfig.kt index 7c30d76ac..345990017 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/domain/OAuthProviderConfig.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/domain/OAuthProviderConfig.kt @@ -22,8 +22,15 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo JsonSubTypes.Type(value = OAuthProviderConfig.Aip::class, name = "AIP"), ) sealed class OAuthProviderConfig { - /** Google: 추가 설정 없음 */ - data object Google : OAuthProviderConfig() + /** Google: 사이트 검증 설정 */ + data class Google( + val websiteVerification: WebsiteVerification? = null, + ) : OAuthProviderConfig() { + data class WebsiteVerification( + val metaVerificationToken: String? = null, + val htmlVerificationFileName: String? = null, + ) + } /** Naver: 추가 설정 없음 */ data object Naver : OAuthProviderConfig() diff --git a/backend/iam/src/main/kotlin/io/deck/iam/domain/SystemSettingEntity.kt b/backend/iam/src/main/kotlin/io/deck/iam/domain/SystemSettingEntity.kt index e149fc16a..237c44cd7 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/domain/SystemSettingEntity.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/domain/SystemSettingEntity.kt @@ -89,9 +89,6 @@ class SystemSettingEntity( @Encrypted @Column(nullable = true, updatable = true, columnDefinition = "TEXT") var oktaClientSecret: String? = null, - /** Google Site Verification 토큰 (HTML 파일 확인 방식) */ - @Column(nullable = true, updatable = true, length = 255) - var googleSiteVerification: String? = null, @Embedded var workspacePolicy: WorkspacePolicy? = WorkspacePolicy(), @Embedded @@ -105,33 +102,15 @@ class SystemSettingEntity( @Column(nullable = true, updatable = true) var updatedAt: Instant? = null, ) { - companion object { - fun normalizeGoogleSiteVerificationFilename(value: String?): String? { - val trimmed = value?.trim().orEmpty() - if (trimmed.isBlank()) { - return null - } - return if (trimmed.startsWith("google") && trimmed.endsWith(".html")) { - trimmed - } else { - "google$trimmed.html" - } - } - } - /** * 설정 정보 수정 (로고 제외) */ fun update( brandName: String, contactEmail: String?, - googleSiteVerification: String? = null, ) { this.brandName = brandName this.contactEmail = contactEmail?.trim()?.ifBlank { null } - if (googleSiteVerification != null) { - this.googleSiteVerification = normalizeGoogleSiteVerificationFilename(googleSiteVerification) - } } fun updateWorkspacePolicy(workspacePolicy: WorkspacePolicy?) { diff --git a/backend/iam/src/main/kotlin/io/deck/iam/service/OAuthProviderQueryImpl.kt b/backend/iam/src/main/kotlin/io/deck/iam/service/OAuthProviderQueryImpl.kt index 9b49871b6..a9c7337eb 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/service/OAuthProviderQueryImpl.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/service/OAuthProviderQueryImpl.kt @@ -1,6 +1,7 @@ package io.deck.iam.service import io.deck.iam.api.OAuthProviderQuery +import io.deck.iam.domain.OAuthProviderConfig import io.deck.iam.domain.OAuthProviderType import org.springframework.stereotype.Service @@ -12,4 +13,13 @@ class OAuthProviderQueryImpl( val type = OAuthProviderType.entries.find { it.name == providerType } ?: return false return oauthProviderService.get(type)?.isConfigured() ?: false } + + override fun getGoogleSiteVerification(): OAuthProviderQuery.GoogleSiteVerificationSnapshot? { + val entity = oauthProviderService.get(OAuthProviderType.GOOGLE) ?: return null + val verification = (entity.config as? OAuthProviderConfig.Google)?.websiteVerification ?: return null + return OAuthProviderQuery.GoogleSiteVerificationSnapshot( + metaVerificationToken = verification.metaVerificationToken, + htmlVerificationFileName = verification.htmlVerificationFileName, + ) + } } diff --git a/backend/iam/src/main/kotlin/io/deck/iam/service/SystemSettingService.kt b/backend/iam/src/main/kotlin/io/deck/iam/service/SystemSettingService.kt index 6453d02f2..128ea60fd 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/service/SystemSettingService.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/service/SystemSettingService.kt @@ -226,11 +226,10 @@ class SystemSettingService( userId: UUID, brandName: String, contactEmail: String?, - googleSiteVerification: String? = null, ): SystemSettingEntity { requireOwner(userId) val settings = getSettings() - settings.update(brandName = brandName, contactEmail = contactEmail, googleSiteVerification = googleSiteVerification) + settings.update(brandName = brandName, contactEmail = contactEmail) val saved = systemSettingRepository.save(settings) publishActivity( eventType = IamActivityLogType.SYSTEM_SETTINGS_GENERAL_UPDATED, @@ -240,7 +239,6 @@ class SystemSettingService( mapOf( "brandName" to saved.brandName, "contactEmail" to saved.contactEmail, - "googleSiteVerification" to saved.googleSiteVerification, ), ) return saved diff --git a/backend/iam/src/test/kotlin/io/deck/iam/config/DynamicClientRegistrationRepositoryTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/config/DynamicClientRegistrationRepositoryTest.kt index 24c352a33..cd4ef58b7 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/config/DynamicClientRegistrationRepositoryTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/config/DynamicClientRegistrationRepositoryTest.kt @@ -23,7 +23,7 @@ class DynamicClientRegistrationRepositoryTest : enabled = true, clientId = "google-id", clientSecret = "google-secret", - config = OAuthProviderConfig.Google, + config = OAuthProviderConfig.Google(), ) val repository = DynamicClientRegistrationRepository(oauthProviderRepository) diff --git a/backend/iam/src/test/kotlin/io/deck/iam/controller/BrandingControllerTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/controller/BrandingControllerTest.kt index 5e65c5bbe..116c2de92 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/controller/BrandingControllerTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/controller/BrandingControllerTest.kt @@ -26,19 +26,6 @@ class BrandingControllerTest : } describe("브랜딩 로고 서빙") { - it("Google이 제공한 전체 파일명을 그대로 저장해도 사이트 검증 응답을 반환해야 한다") { - // given - every { systemSettingService.getSettings() } returns SystemSettingEntity(googleSiteVerification = "google12cfc68677988bb4.html") - - // when - val response = controller.siteVerification("google12cfc68677988bb4.html") - - // then - response.statusCode shouldBe HttpStatus.OK - response.headers.contentType shouldBe MediaType.TEXT_HTML - response.body shouldBe "google-site-verification: google12cfc68677988bb4.html" - } - it("public branding 메타는 현재 versioned 로고 URL을 내려줘야 한다") { // given val lightSvg = svgDataUri("") diff --git a/backend/iam/src/test/kotlin/io/deck/iam/controller/GoogleSiteVerificationControllerTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/controller/GoogleSiteVerificationControllerTest.kt new file mode 100644 index 000000000..ff1de6390 --- /dev/null +++ b/backend/iam/src/test/kotlin/io/deck/iam/controller/GoogleSiteVerificationControllerTest.kt @@ -0,0 +1,78 @@ +package io.deck.iam.controller + +import io.deck.iam.api.OAuthProviderQuery +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType + +class GoogleSiteVerificationControllerTest : + DescribeSpec({ + lateinit var oauthProviderQuery: OAuthProviderQuery + lateinit var controller: GoogleSiteVerificationController + + beforeEach { + oauthProviderQuery = mockk(relaxed = true) + controller = GoogleSiteVerificationController(oauthProviderQuery) + } + + describe("Google Site Verification") { + it("설정된 파일명과 일치하면 검증 응답을 반환해야 한다") { + // given + every { oauthProviderQuery.getGoogleSiteVerification() } returns + OAuthProviderQuery.GoogleSiteVerificationSnapshot( + htmlVerificationFileName = "google12cfc68677988bb4.html", + ) + + // when + val response = controller.verify("google12cfc68677988bb4.html") + + // then + response.statusCode shouldBe HttpStatus.OK + response.headers.contentType shouldBe MediaType.TEXT_PLAIN + response.body shouldBe "google-site-verification: google12cfc68677988bb4.html" + } + + it("파일명이 일치하지 않으면 404를 반환해야 한다") { + // given + every { oauthProviderQuery.getGoogleSiteVerification() } returns + OAuthProviderQuery.GoogleSiteVerificationSnapshot( + htmlVerificationFileName = "google12cfc68677988bb4.html", + ) + + // when + val response = controller.verify("googleabc123.html") + + // then + response.statusCode shouldBe HttpStatus.NOT_FOUND + } + + it("검증 설정이 없으면 404를 반환해야 한다") { + // given + every { oauthProviderQuery.getGoogleSiteVerification() } returns null + + // when + val response = controller.verify("google12cfc68677988bb4.html") + + // then + response.statusCode shouldBe HttpStatus.NOT_FOUND + } + + it("htmlVerificationFileName이 null이면 404를 반환해야 한다") { + // given + every { oauthProviderQuery.getGoogleSiteVerification() } returns + OAuthProviderQuery.GoogleSiteVerificationSnapshot( + metaVerificationToken = "some-token", + htmlVerificationFileName = null, + ) + + // when + val response = controller.verify("google12cfc68677988bb4.html") + + // then + response.statusCode shouldBe HttpStatus.NOT_FOUND + } + } + }) diff --git a/backend/iam/src/test/kotlin/io/deck/iam/service/SystemSettingServiceTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/service/SystemSettingServiceTest.kt index f9adaebd0..40ee28e14 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/service/SystemSettingServiceTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/service/SystemSettingServiceTest.kt @@ -133,33 +133,6 @@ class SystemSettingServiceTest : verify { systemSettingRepository.save(existingSettings) } } - it("google site verification 값을 생략하면 기존 값을 유지해야 한다") { - // given - val ownerId = UUID.randomUUID() - val existingSettings = - SystemSettingEntity( - brandName = "Old Brand", - contactEmail = "old@deck.io", - googleSiteVerification = "google12cfc68677988bb4.html", - ) - - every { ownerService.isOwner(ownerId) } returns true - every { systemSettingRepository.findFirst() } returns existingSettings - every { systemSettingRepository.save(any()) } answers { firstArg() } - - // when - val result = - systemSettingService.updateGeneral( - userId = ownerId, - brandName = "New Brand", - contactEmail = "privacy@deck.io", - ) - - // then - result.googleSiteVerification shouldBe "google12cfc68677988bb4.html" - verify { systemSettingRepository.save(existingSettings) } - } - it("Owner가 아니면 수정을 거부해야 한다") { // given val userId = UUID.randomUUID() diff --git a/frontend/app/src/entities/system-settings/types.ts b/frontend/app/src/entities/system-settings/types.ts index d3f159991..45db73b45 100644 --- a/frontend/app/src/entities/system-settings/types.ts +++ b/frontend/app/src/entities/system-settings/types.ts @@ -28,7 +28,6 @@ export interface CurrencyPolicy { export interface SystemSettings { brandName: string; contactEmail?: string | null; - googleSiteVerification?: string | null; baseUrl: string; logoHorizontalUrl?: string; logoHorizontalDarkUrl?: string; @@ -64,7 +63,6 @@ export interface AuthResponse { export interface UpdateGeneralSettingsRequest { brandName: string; contactEmail?: string | null; - googleSiteVerification?: string | null; } export interface UpdateWorkspacePolicyRequest { From 7a12a449088f29e9d358d3106dc4ad537d1ab1c9 Mon Sep 17 00:00:00 2001 From: JK Date: Sat, 4 Apr 2026 01:00:06 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=ED=86=A0=EB=A1=A0=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EA=B2=B0=EA=B3=BC=20=EB=B0=98=EC=98=81=20(?= =?UTF-8?q?=EB=9D=BC=EC=9A=B4=EB=93=9C=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isu_005 (backend/iam/src/main/kotlin/io/deck/iam/controller/OAuthProviderDtos.kt:61): Google 업데이트 요청을 항상 OAuthProviderConfig.Google()로 변환하면, 인증 설정 저장 시 기존 websiteVerification 값이 매번 빈 객체로 덮어써집니다. 프론트의 설정 화면은 enabled/clientId/clientSecret/domain만 PUT하므로 auth 설정을 한 번만 저장해도 저장돼 있던 htmlVerificationFileName이 삭제되고, /google*.html 검증 엔드포인트가 404로 바뀝니다. --- .../deck/iam/service/OAuthProviderService.kt | 32 ++++++- .../iam/service/OAuthProviderServiceTest.kt | 89 +++++++++++++++++++ 2 files changed, 117 insertions(+), 4 deletions(-) diff --git a/backend/iam/src/main/kotlin/io/deck/iam/service/OAuthProviderService.kt b/backend/iam/src/main/kotlin/io/deck/iam/service/OAuthProviderService.kt index d7b7f5d73..a4e10aabd 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/service/OAuthProviderService.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/service/OAuthProviderService.kt @@ -65,14 +65,15 @@ class OAuthProviderService( oauthProviderRepository.findById(type).orElseGet { OAuthProviderEntity(provider = type) } + val mergedConfig = mergeProviderConfig(type, entity.config, config) // 활성화된 Provider는 필수값 검증 if (enabled) { - val req = ProviderSaveRequest(type, enabled, clientId, clientSecret, config) + val req = ProviderSaveRequest(type, enabled, clientId, clientSecret, mergedConfig) validateProviderConfig(req, entity) } - entity.update(enabled, clientId, clientSecret, config) + entity.update(enabled, clientId, clientSecret, mergedConfig) // Lockout 방지 검증 validateOwnerCanLogin(entity) @@ -94,13 +95,14 @@ class OAuthProviderService( oauthProviderRepository.findById(req.type).orElseGet { OAuthProviderEntity(provider = req.type) } + val mergedConfig = mergeProviderConfig(req.type, entity.config, req.config) // 활성화된 Provider는 필수값 검증 if (req.enabled) { - validateProviderConfig(req, entity) + validateProviderConfig(req.copy(config = mergedConfig), entity) } - entity.update(req.enabled, req.clientId, req.clientSecret, req.config) + entity.update(req.enabled, req.clientId, req.clientSecret, mergedConfig) entity } @@ -201,6 +203,28 @@ class OAuthProviderService( val config: OAuthProviderConfig?, ) + private fun mergeProviderConfig( + type: OAuthProviderType, + existingConfig: OAuthProviderConfig?, + requestConfig: OAuthProviderConfig?, + ): OAuthProviderConfig? = + when (type) { + OAuthProviderType.GOOGLE -> { + val existingGoogle = existingConfig as? OAuthProviderConfig.Google + val requestGoogle = requestConfig as? OAuthProviderConfig.Google + + when { + requestGoogle == null -> existingGoogle + requestGoogle.websiteVerification != null -> requestGoogle + else -> requestGoogle.copy(websiteVerification = existingGoogle?.websiteVerification) + } + } + + else -> { + requestConfig + } + } + private fun publishProviderActivity(entity: OAuthProviderEntity) { eventPublisher.publishEvent( currentUserActivityEvent( diff --git a/backend/iam/src/test/kotlin/io/deck/iam/service/OAuthProviderServiceTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/service/OAuthProviderServiceTest.kt index 014b56022..1f16e6340 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/service/OAuthProviderServiceTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/service/OAuthProviderServiceTest.kt @@ -719,6 +719,95 @@ class OAuthProviderServiceTest : result.size shouldBe 2 verify { oauthProviderRepository.saveAll(any>()) } } + + it("Google provider 일괄 저장 시 기존 사이트 검증 설정을 유지해야 한다") { + // given + val owner = createOwner() + val googleIdentity = createIdentity(owner, AuthProvider.GOOGLE) + val settings = SystemSettingEntity(internalLoginEnabled = false) + val existingGoogle = + OAuthProviderEntity( + provider = OAuthProviderType.GOOGLE, + enabled = true, + clientId = "google-id", + clientSecret = "google-secret", + config = + OAuthProviderConfig.Google( + websiteVerification = + OAuthProviderConfig.Google.WebsiteVerification( + htmlVerificationFileName = "google12cfc68677988bb4.html", + ), + ), + ) + + every { oauthProviderRepository.findById(OAuthProviderType.GOOGLE) } returns Optional.of(existingGoogle) + every { oauthProviderRepository.saveAll(any>()) } answers { firstArg() } + every { systemSettingService.getSettings() } returns settings + every { userRepository.findOwners() } returns listOf(owner) + every { identityService.findAllByUserId(owner.id) } returns listOf(googleIdentity) + + // when + val result = + oauthProviderService.saveAll( + listOf( + OAuthProviderService.ProviderSaveRequest( + type = OAuthProviderType.GOOGLE, + enabled = true, + clientId = "google-id", + clientSecret = "", + config = OAuthProviderConfig.Google(), + ), + ), + ) + + // then + (result.single().config as OAuthProviderConfig.Google).websiteVerification?.htmlVerificationFileName shouldBe + "google12cfc68677988bb4.html" + } + } + + describe("Google 사이트 검증 설정 보존") { + it("Google provider 단건 저장 시 기존 사이트 검증 설정을 유지해야 한다") { + // given + val owner = createOwner() + val identity = createIdentity(owner, AuthProvider.GOOGLE) + val settings = SystemSettingEntity(internalLoginEnabled = false) + val existingGoogle = + OAuthProviderEntity( + provider = OAuthProviderType.GOOGLE, + enabled = true, + clientId = "google-id", + clientSecret = "google-secret", + config = + OAuthProviderConfig.Google( + websiteVerification = + OAuthProviderConfig.Google.WebsiteVerification( + htmlVerificationFileName = "google12cfc68677988bb4.html", + ), + ), + ) + + every { oauthProviderRepository.findById(OAuthProviderType.GOOGLE) } returns Optional.of(existingGoogle) + every { oauthProviderRepository.findAll() } returns emptyList() + every { oauthProviderRepository.save(any()) } answers { firstArg() } + every { systemSettingService.getSettings() } returns settings + every { userRepository.findOwners() } returns listOf(owner) + every { identityService.findAllByUserId(owner.id) } returns listOf(identity) + + // when + val result = + oauthProviderService.save( + type = OAuthProviderType.GOOGLE, + enabled = true, + clientId = "google-id", + clientSecret = "", + config = OAuthProviderConfig.Google(), + ) + + // then + (result.config as OAuthProviderConfig.Google).websiteVerification?.htmlVerificationFileName shouldBe + "google12cfc68677988bb4.html" + } } describe("activity log 이벤트 발행") {