refs #723: externalize backoffice catalogs and MCP tools

This commit is contained in:
devmrko
2026-07-23 19:41:54 +09:00
parent 1917df09a2
commit 0d9028ef13
58 changed files with 1671 additions and 531 deletions

View File

@@ -3,10 +3,11 @@ package com.cloudhandson.vpdbackoffice.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import com.cloudhandson.vpdbackoffice.config.CatalogProperties;
import com.cloudhandson.vpdbackoffice.config.MaskingProperties;
import com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.core.JdbcTemplate;
class MaskingPolicySynchronizerTest {
@@ -28,11 +29,24 @@ class MaskingPolicySynchronizerTest {
}
@Test
void hmmEmployeePolicyComesFromTheSharedJsonCatalog() {
var catalogProvider = new StructuredDataCatalogProvider(
new ObjectMapper(), new ClassPathResource("config/structured-data-catalog.json"));
void hmmEmployeePolicyComesFromEnvironmentPolicyCatalog() {
ObjectMapper objectMapper = new ObjectMapper();
var dataCatalog = new EnvironmentDataCatalog(
new CatalogProperties("ADMIN", """
[{"key":"employees","tableName":"HMM_HR_EMPLOYEES","objectType":"TABLE",
"businessName":"직원","description":"직원"}]
"""),
objectMapper);
var policyCatalog = new EnvironmentMaskingPolicyCatalog(
new MaskingProperties("""
[{"objectName":"HMM_HR_EMPLOYEES","policyName":"HMM_EMPLOYEE_PII_REDACT"}]
"""),
objectMapper);
var synchronizer = new MaskingPolicySynchronizer(
mock(JdbcTemplate.class), mock(MaskingRuleMapper.class), catalogProvider);
mock(JdbcTemplate.class),
mock(MaskingRuleMapper.class),
dataCatalog,
policyCatalog);
assertThat(synchronizer.owner()).isEqualTo("ADMIN");
assertThat(synchronizer.managedPolicyName("HMM_HR_EMPLOYEES"))

View File

@@ -2,7 +2,10 @@ package com.cloudhandson.vpdbackoffice.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
import com.cloudhandson.vpdbackoffice.config.McpProperties;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
@@ -10,16 +13,70 @@ import org.junit.jupiter.api.Test;
class McpSseServiceTest {
private static final String HMM_TOOLS = """
[
{
"name":"resolve_hr_term",
"label":"HMM HR 용어 표준화",
"description":"휴가·근태 표현을 표준 용어와 코드로 변환합니다.",
"argumentName":"term",
"argumentDescription":"확인할 휴가·근태 용어입니다.",
"executionType":"AGENT_TOOL",
"targetName":"HMM_HR_TERM_RESOLVER",
"targetParameterName":"P_TERM"
},
{
"name":"search_hr_data",
"label":"HMM HR 데이터 조회",
"description":"조직, 직원, 휴가, 근태 데이터를 조회합니다.",
"argumentName":"query",
"argumentDescription":"완전한 자연어 질문입니다.",
"executionType":"AGENT_TOOL",
"targetName":"HMM_HR_NORMALIZED_DATA_SEARCH",
"targetParameterName":"P_QUERY"
},
{
"name":"search_hr_policy",
"label":"HMM HR 규정 검색",
"description":"HR 규정 PDF를 검색합니다.",
"argumentName":"query",
"argumentDescription":"정책에 대한 자연어 질문입니다.",
"executionType":"AGENT_TOOL",
"targetName":"HMM_HR_POLICY_SEARCH",
"targetParameterName":"P_QUERY"
}
]
""";
private final ObjectMapper objectMapper = new ObjectMapper();
private final CapturingHmmAiAgentToolRunner agentToolRunner = new CapturingHmmAiAgentToolRunner();
private final McpProperties mcpProperties =
new McpProperties(
"https://example.com/mcp",
"hmm-hr-backoffice",
"",
"",
"",
"",
HMM_TOOLS);
private final McpToolCatalog toolCatalog =
new EnvironmentMcpToolCatalog(mcpProperties, objectMapper);
private final CapturingHmmAiAgentToolRunner agentToolRunner =
new CapturingHmmAiAgentToolRunner();
private final HmmMcpBearerAuthenticator bearerAuthenticator =
token -> new HmmMcpPrincipal(1L, "E1001", 1L);
private final McpSseService service =
new McpSseService(agentToolRunner, bearerAuthenticator, objectMapper);
private final McpSseService service = new McpSseService(
agentToolRunner,
mock(SelectAiService.class),
bearerAuthenticator,
toolCatalog,
mcpProperties,
new BackofficeProperties(null, null, null, null, null),
objectMapper);
@Test
void listsHMMTermDataAndPolicyToolsWithTheirActualInputs() {
ObjectNode response = service.handle("default", request(1, "tools/list"), "valid-token");
void listsEnvironmentConfiguredTermDataAndPolicyTools() {
ObjectNode response =
service.handle("default", request(1, "tools/list"), "valid-token");
var tools = response.path("result").path("tools");
assertThat(tools).hasSize(3);
@@ -31,13 +88,10 @@ class McpSseServiceTest {
assertThat(tools.get(1).path("inputSchema").path("required"))
.extracting(JsonNode::asText)
.containsExactly("query");
assertThat(tools.get(2).path("inputSchema").path("required"))
.extracting(JsonNode::asText)
.containsExactly("query");
}
@Test
void callsHMMTermResolverWithTheApprovedAgentToolAndInputName() {
void callsConfiguredAgentToolWithItsDeclaredInputName() {
ObjectNode request = request(2, "tools/call");
ObjectNode params = request.putObject("params");
params.put("name", "resolve_hr_term");
@@ -50,19 +104,19 @@ class McpSseServiceTest {
assertThat(agentToolRunner.bearerToken).isEqualTo("valid-token");
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("resolve_hr_term")
.contains("HMM_HR_TERM_RESOLVER")
.contains("ANNUAL_LEAVE_CARRYOVER");
}
@Test
void rejectsDiscoveryWhenBearerAuthenticationFails() {
McpSseService rejectingService = new McpSseService(
agentToolRunner,
mock(SelectAiService.class),
token -> {
throw new McpUnauthorizedException();
},
toolCatalog,
mcpProperties,
new BackofficeProperties(null, null, null, null, null),
objectMapper);
assertThatThrownBy(() ->
@@ -71,16 +125,38 @@ class McpSseServiceTest {
}
@Test
void rejectsUnknownToolsWithoutCallingTheAgentRunner() {
void rejectsUnknownToolsWithoutCallingTheRunner() {
ObjectNode request = request(3, "tools/call");
ObjectNode params = request.putObject("params");
params.put("name", "ords.query.kb_select_ai_vpd");
params.putObject("arguments").put("prompt", "legacy query");
params.put("name", "unconfigured.tool");
params.putObject("arguments").put("prompt", "query");
ObjectNode response = service.handle("default", request, "valid-token");
assertThat(response.path("result").isMissingNode()).isTrue();
assertThat(response.path("error").path("message").asText()).contains("등록되지 않은 HMM MCP tool");
assertThat(response.path("error").path("message").asText())
.contains("등록되지 않은 MCP tool");
}
@Test
void rejectsDuplicateToolNamesAtStartup() {
String duplicate = """
[
{"name":"same","label":"A","description":"A","argumentName":"query",
"argumentDescription":"A","executionType":"AGENT_TOOL",
"targetName":"TOOL_A","targetParameterName":"P_QUERY"},
{"name":"same","label":"B","description":"B","argumentName":"query",
"argumentDescription":"B","executionType":"AGENT_TOOL",
"targetName":"TOOL_B","targetParameterName":"P_QUERY"}
]
""";
McpProperties duplicateProperties =
new McpProperties("", "", "", "", "", "", duplicate);
assertThatThrownBy(() ->
new EnvironmentMcpToolCatalog(duplicateProperties, objectMapper))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("BACKOFFICE_MCP_TOOLS");
}
private ObjectNode request(int id, String method) {
@@ -91,7 +167,8 @@ class McpSseServiceTest {
return request;
}
private final class CapturingHmmAiAgentToolRunner implements HmmAiAgentToolRunner {
private final class CapturingHmmAiAgentToolRunner
implements HmmAiAgentToolRunner {
private String toolName;
private ObjectNode input;
@@ -106,9 +183,7 @@ class McpSseServiceTest {
toolName = requestedToolName;
input = requestedInput.deepCopy();
this.bearerToken = bearerToken;
return objectMapper.createObjectNode()
.put("termCode", "ANNUAL_LEAVE_CARRYOVER")
.put("termName", "연차 이월");
return objectMapper.createObjectNode().put("status", "ok");
}
}
}

View File

@@ -0,0 +1,34 @@
package com.cloudhandson.vpdbackoffice.service;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import com.cloudhandson.vpdbackoffice.config.CatalogProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
class SchemaMetadataServiceTest {
@Test
void rejectsAnnotationChangesForConfiguredViewsBeforeAnyDdl() {
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
DataCatalog catalog = new EnvironmentDataCatalog(
new CatalogProperties("ADMIN", """
[{"key":"employee-view","tableName":"HMM_EMPLOYEE_VIEW","objectType":"VIEW",
"businessName":"직원 뷰","description":"읽기 전용 직원 뷰"}]
"""),
new ObjectMapper());
StructuredDataService structuredDataService =
new StructuredDataService(jdbcTemplate, catalog);
SchemaMetadataService service =
new SchemaMetadataService(jdbcTemplate, structuredDataService);
assertThatThrownBy(() ->
service.updateTableAnnotation("employee-view", "DISPLAY_NAME", "직원"))
.isInstanceOf(AppException.class)
.hasMessageContaining("TABLE 객체에서만");
verifyNoInteractions(jdbcTemplate);
}
}

View File

@@ -3,33 +3,51 @@ package com.cloudhandson.vpdbackoffice.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.cloudhandson.vpdbackoffice.config.SecuritySqlScriptProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
class SecuritySqlScriptServiceTest {
private final SecuritySqlScriptService service = new SecuritySqlScriptService();
private final SecuritySqlScriptService service = new SecuritySqlScriptService(
new SecuritySqlScriptProperties("""
[{
"scriptId":"hmm-leave-vpd",
"category":"HMM / VPD",
"fileName":"72_hmm_leave_team_vpd.sql",
"title":"HMM 휴가 팀 접근 정책",
"description":"팀장과 팀원 휴가 행 접근 정책"
}]
"""),
new ObjectMapper());
@Test
void exposesOnlyTheCuratedGitTrackedSecurityScripts() {
void exposesOnlyEnvironmentAllowlistedBundledScripts() {
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"
);
.containsExactly("72_hmm_leave_team_vpd.sql");
assertThat(service.find("select-ai-vpd-ords").source())
.contains("POST /ords/cb-ords/kb-select-ai-vpd/query")
.contains("Authorization: Bearer <VPD token>");
assertThat(service.find("hmm-leave-vpd").source())
.contains("HMM_ACCESS_CTX_PKG")
.contains("HMM_LEAVE_VPD_FILTER");
}
@Test
void rejectsUnknownScriptIdsInsteadOfResolvingAPathFromRequestInput() {
void rejectsUnknownScriptIdsInsteadOfResolvingARequestPath() {
assertThatThrownBy(() -> service.find("../../etc/passwd"))
.isInstanceOf(AppException.class)
.hasMessageContaining("조회할 수 없는");
}
@Test
void rejectsUnsafeResourcePathsAtStartup() {
assertThatThrownBy(() -> new SecuritySqlScriptService(
new SecuritySqlScriptProperties("""
[{"scriptId":"bad","category":"x","fileName":"../../etc/passwd.sql",
"title":"x","description":"x"}]
"""),
new ObjectMapper()))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("BACKOFFICE_SECURITY_SQL_SCRIPTS");
}
}

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 com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
class SelectAiServiceTest {
private final SelectAiService service = new SelectAiService(
new BackofficeProperties(null, null, null, null, null),
token -> new HmmMcpPrincipal(1L, "E1001", 1L),
new ObjectMapper());
@Test
void acceptsOneReadOnlySelectAndRemovesTrailingSemicolon() {
assertThat(service.validateReadOnlySql(
"SELECT employee_code FROM hmm_hr_employees;"))
.isEqualTo("SELECT employee_code FROM hmm_hr_employees");
}
@Test
void rejectsDmlPackagesCommentsAndMultipleStatements() {
assertThatThrownBy(() ->
service.validateReadOnlySql("SELECT * FROM x; DELETE FROM x"))
.isInstanceOf(AppException.class);
assertThatThrownBy(() ->
service.validateReadOnlySql("SELECT DBMS_LOCK.SLEEP(10) FROM dual"))
.isInstanceOf(AppException.class);
assertThatThrownBy(() ->
service.validateReadOnlySql("SELECT * FROM x -- bypass"))
.isInstanceOf(AppException.class);
}
}

View File

@@ -4,61 +4,72 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import com.cloudhandson.vpdbackoffice.config.CatalogProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.core.JdbcTemplate;
class StructuredDataServiceTest {
private final StructuredDataCatalogProvider catalogProvider = new StructuredDataCatalogProvider(
new ObjectMapper(), new ClassPathResource("config/structured-data-catalog.json"));
private final StructuredDataService service = new StructuredDataService(
mock(JdbcTemplate.class), catalogProvider);
private static final String HMM_OBJECTS = """
[
{"key":"teams","tableName":"HMM_ORG_TEAMS","objectType":"TABLE",
"businessName":"조직 원장","description":"조직 정보"},
{"key":"employees","tableName":"HMM_HR_EMPLOYEES","objectType":"TABLE",
"businessName":"직원 원장","description":"직원 정보"},
{"key":"leave-balances","tableName":"HMM_LEAVE_BALANCES","objectType":"VIEW",
"businessName":"휴가 잔여 원장","description":"휴가 잔여 정보"}
]
""";
private final DataCatalog catalog = new EnvironmentDataCatalog(
new CatalogProperties("ADMIN", HMM_OBJECTS), new ObjectMapper());
private final StructuredDataService service =
new StructuredDataService(mock(JdbcTemplate.class), catalog);
@Test
void exposesOnlyTheHMMTablesFromTheJsonCatalog() {
assertThat(service.catalog().sourceName()).isEqualTo("HMMAIPOC");
void exposesOnlyEnvironmentConfiguredTablesAndViews() {
assertThat(service.owner()).isEqualTo("ADMIN");
assertThat(service.tables())
.extracting(table -> table.tableName())
.containsExactly(
"HMM_ORG_TEAMS",
"HMM_HR_EMPLOYEES",
"HMM_LEAVE_BALANCES",
"HMM_LEAVE_REQUESTS",
"HMM_ATTENDANCE_DAILY",
"HMM_HR_TERMS");
"HMM_LEAVE_BALANCES");
assertThat(service.requireTable("leave-balances").objectType()).isEqualTo("VIEW");
assertThat(service.previewSql(service.requireTable("employees")))
.isEqualTo("SELECT * FROM \"ADMIN\".\"HMM_HR_EMPLOYEES\" WHERE ROWNUM <= ?");
}
@Test
void rejectsAnyTableOutsideTheServerSideAllowlist() {
void rejectsAnyObjectOutsideTheServerSideAllowlist() {
assertThatThrownBy(() -> service.requireTable("KB_SECURITY_AUDIT_LOG"))
.isInstanceOf(AppException.class)
.hasMessage("선택할 수 없는 정형 데이터 테이블입니다.");
.hasMessage("선택할 수 없는 카탈로그 객체입니다.");
}
@Test
void rejectsUnsafeOracleIdentifiersInAnExternalCatalog() {
String unsafeJson = """
{
"sourceName": "demo",
"owner": "ADMIN; DROP USER X",
"pageHelp": "help",
"catalogDescription": "description",
"rowLimit": 10,
"tables": [
{"key":"employees","tableName":"EMPLOYEES","businessName":"직원","description":"직원"}
]
}
void rejectsUnsafeOracleIdentifiers() {
assertThatThrownBy(() -> new EnvironmentDataCatalog(
new CatalogProperties("ADMIN; DROP USER X", HMM_OBJECTS), new ObjectMapper()))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("BACKOFFICE_CATALOG_OWNER");
}
@Test
void rejectsDuplicateObjectNamesEvenWhenKeysDiffer() {
String duplicate = """
[
{"key":"employees","tableName":"HMM_HR_EMPLOYEES","objectType":"TABLE",
"businessName":"직원","description":"직원"},
{"key":"workers","tableName":"HMM_HR_EMPLOYEES","objectType":"VIEW",
"businessName":"직원 뷰","description":"직원 뷰"}
]
""";
assertThatThrownBy(() -> new StructuredDataCatalogProvider(
new ObjectMapper(), new ByteArrayResource(unsafeJson.getBytes())))
assertThatThrownBy(() -> new EnvironmentDataCatalog(
new CatalogProperties("ADMIN", duplicate), new ObjectMapper()))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("owner 형식이 올바르지 않습니다");
.hasMessageContaining("BACKOFFICE_CATALOG_OBJECTS");
}
}

View File

@@ -35,7 +35,7 @@ class GuidedFlowTemplateTest {
.doesNotContain("dashboard-workflow")
.doesNotContain(">01<", ">02<");
assertThat(layout)
.contains("HMM HR Access Console")
.contains("Data &amp; AI Backoffice")
.contains("사용자")
.contains("접근 그룹")
.contains("역할")
@@ -73,7 +73,7 @@ class GuidedFlowTemplateTest {
void loginOffersRememberMeOnlyThroughTheSpringSecurityParameter() throws IOException {
assertThat(template("login.html"))
.contains("name=\"remember-me\"")
.contains("데이터 접근 제어 콘솔")
.contains("product?.displayName()")
.contains("<span class=\"login-mark\">접근</span>")
.contains("로그인 유지")
.contains("th:if=\"${rememberMeAvailable}\"")
@@ -265,11 +265,14 @@ class GuidedFlowTemplateTest {
assertThat(reasoning).contains("MCP tool을 고르고").contains("검증 세션 사용자").contains("MCP Tool");
assertThat(client).contains("tool 선택과 호출은 reasoning 결과").doesNotContain("Context Path");
assertThat(sse)
.contains("Instruction / parameter mapping")
.contains("resolve_hr_term")
.contains("search_hr_data")
.contains("search_hr_policy")
.doesNotContain("kb_select_ai_vpd", "KB_CLAIMS");
.contains("tools/list.inputSchema")
.contains("환경 설정으로 승인한 MCP 도구")
.doesNotContain(
"resolve_hr_term",
"search_hr_data",
"search_hr_policy",
"kb_select_ai_vpd",
"KB_CLAIMS");
}
@Test

View File

@@ -30,7 +30,7 @@ class SettingsTemplateRenderTest {
String database = engine.process("settings-database", context);
assertThat(connection)
.contains("HMM HR Agent 도구")
.contains("설정 기반 MCP 도구")
.contains("https://hmm-backoffice.cloud-handson.com/mcp")
.contains("Legacy ORDS Base URL")
.contains("/settings/database")