refs #712: authenticate backoffice MCP user tokens

This commit is contained in:
devmrko
2026-07-23 14:45:18 +09:00
parent ce4b418603
commit 1917df09a2
18 changed files with 525 additions and 37 deletions

View File

@@ -0,0 +1,65 @@
package com.cloudhandson.vpdbackoffice.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.sql.CallableStatement;
import java.sql.Clob;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import org.junit.jupiter.api.Test;
import org.mockito.InOrder;
import org.springframework.jdbc.core.ConnectionCallback;
import org.springframework.jdbc.core.JdbcTemplate;
class JdbcHmmAiAgentToolRunnerTest {
@Test
void setsAndClearsHMMContextOnTheSameConnection() throws Exception {
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
Connection connection = mock(Connection.class);
CallableStatement setContext = mock(CallableStatement.class);
CallableStatement clearContext = mock(CallableStatement.class);
PreparedStatement runTool = mock(PreparedStatement.class);
ResultSet resultSet = mock(ResultSet.class);
Clob clob = mock(Clob.class);
when(jdbcTemplate.execute(any(ConnectionCallback.class))).thenAnswer(invocation -> {
ConnectionCallback<?> callback = invocation.getArgument(0);
return callback.doInConnection(connection);
});
when(connection.prepareCall(
"BEGIN ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(?); END;"))
.thenReturn(setContext);
when(connection.prepareCall("BEGIN ADMIN.HMM_ACCESS_CTX_PKG.CLEAR_USER; END;"))
.thenReturn(clearContext);
when(connection.prepareStatement(any(String.class))).thenReturn(runTool);
when(runTool.executeQuery()).thenReturn(resultSet);
when(resultSet.next()).thenReturn(true);
when(resultSet.getClob(1)).thenReturn(clob);
when(clob.length()).thenReturn(15L);
when(clob.getSubString(1, 15)).thenReturn("{\"status\":\"ok\"}");
var runner = new JdbcHmmAiAgentToolRunner(jdbcTemplate, new ObjectMapper());
var result = runner.run(
"HMM_HR_TERM_RESOLVER",
new ObjectMapper().createObjectNode().put("P_TERM", "annual leave"),
"opaque-user-token");
assertThat(result.path("status").asText()).isEqualTo("ok");
InOrder order = inOrder(connection, setContext, runTool, clearContext);
order.verify(connection).prepareCall(
"BEGIN ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(?); END;");
order.verify(setContext).setString(1, "opaque-user-token");
order.verify(setContext).execute();
order.verify(connection).prepareStatement(any(String.class));
order.verify(runTool).executeQuery();
order.verify(connection).prepareCall("BEGIN ADMIN.HMM_ACCESS_CTX_PKG.CLEAR_USER; END;");
order.verify(clearContext).execute();
}
}

View File

@@ -1,6 +1,7 @@
package com.cloudhandson.vpdbackoffice.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -11,11 +12,14 @@ class McpSseServiceTest {
private final ObjectMapper objectMapper = new ObjectMapper();
private final CapturingHmmAiAgentToolRunner agentToolRunner = new CapturingHmmAiAgentToolRunner();
private final McpSseService service = new McpSseService(agentToolRunner, objectMapper);
private final HmmMcpBearerAuthenticator bearerAuthenticator =
token -> new HmmMcpPrincipal(1L, "E1001", 1L);
private final McpSseService service =
new McpSseService(agentToolRunner, bearerAuthenticator, objectMapper);
@Test
void listsHMMTermDataAndPolicyToolsWithTheirActualInputs() {
ObjectNode response = service.handle("default", request(1, "tools/list"));
ObjectNode response = service.handle("default", request(1, "tools/list"), "valid-token");
var tools = response.path("result").path("tools");
assertThat(tools).hasSize(3);
@@ -39,10 +43,11 @@ class McpSseServiceTest {
params.put("name", "resolve_hr_term");
params.putObject("arguments").put("term", "연차 이월");
ObjectNode response = service.handle("default", request, "ignored-by-backoffice-session");
ObjectNode response = service.handle("default", request, "valid-token");
assertThat(agentToolRunner.toolName).isEqualTo("HMM_HR_TERM_RESOLVER");
assertThat(agentToolRunner.input.path("P_TERM").asText()).isEqualTo("연차 이월");
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())
@@ -51,6 +56,20 @@ class McpSseServiceTest {
.contains("ANNUAL_LEAVE_CARRYOVER");
}
@Test
void rejectsDiscoveryWhenBearerAuthenticationFails() {
McpSseService rejectingService = new McpSseService(
agentToolRunner,
token -> {
throw new McpUnauthorizedException();
},
objectMapper);
assertThatThrownBy(() ->
rejectingService.handle("default", request(4, "tools/list"), "invalid-token"))
.isInstanceOf(McpUnauthorizedException.class);
}
@Test
void rejectsUnknownToolsWithoutCallingTheAgentRunner() {
ObjectNode request = request(3, "tools/call");
@@ -58,7 +77,7 @@ class McpSseServiceTest {
params.put("name", "ords.query.kb_select_ai_vpd");
params.putObject("arguments").put("prompt", "legacy query");
ObjectNode response = service.handle("default", request);
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");
@@ -76,11 +95,17 @@ class McpSseServiceTest {
private String toolName;
private ObjectNode input;
private String bearerToken;
@Override
public JsonNode run(String requestedToolName, ObjectNode requestedInput) {
public JsonNode run(
String requestedToolName,
ObjectNode requestedInput,
String bearerToken
) {
toolName = requestedToolName;
input = requestedInput.deepCopy();
this.bearerToken = bearerToken;
return objectMapper.createObjectNode()
.put("termCode", "ANNUAL_LEAVE_CARRYOVER")
.put("termName", "연차 이월");

View File

@@ -24,14 +24,14 @@ class SettingsTemplateRenderTest {
var context = new Context(Locale.KOREAN);
context.setVariable("_csrf", new CsrfFixture("_csrf", "test-token"));
context.setVariable("ordsBaseUrl", "https://ords.example.test/ords");
context.setVariable("hmmMcpPublicUrl", "https://hmm-mcp.cloud-handson.com/mcp");
context.setVariable("hmmMcpPublicUrl", "https://hmm-backoffice.cloud-handson.com/mcp");
String connection = engine.process("settings", context);
String database = engine.process("settings-database", context);
assertThat(connection)
.contains("HMM HR Agent 도구")
.contains("https://hmm-mcp.cloud-handson.com/mcp")
.contains("https://hmm-backoffice.cloud-handson.com/mcp")
.contains("Legacy ORDS Base URL")
.contains("/settings/database")
.doesNotContain("g329127dfd380ad-kbaipoc")