From f5b2133102475bb43617d32387f84ef265d1b4ec Mon Sep 17 00:00:00 2001 From: RosieOh Date: Sat, 24 Oct 2026 20:59:52 +0900 Subject: [PATCH] =?UTF-8?q?feat=20:=20=EC=9A=B4=EC=98=81=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=EB=A5=BC=20=ED=95=9C=20=ED=99=94=EB=A9=B4=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=B3=B4=EB=8A=94=20=EA=B4=80=EC=A0=9C=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=9D=84=20=EB=8D=94=ED=95=9C=EB=8B=A4=20(#156)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 운영 상태를 확인하려면 서버에 붙어 로그를 보거나 /api/admin/* 을 하나씩 호출해야 했다. 정작 급할 때 제일 먼저 알아야 할 것 - 어떤 데이터가 낡았는가 - 는 동기화 응답을 눈으로 읽어야 알 수 있었다. 백엔드에 새 엔드포인트를 만들지 않는다. 이미 있는 네 경로만 읽는다. /api/admin/sync/status 데이터 신선도 /api/admin/dashboard 가입 추이, 최근 활동, 집계 /api/admin/policies/verification-status 지역별 정책 금액 검증률 /facilities/forecast-accuracy 입소 예측 정확도 정확도는 공개 지표와 같은 경로를 쓴다. 같은 숫자를 두 곳에서 다르게 계산하지 않기 위해서다. 화면의 첫 자리는 신선도다. 작업마다 기준 시간이 달라 "몇 시간 지났다" 로는 비교할 수 없으므로, 기준선을 트랙의 같은 지점에 고정하고 그 대비로 채운다 - 막대 길이가 곧 "기준의 몇 %를 썼는가" 이고, 넘긴 작업은 기준선 너머까지 채워진다. 기준이 작업마다 다르다는 사실 자체가 읽혀야 하므로 기준 시간을 이름 아래에 적는다. 낡은 것이 위로 온다. 경계: 껍데기(정적 HTML)는 공개, 수치는 전부 관리자 토큰을 요구한다. 껍데기를 막으면 로그인조차 할 수 없고, 수치 경로가 열리면 관제 화면이 인증 없이 읽히는 창구가 된다. 테스트가 그 분리를 고정한다 - 껍데기가 서버 렌더링 없는 정적 파일임을 응답과 클래스패스 리소스의 바이트 비교로 확인하고("템플릿 문법이 없다" 로 보려 했더니 자바스크립트 템플릿 리터럴에 걸렸다), 수치 경로 셋이 비로그인에 401/403 인지 확인한다. 의존성을 더하지 않는다. 외부 스크립트도, 번들한 폰트도 없다 - 단일 파일이고 이미지 크기가 늘지 않는다. 한 패널이 실패해도 나머지는 그린다. 전부 비어 있는 관제 화면이 가장 쓸모없다. --- .../core/security/SecurityConfig.java | 5 + .../resources/static/control-plane/index.html | 910 ++++++++++++++++++ .../integration/ControlPlaneTest.java | 123 +++ 3 files changed, 1038 insertions(+) create mode 100644 src/main/resources/static/control-plane/index.html create mode 100644 src/test/java/com/carecode/integration/ControlPlaneTest.java diff --git a/src/main/java/com/carecode/core/security/SecurityConfig.java b/src/main/java/com/carecode/core/security/SecurityConfig.java index cc317cd..f3484f1 100644 --- a/src/main/java/com/carecode/core/security/SecurityConfig.java +++ b/src/main/java/com/carecode/core/security/SecurityConfig.java @@ -114,6 +114,11 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 인증을 거치는 별도 다운로드 경로가 필요하다. .requestMatchers("/files/profile-images/**").permitAll() .requestMatchers("/static/**").permitAll() + + // 관제 화면(정적 HTML/JS). 껍데기만 공개다 — 데이터는 전부 관리자 토큰으로 + // /api/admin/** 을 부르며, 그쪽이 ADMIN 을 요구한다. 로그인 전에는 아무 수치도 + // 담고 있지 않아 공개해도 노출되는 것이 없다. + .requestMatchers("/control-plane", "/control-plane/**").permitAll() // 통합 인증 관련 엔드포인트 (공개 접근) .requestMatchers("/auth/login", "/auth/register").permitAll() // 일반 로그인/회원가입 diff --git a/src/main/resources/static/control-plane/index.html b/src/main/resources/static/control-plane/index.html new file mode 100644 index 0000000..d3516fe --- /dev/null +++ b/src/main/resources/static/control-plane/index.html @@ -0,0 +1,910 @@ + + + + + + +CareCode 관제 + + + + + +
+ +
+
CareCode 관제
+
+
+ +
+ + +
+

관리자 로그인

+
+

운영 지표는 관리자 계정으로만 볼 수 있습니다.

+
+ + + + +
+
+
+ + +
+ + +
+
+

데이터 신선도

+
+
+
+
+ +
+
+

입소 예측 정확도

+
+
+ +
+

가입 추이

+
+
+ +
+

정책 금액 검증

+
+
+
+ +
+
+

최근 활동

+
+
+
+
+ +
+
+ + + + diff --git a/src/test/java/com/carecode/integration/ControlPlaneTest.java b/src/test/java/com/carecode/integration/ControlPlaneTest.java new file mode 100644 index 0000000..bd36af9 --- /dev/null +++ b/src/test/java/com/carecode/integration/ControlPlaneTest.java @@ -0,0 +1,123 @@ +package com.carecode.integration; + +import com.carecode.CareCodeApplication; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; + +/** + * 관제 화면의 경계를 고정한다. + * + *

껍데기(정적 HTML/JS)는 공개다 — 로그인 전에는 아무 수치도 담고 있지 않다. 수치는 전부 + * 브라우저가 관리자 토큰으로 {@code /api/admin/**} 을 불러 채우고, 그쪽이 ADMIN 을 요구한다. + * + *

이 테스트가 지키는 것은 그 분리다. 껍데기가 막히면 로그인조차 할 수 없고, 수치 + * 경로가 열리면 관제 화면이 인증 없이 읽히는 창구가 된다. + */ +@SpringBootTest( + classes = CareCodeApplication.class, + properties = { + "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration," + + "org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration," + + "org.springframework.boot.autoconfigure.mail.MailSenderAutoConfiguration", + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:carecode_control_plane;MODE=MySQL;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.flyway.enabled=false", + "jwt.secret=testJwtSecretKeyForAccessControlTestMustBe256BitsLong0123456789", + "springdoc.api-docs.enabled=false", + "springdoc.swagger-ui.enabled=false", + "public.data.api.key=dummy", + "KAKAO_CLIENT_ID=dummy-kakao-client", + "KAKAO_CLIENT_SECRET=dummy-kakao-secret", + "MAIL_USERNAME=dummy", + "MAIL_PASSWORD=dummy" + } +) +@AutoConfigureMockMvc +@DisplayName("관제 화면") +class ControlPlaneTest { + + @MockBean RedisConnectionFactory redisConnectionFactory; + @MockBean StringRedisTemplate stringRedisTemplate; + @MockBean JavaMailSender javaMailSender; + + @Autowired MockMvc mockMvc; + + @Test + @DisplayName("껍데기는 로그인 없이 열린다 - 막히면 로그인조차 할 수 없다") + void shellIsReachableWithoutLogin() throws Exception { + MvcResult result = mockMvc.perform(get("/control-plane/index.html")).andReturn(); + + assertThat(result.getResponse().getStatus()).isEqualTo(200); + + String html = result.getResponse().getContentAsString(StandardCharsets.UTF_8); + assertThat(html).as("관제 화면이 맞는지").contains("CareCode 관제"); + } + + /** + * 껍데기에 수치가 들어 있으면 공개로 둔 의미가 없다. + * + *

서버가 아무것도 끼워 넣지 않는다는 것을 파일과 응답을 그대로 비교해 확인한다. + * "템플릿 문법이 없다" 로 보려 했더니 자바스크립트의 템플릿 리터럴({@code `${...}`})에 + * 걸렸다 — 그건 서버 렌더링과 무관하다. 바이트가 같으면 끼워 넣을 자리가 없다는 뜻이다. + * + *

회원 수·동기화 시각 같은 값은 브라우저가 로그인 뒤에 불러 채운다. + */ + @Test + @DisplayName("껍데기는 서버가 아무것도 끼워 넣지 않은 정적 파일이다") + void shellIsServedVerbatim() throws Exception { + String served = mockMvc.perform(get("/control-plane/index.html")) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + + String onDisk = new String( + new ClassPathResource("static/control-plane/index.html").getInputStream().readAllBytes(), + StandardCharsets.UTF_8); + + assertThat(served).isEqualTo(onDisk); + } + + @ParameterizedTest(name = "{0} 은 로그인 없이 열리지 않는다") + @ValueSource(strings = { + "/api/admin/dashboard", + "/api/admin/sync/status", + "/api/admin/policies/verification-status" + }) + void dataEndpointsRequireLogin(String path) throws Exception { + assertThat(mockMvc.perform(get(path)).andReturn().getResponse().getStatus()) + .as("%s 가 열려 있으면 관제 화면이 인증 없이 읽히는 창구가 된다", path) + .isIn(401, 403); + } + + /** + * 예측 정확도는 공개 지표다. 관제 화면도 같은 경로를 쓴다 — 같은 숫자를 두 곳에서 다르게 + * 계산하지 않기 위해서다. + */ + @Test + @DisplayName("예측 정확도는 공개 경로를 그대로 쓴다") + void forecastAccuracyStaysPublic() throws Exception { + assertThat(mockMvc.perform(get("/facilities/forecast-accuracy")) + .andReturn().getResponse().getStatus()) + .isNotIn(401, 403); + } +}