Consolidate data access control backoffice updates

This commit is contained in:
devmrko
2026-07-13 23:06:23 +09:00
parent 403298d474
commit e18b30feab
181 changed files with 11571 additions and 954 deletions

View File

@@ -4,6 +4,10 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -28,6 +32,7 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
import org.springframework.web.filter.ForwardedHeaderFilter;
@@ -45,7 +50,13 @@ class TransportSecurityTest {
TestPropertyValues.of(
"backoffice.security.admin-user=admin",
"backoffice.security.admin-password=test-password",
"backoffice.security.guest-enabled=true",
"backoffice.security.guest-user=guest",
"backoffice.security.guest-password=guest-password",
"backoffice.security.require-https=true",
"backoffice.security.remember-me-enabled=true",
"backoffice.security.remember-me-key=test-only-remember-me-key",
"backoffice.security.remember-me-days=14",
"backoffice.token.max-days=365",
"backoffice.ords.base-url=https://ords.example.test",
"backoffice.ords.timeout=10s",
@@ -95,6 +106,69 @@ class TransportSecurityTest {
.andExpect(redirectedUrl("https://admin.example.test/login"));
}
@Test
void rememberMeIsIssuedOnlyWhenTheLoginCheckboxIsSelected() throws Exception {
mockMvc.perform(post("/login")
.with(csrf())
.param("username", "admin")
.param("password", "test-password")
.param("remember-me", "true")
.header("X-Forwarded-Proto", "https")
.header("X-Forwarded-Host", "admin.example.test"))
.andExpect(status().is3xxRedirection())
.andExpect(cookie().exists("VPD_REMEMBER_ME"))
.andExpect(cookie().secure("VPD_REMEMBER_ME", true))
.andExpect(cookie().httpOnly("VPD_REMEMBER_ME", true));
}
@Test
void guestLoginSucceedsWhenConfigured() throws Exception {
mockMvc.perform(post("/login")
.with(csrf())
.param("username", "guest")
.param("password", "guest-password")
.header("X-Forwarded-Proto", "https")
.header("X-Forwarded-Host", "admin.example.test"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("https://admin.example.test/"));
}
@Test
void viewerCanReadButCannotPostMutations() throws Exception {
mockMvc.perform(get("/")
.with(user("guest").roles("VIEWER"))
.header("X-Forwarded-Proto", "https")
.header("X-Forwarded-Host", "admin.example.test"))
.andExpect(status().isOk());
mockMvc.perform(post("/mutation")
.with(csrf())
.with(user("guest").roles("VIEWER"))
.header("X-Forwarded-Proto", "https")
.header("X-Forwarded-Host", "admin.example.test"))
.andExpect(status().isForbidden());
}
@Test
void viewerCanPostReadOnlyExecutionForms() throws Exception {
mockMvc.perform(post("/probe")
.with(csrf())
.with(user("guest").roles("VIEWER"))
.header("X-Forwarded-Proto", "https")
.header("X-Forwarded-Host", "admin.example.test"))
.andExpect(status().isOk());
}
@Test
void adminCanPostMutations() throws Exception {
mockMvc.perform(post("/mutation")
.with(csrf())
.with(user("admin").roles("ADMIN"))
.header("X-Forwarded-Proto", "https")
.header("X-Forwarded-Host", "admin.example.test"))
.andExpect(status().isOk());
}
@Test
void productionSessionCookieSettingsAreBound() throws Exception {
var environment = new StandardEnvironment();
@@ -138,5 +212,15 @@ class TransportSecurityTest {
String home() {
return "home";
}
@PostMapping("/mutation")
String mutation() {
return "changed";
}
@PostMapping("/probe")
String probe() {
return "queried";
}
}
}

View File

@@ -0,0 +1,26 @@
package com.cloudhandson.vpdbackoffice.domain.masking;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.junit.jupiter.api.Test;
class MaskingTemplateTest {
@Test
void exposesOnlyCuratedTemplates() {
assertThat(MaskingTemplate.from("rrn_partial"))
.isEqualTo(MaskingTemplate.RRN_PARTIAL);
assertThat(MaskingTemplate.FULL.asoFunction())
.isEqualTo("DBMS_REDACT.FULL");
assertThat(MaskingTemplate.NULLIFY.previewResult())
.isEqualTo("NULL");
}
@Test
void rejectsUnknownTemplateCode() {
assertThatThrownBy(() -> MaskingTemplate.from("RAW_SQL"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("지원하지 않는");
}
}

View File

@@ -26,7 +26,7 @@ class ProbeResultTest {
assertThat(result.successLike()).isTrue();
assertThat(result.title()).contains("데이터를 볼 수 있습니다");
assertThat(result.plainSummary()).contains("2개").contains("VPD");
assertThat(result.plainSummary()).contains("2개").contains("행 접근 정책");
assertThat(result.nextAction()).contains("예상한 범위");
}
@@ -82,7 +82,7 @@ class ProbeResultTest {
assertThat(inactive.title()).contains("만료되었거나 회수");
assertThat(inactive.nextAction()).contains("활성 토큰");
assertThat(unavailable.title()).contains("ORDS");
assertThat(unavailable.nextAction()).contains("권한 설정을 바꾸지 말고");
assertThat(unavailable.nextAction()).contains("행 접근 규칙을 바꾸지 말고");
}
@Test
@@ -93,9 +93,9 @@ class ProbeResultTest {
"ORA-28110"
);
assertThat(result.title()).contains("VPD Filter");
assertThat(result.title()).contains("행 접근 Filter");
assertThat(result.plainSummary()).contains("토큰과 사용자 권한은 확인");
assertThat(result.nextAction()).contains("토큰이나 권한을 바꾸지 말고").contains("자동 Filter");
assertThat(result.nextAction()).contains("토큰이나 행 접근 규칙을 바꾸지 말고").contains("표준 행 접근 Filter");
}
@Test

View File

@@ -1,21 +0,0 @@
package com.cloudhandson.vpdbackoffice.service;
import static org.mockito.Mockito.verify;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
class DdsAuthorizationChangeNotifierTest {
@Test
void invokesTheOptionalDdsSynchronizerWhenTheDedicatedApplicationProvidesOne() {
DdsAuthorizationSynchronizer synchronizer = org.mockito.Mockito.mock(DdsAuthorizationSynchronizer.class);
DefaultListableBeanFactory factory = new DefaultListableBeanFactory();
factory.registerSingleton("ddsSynchronizer", synchronizer);
new DdsAuthorizationChangeNotifier(factory.getBeanProvider(DdsAuthorizationSynchronizer.class))
.changed("PERMISSION_SAVED");
verify(synchronizer).synchronize("PERMISSION_SAVED");
}
}

View File

@@ -57,8 +57,8 @@ class EffectiveMatrixServiceTest {
@Override
public List<PermissionView> findPermissionViews() {
return List.of(
new PermissionView(1000L, 10L, "DIRECT_ROLE", 1L, "BOARD_POSTS", "SELECT", "ALLOW", "ALL", null, "ALL ROWS", "모든 민감 컬럼 NULL 처"),
new PermissionView(1001L, 20L, "GROUP_ROLE", 2L, "BOARD_ASSIGNMENTS", "SELECT", "ALLOW", "ALL", null, "ALL ROWS", "모든 민감 컬럼 NULL 처")
new PermissionView(1000L, 10L, "DIRECT_ROLE", 1L, "BOARD_POSTS", "SELECT", "ALLOW", "ALL", null, "ALL ROWS", "컬럼 원문/마스킹은 컬럼 마스킹에서 관"),
new PermissionView(1001L, 20L, "GROUP_ROLE", 2L, "BOARD_ASSIGNMENTS", "SELECT", "ALLOW", "ALL", null, "ALL ROWS", "컬럼 원문/마스킹은 컬럼 마스킹에서 관")
);
}
}

View File

@@ -0,0 +1,21 @@
package com.cloudhandson.vpdbackoffice.service;
import static org.mockito.Mockito.verify;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
class ExternalAuthorizationChangeNotifierTest {
@Test
void invokesTheOptionalExternalSynchronizerWhenOneIsConfigured() {
ExternalAuthorizationSynchronizer synchronizer = org.mockito.Mockito.mock(ExternalAuthorizationSynchronizer.class);
DefaultListableBeanFactory factory = new DefaultListableBeanFactory();
factory.registerSingleton("externalAuthorizationSynchronizer", synchronizer);
new ExternalAuthorizationChangeNotifier(factory.getBeanProvider(ExternalAuthorizationSynchronizer.class))
.changed("PERMISSION_SAVED");
verify(synchronizer).synchronize("PERMISSION_SAVED");
}
}

View File

@@ -0,0 +1,30 @@
package com.cloudhandson.vpdbackoffice.service;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class MaskingPolicySynchronizerTest {
@Test
void maskingExpressionIsAParseableSqlConditionForRedactionExpressionBindValue() {
assertThat(MaskingPolicySynchronizer.maskingExpression(11))
.isEqualTo(
"SYS_CONTEXT('CB_AGENT_CTX', 'MR_11') IS NULL "
+ "OR SYS_CONTEXT('CB_AGENT_CTX', 'MR_11') <> 'Y'");
}
@Test
void maskingExpressionDoesNotUsePlsqlLiteralEscapingBecauseJdbcBindsTheValue() {
assertThat(MaskingPolicySynchronizer.maskingExpression(11))
.doesNotContain("''CB_AGENT_CTX''")
.doesNotContain("''MR_11''")
.doesNotContain("''Y''");
}
@Test
void kbContractsPremiumPolicyIsManagedByBackoffice() {
assertThat(MaskingPolicySynchronizer.managedPolicyName("KB_CONTRACTS"))
.isEqualTo("KB_CONTRACT_PREMIUM_REDACT");
}
}

View File

@@ -4,7 +4,6 @@ import static org.assertj.core.api.Assertions.assertThat;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.List;
import org.junit.jupiter.api.Test;
class McpSseServiceTest {
@@ -12,50 +11,65 @@ class McpSseServiceTest {
private final ObjectMapper objectMapper = new ObjectMapper();
private final SelectAiAgentOrdsService selectAiAgentOrdsService = new CapturingSelectAiAgentOrdsService();
private final McpSseService service = new McpSseService(
new EmptyToolRegistry(),
null,
selectAiAgentOrdsService,
objectMapper
);
@Test
void listsSelectAiRouterToolWithPromptAndBearerInputs() {
void listsOnlyVpdSelectAiToolWithPromptInput() {
ObjectNode response = service.handle("default", request(1, "tools/list"));
var tools = response.path("result").path("tools");
var tool = tools.findValuesAsText("name").indexOf("ords.agent.kb_select_ai_router");
assertThat(tool).isGreaterThanOrEqualTo(0);
var router = tools.get(tool);
assertThat(router.path("inputSchema").path("required"))
assertThat(tools).hasSize(1);
var selectAi = tools.get(0);
assertThat(selectAi.path("name").asText()).isEqualTo("ords.query.kb_select_ai_vpd");
assertThat(selectAi.path("inputSchema").path("required"))
.extracting(node -> node.asText())
.contains("bearerToken", "prompt");
assertThat(router.path("inputSchema").path("properties").has("conversationId")).isTrue();
.contains("prompt");
assertThat(selectAi.path("inputSchema").path("properties").has("bearerToken")).isFalse();
assertThat(selectAi.path("inputSchema").path("properties").has("limit")).isTrue();
assertThat(selectAi.path("inputSchema").path("properties").has("conversationId")).isFalse();
}
@Test
void callsSelectAiRouterThroughOrdsService() {
void callsVpdSelectAiThroughOrdsService() {
ObjectNode request = request(2, "tools/call");
ObjectNode params = (ObjectNode) request.putObject("params");
params.put("name", "ords.agent.kb_select_ai_router");
params.put("name", "ords.query.kb_select_ai_vpd");
ObjectNode arguments = params.putObject("arguments");
arguments.put("bearerToken", "user-bearer");
arguments.put("prompt", "고객 수를 세는 SQL을 만들어줘");
arguments.put("conversationId", "smoke-1");
arguments.put("prompt", "고객 수를 조회해 줘");
arguments.put("limit", 25);
ObjectNode response = service.handle("default", request);
ObjectNode response = service.handle("default", request, "user-bearer");
CapturingSelectAiAgentOrdsService agentService =
(CapturingSelectAiAgentOrdsService) selectAiAgentOrdsService;
assertThat(agentService.bearerToken).isEqualTo("user-bearer");
assertThat(agentService.prompt).isEqualTo("고객 수를 세는 SQL을 만들어");
assertThat(agentService.conversationId).isEqualTo("smoke-1");
assertThat(agentService.prompt).isEqualTo("고객 수를 조회해 ");
assertThat(agentService.limit).isEqualTo(25);
assertThat(response.path("error").isMissingNode()).isTrue();
assertThat(response.path("result").path("isError").asBoolean()).isFalse();
assertThat(response.path("result").path("content").get(0).path("text").asText())
.contains("KB_SELECT_AI_ROUTER_TEAM")
.contains("KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1")
.contains("SELECT COUNT(*) FROM KB_CUSTOMERS");
}
@Test
void returnsToolLevelDeniedResultWhenVpdTokenIsMissing() {
ObjectNode request = request(3, "tools/call");
ObjectNode params = (ObjectNode) request.putObject("params");
params.put("name", "ords.query.kb_select_ai_vpd");
params.putObject("arguments").put("prompt", "고객 수를 조회해 줘");
ObjectNode response = service.handle("default", request, "");
assertThat(response.path("error").isMissingNode()).isTrue();
assertThat(response.path("result").path("isError").asBoolean()).isTrue();
assertThat(response.path("result").path("content").get(0).path("text").asText())
.contains("VPD_TOKEN_DENIED")
.contains("권한이 없습니다");
}
private ObjectNode request(int id, String method) {
ObjectNode request = objectMapper.createObjectNode();
request.put("jsonrpc", "2.0");
@@ -64,36 +78,24 @@ class McpSseServiceTest {
return request;
}
private static final class EmptyToolRegistry extends McpToolRegistry {
private EmptyToolRegistry() {
super(null);
}
@Override
public List<com.cloudhandson.vpdbackoffice.domain.mcp.McpToolView> listTools() {
return List.of();
}
}
private static final class CapturingSelectAiAgentOrdsService extends SelectAiAgentOrdsService {
private String bearerToken;
private String prompt;
private String conversationId;
private int limit;
private CapturingSelectAiAgentOrdsService() {
super(null, null, new ObjectMapper());
}
@Override
public JsonNode run(String bearerToken, String prompt, String conversationId) {
public JsonNode run(String bearerToken, String prompt, int limit) {
this.bearerToken = bearerToken;
this.prompt = prompt;
this.conversationId = conversationId;
this.limit = limit;
return new ObjectMapper().createObjectNode()
.put("team", "KB_SELECT_AI_ROUTER_TEAM")
.put("answer", "SELECT COUNT(*) FROM KB_CUSTOMERS");
.put("profile", "KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1")
.put("generatedSql", "SELECT COUNT(*) FROM KB_CUSTOMERS");
}
}
}

View File

@@ -319,7 +319,7 @@ class PermissionServiceTest {
}
@Test
void acceptsMultipleVisibleColumns() {
void ignoresVisibleColumnsBecauseAsoHandlesColumnMasking() {
var command = new PermissionSetCommand(
10L,
1L,
@@ -332,7 +332,7 @@ class PermissionServiceTest {
permissionService.savePermissionSet(command);
FakePermissionMapper mapper = (FakePermissionMapper) permissionMapper;
assertThat(mapper.insertedVisibleColumns).containsExactly("DEPT_CODE", "OWNER_EMP_NO");
assertThat(mapper.insertedVisibleColumns).isEmpty();
}
@Test

View File

@@ -0,0 +1,35 @@
package com.cloudhandson.vpdbackoffice.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.junit.jupiter.api.Test;
class SecuritySqlScriptServiceTest {
private final SecuritySqlScriptService service = new SecuritySqlScriptService();
@Test
void exposesOnlyTheCuratedGitTrackedSecurityScripts() {
assertThat(service.list())
.extracting(item -> item.fileName())
.containsExactly(
"62_kb_aso_masking_backoffice_metadata.sql",
"63_kb_aso_masking_rule_runtime.sql",
"64_kb_aso_masking_default_column_rules.sql",
"65_kb_select_ai_vpd_query_api.sql",
"66_kb_select_ai_vpd_query_ords.sql"
);
assertThat(service.find("select-ai-vpd-ords").source())
.contains("POST /ords/cb-ords/kb-select-ai-vpd/query")
.contains("Authorization: Bearer <VPD token>");
}
@Test
void rejectsUnknownScriptIdsInsteadOfResolvingAPathFromRequestInput() {
assertThatThrownBy(() -> service.find("../../etc/passwd"))
.isInstanceOf(AppException.class)
.hasMessageContaining("조회할 수 없는");
}
}

View File

@@ -17,8 +17,8 @@ class GuidedFlowTemplateTest {
String javascript = Files.readString(Path.of("src/main/resources/static/js/app.js"));
assertThat(html)
.contains("th:replace=\"~{fragments/layout :: head('VPD 권한 운영')}\"")
.contains("권한 운영 흐름")
.contains("th:replace=\"~{fragments/layout :: head('데이터 접근 제어')}\"")
.contains("행 접근과 컬럼 마스킹 운영 흐름")
.contains("업무 흐름")
.contains("dashboard-menu-flow")
.contains("도움말: 메뉴와 권한 적용 구조 보기")
@@ -27,7 +27,7 @@ class GuidedFlowTemplateTest {
.contains("CB_AGENT_CTX")
.contains("권한 데이터 모델")
.contains("접근 검증")
.contains("접근 규칙")
.contains("접근 규칙")
.contains("현재 상태")
.contains("등록 대상 상세")
.contains("<details class=\"dashboard-details\">")
@@ -35,8 +35,8 @@ class GuidedFlowTemplateTest {
.doesNotContain("dashboard-workflow")
.doesNotContain(">01<", ">02<");
assertThat(layout)
.contains("VPD 권한 운영")
.contains("권한 관리")
.contains("데이터 접근 제어")
.contains("접근 제어")
.contains("보호·검증")
.contains("연동 도구")
.contains("운영")
@@ -44,16 +44,24 @@ class GuidedFlowTemplateTest {
.contains("조회 연동")
.contains("정형 데이터 조회")
.contains("MCP 서비스")
.contains("컬럼 마스킹")
.contains("컬럼 원문 표시 허용")
.contains("행 접근 필터 구조")
.contains("보안 SQL 스크립트")
.contains("고급 접근 조건")
.contains("시스템 설정")
.contains("DB 준비 상태")
.contains("data-submenu-trigger")
.contains("data-submenu-bar")
.contains("data-submenu-panel")
.contains("backoffice-can-mutate")
.contains("backoffice-read-only")
.doesNotContain("VPD Backoffice");
assertThat(javascript)
.contains("data-submenu-trigger")
.contains("data-submenu-panel")
.contains("disableReadOnlyMutationForms")
.contains("/vector-knowledge/search")
.doesNotContain("closeAllMenuGroups");
}
@@ -64,7 +72,8 @@ class GuidedFlowTemplateTest {
"effective-matrix.html", "users.html", "groups.html", "roles.html", "vector-knowledge.html",
"operation-status.html", "settings.html", "settings-database.html", "ords-handlers.html",
"vpd-filter-policies.html", "mcp-chatbot.html", "mcp-reasoning.html", "mcp-client-demo.html",
"mcp-sse.html", "structured-data.html")) {
"mcp-sse.html", "structured-data.html", "masking-rules.html", "user-masking-rules.html",
"vpd-filter-runtime.html", "security-sql-scripts.html")) {
assertThat(template(page))
.contains("<summary>도움말</summary>")
.doesNotContain("context-summary")
@@ -74,6 +83,33 @@ class GuidedFlowTemplateTest {
.doesNotContain("1 · CONTENT", "2 · ACCESS POLICY", "3 · SEARCH");
}
@Test
void loginOffersRememberMeOnlyThroughTheSpringSecurityParameter() throws IOException {
assertThat(template("login.html"))
.contains("name=\"remember-me\"")
.contains("데이터 접근 제어 콘솔")
.contains("<span class=\"login-mark\">접근</span>")
.contains("로그인 유지")
.contains("th:if=\"${rememberMeAvailable}\"")
.doesNotContain("<span class=\"login-mark\">VPD</span>");
}
@Test
void securitySqlScriptsCanRequestExplanationWithoutExecutingSql() throws IOException {
String page = template("security-sql-scripts.html");
String result = template("fragments/security-sql-script-explanation.html");
assertThat(page)
.contains("hx-post=\"/security-sql-scripts/explanation\"")
.contains("LLM 전체·블록·토큰 처리 설명 생성")
.contains("원문은 실행·수정하지 않고");
assertThat(result)
.contains("LLM 스크립트 설명")
.contains("토큰 처리와 행 접근/ASO 적용 흐름")
.contains("data-markdown-view")
.contains("근거 Prompt 보기");
}
@Test
void productFormsUseTaskNamesInsteadOfVisibleStepNumbers() throws IOException {
String permissions = template("permissions.html");
@@ -148,11 +184,11 @@ class GuidedFlowTemplateTest {
String html = template("objects.html");
assertThat(html)
.contains("누가 어떤 행을 볼 수 있는지는 권한 규칙과 VPD에서 결정")
.contains("누가 어떤 행을 볼 수 있는지는 행 접근 규칙과 Oracle VPD 정책에서 결정")
.contains("지식 검색 연결 흐름")
.contains("VPD context 설정과 기본 SELECT")
.contains("행 접근 context 설정과 기본 SELECT")
.contains("전용 벡터 Handler 사용")
.contains("원문 표시 허용 컬럼")
.contains("컬럼 마스킹")
.doesNotContain("표시 보호 등급", "마스킹 방식");
}
@@ -186,7 +222,7 @@ class GuidedFlowTemplateTest {
assertThat(roles)
.contains("표시 보호 등급 상한")
.contains("행 접근 권한은 권한 규칙과 VPD에서 결정합니다");
.contains("행 접근 권한은 행 접근 규칙과 Oracle VPD 정책에서 결정합니다");
assertThat(tokens)
.contains("회수·만료 포함")
.contains("현재 사용 가능한 토큰만");
@@ -241,7 +277,7 @@ class GuidedFlowTemplateTest {
assertThat(chatbot).contains("검증 세션 사용자").contains("태그 벡터 검색");
assertThat(reasoning).contains("MCP tool을 고르고").contains("검증 세션 사용자").contains("MCP Tool");
assertThat(client).contains("tool 선택과 호출은 reasoning 결과").doesNotContain("Context Path");
assertThat(sse).contains("Instruction / parameter mapping").contains("bearerToken");
assertThat(sse).contains("Instruction / parameter mapping").contains("Authorization");
}
@Test
@@ -274,7 +310,7 @@ class GuidedFlowTemplateTest {
.contains("RLS_INFO")
.contains("참고용 SQL 재현")
.contains("SQL (재현)")
.contains("VPD 조건 (재현)")
.contains("행 접근 조건 (VPD predicate 재현)")
.contains("ALL: 추가 행 필터 없음")
.contains("기본 whitelist 역할");
assertThat(vectorSql)

View File

@@ -0,0 +1,87 @@
package com.cloudhandson.vpdbackoffice.web;
import static org.assertj.core.api.Assertions.assertThat;
import com.cloudhandson.vpdbackoffice.domain.masking.ColumnMaskingRule;
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule;
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus;
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingTemplate;
import com.cloudhandson.vpdbackoffice.domain.masking.UserMaskingRule;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
import com.cloudhandson.vpdbackoffice.domain.user.AppUser;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.thymeleaf.context.Context;
import org.thymeleaf.spring6.SpringTemplateEngine;
import org.thymeleaf.templateresolver.FileTemplateResolver;
class MaskingRuleTemplateRenderTest {
@Test
void rendersRuleRegistrationAndUserExceptionPages() {
var resolver = new FileTemplateResolver();
resolver.setPrefix(Path.of("src/main/resources/templates").toAbsolutePath() + "/");
resolver.setSuffix(".html");
resolver.setTemplateMode("HTML");
resolver.setCacheable(false);
var engine = new SpringTemplateEngine();
engine.setTemplateResolver(resolver);
var object = new ProtectedObject(7L, "POC_2", "KB_CUSTOMERS", "kb/customers", "Y");
var contractObject = new ProtectedObject(8L, "POC_2", "KB_CONTRACTS", "kb/contracts", "Y");
var column = new ProtectedColumn(11L, 7L, "RRN_MASKED", "Y", null, "RESTRICTED", "NULLIFY");
var rule = new MaskingRule(1L, "KB_RRN_STANDARD", "주민번호 기본 마스킹", "RRN_PARTIAL", "테스트", "Y");
var columnRule = new ColumnMaskingRule(11L, 7L, "POC_2", "KB_CUSTOMERS", "RRN_MASKED",
1L, "KB_RRN_STANDARD", "주민번호 기본 마스킹", "RRN_PARTIAL", "Y");
var context = new Context(Locale.KOREAN);
context.setVariable("_csrf", new CsrfFixture("_csrf", "test-token"));
context.setVariable("templates", List.of(MaskingTemplate.values()));
context.setVariable("rules", List.of(rule));
context.setVariable("columnRules", List.of(columnRule));
var status = new MaskingPolicyStatus(
"POC_2", "KB_CUSTOMERS", "KB_CUSTOMER_PII_REDACT", "YES", 1, 1, 0, 0
);
context.setVariable("policyStatuses", List.of(status));
context.setVariable("policyStatusByObjectName", Map.of("KB_CUSTOMERS", status));
context.setVariable("objects", List.of(object, contractObject));
context.setVariable("maskingTargetObjects", List.of(object, contractObject));
context.setVariable("sensitiveColumnsByObject", Map.of(7L, List.of(column), 8L, List.of()));
context.setVariable("availableMaskingColumnsByObject", Map.of(7L, List.of("CUST_NM"), 8L, List.of("PREMIUM")));
String rulesPage = engine.process("masking-rules", context);
assertThat(rulesPage)
.contains("사전 정의 마스킹 방식")
.contains("민감 컬럼에 기본 규칙 연결")
.contains("DBMS_REDACT.REGEXP")
.contains("컬럼 원문 표시 허용 사용자")
.contains("현재 DB 정책 동기화")
.contains("자동 동기화")
.contains("DB Redaction 컬럼")
.contains("레거시 VPD 컬럼 제어")
.contains("마스킹 대상 컬럼 추가")
.contains("POC_2.KB_CONTRACTS.PREMIUM")
.contains("KB_CUSTOMER_PII_REDACT")
.contains("적용됨");
context.setVariable("users", List.of(new AppUser(2L, "KB_VPD_ADMIN", "ADMIN", "SEC", "N", "Y")));
context.setVariable("userRules", List.of(new UserMaskingRule(2L, "KB_VPD_ADMIN", 11L,
"POC_2", "KB_CUSTOMERS", "RRN_MASKED", "주민번호 기본 마스킹", "RRN_PARTIAL", "UNMASK", "Y")));
String userPage = engine.process("user-masking-rules", context);
assertThat(userPage)
.contains("컬럼 원문 표시 허용 사용자 추가")
.contains("원문 표시 허용")
.contains("행 접근 여부는 변경하지 않습니다")
.contains("선택한 컬럼의 실제 적용 결과")
.contains("data-masking-exception-preview")
.contains("MR_11");
}
private record CsrfFixture(String parameterName, String token) {
}
}