@@ -0,0 +1,226 @@
|
||||
package com.cloudhandson.vpdbackoffice.config;
|
||||
|
||||
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;
|
||||
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.autoconfigure.web.ServerProperties;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.boot.context.properties.bind.Binder;
|
||||
import org.springframework.boot.env.YamlPropertySourceLoader;
|
||||
import org.springframework.boot.test.util.TestPropertyValues;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.core.env.MapPropertySource;
|
||||
import org.springframework.core.env.StandardEnvironment;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockServletContext;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
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;
|
||||
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
|
||||
|
||||
class TransportSecurityTest {
|
||||
|
||||
private AnnotationConfigWebApplicationContext context;
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
context = new AnnotationConfigWebApplicationContext();
|
||||
context.setServletContext(new MockServletContext());
|
||||
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",
|
||||
"backoffice.ai.enabled=false",
|
||||
"backoffice.ai.timeout=30s"
|
||||
).applyTo(context);
|
||||
context.register(TestWebConfig.class);
|
||||
context.refresh();
|
||||
|
||||
mockMvc = MockMvcBuilders.webAppContextSetup(context)
|
||||
.addFilters(new ForwardedHeaderFilter())
|
||||
.apply(springSecurity())
|
||||
.build();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
context.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void directHttpRequestRedirectsToHttps() throws Exception {
|
||||
mockMvc.perform(get("/login"))
|
||||
.andExpect(status().is3xxRedirection())
|
||||
.andExpect(redirectedUrl("https://localhost/login"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardedHttpsRequestIsAcceptedAndReturnsHsts() throws Exception {
|
||||
mockMvc.perform(get("/login")
|
||||
.header("X-Forwarded-Proto", "https")
|
||||
.header("X-Forwarded-Host", "admin.example.test"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().string(
|
||||
"Strict-Transport-Security",
|
||||
containsString("max-age=31536000")
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardedHostIsUsedForAuthenticationRedirect() throws Exception {
|
||||
mockMvc.perform(get("/")
|
||||
.accept(MediaType.TEXT_HTML)
|
||||
.header("X-Forwarded-Proto", "https")
|
||||
.header("X-Forwarded-Host", "admin.example.test"))
|
||||
.andExpect(status().is3xxRedirection())
|
||||
.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();
|
||||
environment.getPropertySources().addFirst(new MapPropertySource("test-overrides", Map.of(
|
||||
"BACKOFFICE_FORWARD_HEADERS_STRATEGY", "framework",
|
||||
"BACKOFFICE_SESSION_COOKIE_SECURE", "true"
|
||||
)));
|
||||
var loader = new YamlPropertySourceLoader();
|
||||
for (var source : loader.load("application", new ClassPathResource("application.yml"))) {
|
||||
environment.getPropertySources().addLast(source);
|
||||
}
|
||||
|
||||
ServerProperties properties = Binder.get(environment)
|
||||
.bind("server", ServerProperties.class)
|
||||
.orElseThrow(() -> new AssertionError("server properties were not bound"));
|
||||
var cookie = properties.getServlet().getSession().getCookie();
|
||||
|
||||
assertThat(cookie.getSecure()).isTrue();
|
||||
assertThat(cookie.getHttpOnly()).isTrue();
|
||||
assertThat(cookie.getSameSite().attributeValue()).isEqualTo("Lax");
|
||||
assertThat(properties.getForwardHeadersStrategy().name()).isEqualTo("FRAMEWORK");
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableWebMvc
|
||||
@EnableWebSecurity
|
||||
@EnableConfigurationProperties(BackofficeProperties.class)
|
||||
@Import({SecurityConfig.class, TestController.class})
|
||||
static class TestWebConfig {
|
||||
}
|
||||
|
||||
@RestController
|
||||
static class TestController {
|
||||
|
||||
@GetMapping("/login")
|
||||
String login() {
|
||||
return "login";
|
||||
}
|
||||
|
||||
@GetMapping("/")
|
||||
String home() {
|
||||
return "home";
|
||||
}
|
||||
|
||||
@PostMapping("/mutation")
|
||||
String mutation() {
|
||||
return "changed";
|
||||
}
|
||||
|
||||
@PostMapping("/probe")
|
||||
String probe() {
|
||||
return "queried";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("지원하지 않는");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.permission;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class PermissionViewTest {
|
||||
|
||||
@Test
|
||||
void translatesHmmLeaveScopeRulesForBackofficeUsers() {
|
||||
PermissionView view = new PermissionView(
|
||||
1L,
|
||||
2L,
|
||||
"HMM_HR_MANAGER",
|
||||
10L,
|
||||
"HMM_LEAVE_REQUESTS",
|
||||
"SELECT",
|
||||
"ALLOW",
|
||||
"EMPLOYEE_ID MANAGED_TEAM,EMPLOYEE_ID SELF",
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
assertThat(view.businessRuleSummary())
|
||||
.contains("본인 및 직접 보고 팀원 행")
|
||||
.contains("본인 직원 행");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.probe;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ProbeResultTest {
|
||||
|
||||
@Test
|
||||
void explainsSuccessfulResultInPlainLanguage() {
|
||||
ProbeResult result = new ProbeResult(
|
||||
ProbeStatus.SUCCESS,
|
||||
List.of("DOC_ID"),
|
||||
List.of(Map.of("DOC_ID", 1), Map.of("DOC_ID", 2)),
|
||||
2,
|
||||
List.of(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
assertThat(result.successLike()).isTrue();
|
||||
assertThat(result.title()).contains("데이터를 볼 수 있습니다");
|
||||
assertThat(result.plainSummary()).contains("2개").contains("행 접근 정책");
|
||||
assertThat(result.nextAction()).contains("예상한 범위");
|
||||
}
|
||||
|
||||
@Test
|
||||
void treatsEmptyRowsAsAnEnforcedPermissionOutcome() {
|
||||
ProbeResult result = new ProbeResult(
|
||||
ProbeStatus.VPD_DENY_EMPTY_RESULT,
|
||||
List.of(),
|
||||
List.of(),
|
||||
0,
|
||||
List.of(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
assertThat(result.successLike()).isTrue();
|
||||
assertThat(result.title()).contains("볼 수 있는 행이 없습니다");
|
||||
assertThat(result.plainSummary()).contains("오류가 아닐 수 있습니다");
|
||||
assertThat(result.nextAction()).contains("유효 권한");
|
||||
}
|
||||
|
||||
@Test
|
||||
void givesActionableGuidanceForUnknownToken() {
|
||||
ProbeResult result = ProbeResult.blocked(
|
||||
ProbeStatus.TOKEN_NOT_FOUND,
|
||||
"TOKEN_NOT_FOUND",
|
||||
"토큰을 찾을 수 없습니다."
|
||||
);
|
||||
|
||||
assertThat(result.successLike()).isFalse();
|
||||
assertThat(result.title()).contains("등록되지 않은 토큰");
|
||||
assertThat(result.plainSummary()).contains("DB").contains("원문");
|
||||
assertThat(result.nextAction()).contains("새 토큰").contains("발급");
|
||||
}
|
||||
|
||||
@Test
|
||||
void distinguishesInactiveTokenAndOrdsFailure() {
|
||||
ProbeResult inactive = ProbeResult.blocked(
|
||||
ProbeStatus.TOKEN_INACTIVE,
|
||||
"TOKEN_INACTIVE",
|
||||
"만료되었거나 회수된 토큰입니다."
|
||||
);
|
||||
ProbeResult unavailable = ProbeResult.blocked(
|
||||
ProbeStatus.ORDS_UNAVAILABLE,
|
||||
"ORDS_UNAVAILABLE",
|
||||
"연결할 수 없습니다."
|
||||
);
|
||||
|
||||
assertThat(inactive.title()).contains("만료되었거나 회수");
|
||||
assertThat(inactive.nextAction()).contains("활성 토큰");
|
||||
assertThat(unavailable.title()).contains("ORDS");
|
||||
assertThat(unavailable.nextAction()).contains("행 접근 규칙을 바꾸지 말고");
|
||||
}
|
||||
|
||||
@Test
|
||||
void identifiesBrokenCustomVpdFilterSeparatelyFromTokenErrors() {
|
||||
ProbeResult result = ProbeResult.blocked(
|
||||
ProbeStatus.VPD_FILTER_ERROR,
|
||||
"VPD_FILTER_ERROR",
|
||||
"ORA-28110"
|
||||
);
|
||||
|
||||
assertThat(result.title()).contains("행 접근 Filter");
|
||||
assertThat(result.plainSummary()).contains("토큰과 사용자 권한은 확인");
|
||||
assertThat(result.nextAction()).contains("토큰이나 행 접근 규칙을 바꾸지 말고").contains("표준 행 접근 Filter");
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposesVpdSqlTraceWithoutChangingTheRows() {
|
||||
ProbeResult result = new ProbeResult(
|
||||
ProbeStatus.SUCCESS,
|
||||
List.of("DEPT_CODE"),
|
||||
List.of(Map.of("DEPT_CODE", "HR")),
|
||||
1,
|
||||
List.of(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
).withSqlTrace(
|
||||
"(DEPT_CODE = SYS_CONTEXT('CB_AGENT_CTX', 'DEPT_CODE'))",
|
||||
"SELECT * FROM ADMIN.DOCUMENTS WHERE (DEPT_CODE = SYS_CONTEXT('CB_AGENT_CTX', 'DEPT_CODE'))"
|
||||
);
|
||||
|
||||
assertThat(result.hasSqlTrace()).isTrue();
|
||||
assertThat(result.vpdPredicate()).contains("DEPT_CODE");
|
||||
assertThat(result.effectiveSql()).contains("ADMIN.DOCUMENTS");
|
||||
assertThat(result.rowCount()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsDatabaseExecutionEvidenceSeparateFromTheReconstructedSql() {
|
||||
SqlExecutionEvidence evidence = new SqlExecutionEvidence(
|
||||
"4f2jz7n2k0s3p",
|
||||
1,
|
||||
"SELECT d.doc_id FROM ADMIN.CB_V_SEARCH_DOCUMENTS d WHERE ROWNUM <= :1",
|
||||
"2026-07-01T10:00:00",
|
||||
3,
|
||||
12,
|
||||
35,
|
||||
120,
|
||||
"Predicate Information (identified by operation id):\n1 - filter(DEPT_CODE = SYS_CONTEXT(...))"
|
||||
);
|
||||
ProbeResult result = new ProbeResult(
|
||||
ProbeStatus.SUCCESS,
|
||||
List.of("DOC_ID"),
|
||||
List.of(Map.of("DOC_ID", 1)),
|
||||
1,
|
||||
List.of(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
).withSqlTrace("DEPT_CODE = SYS_CONTEXT(...)", "SELECT ... WHERE (...)")
|
||||
.withExecutionEvidence(evidence, null);
|
||||
|
||||
assertThat(result.hasExecutionEvidence()).isTrue();
|
||||
assertThat(result.executionEvidence().sqlId()).isEqualTo("4f2jz7n2k0s3p");
|
||||
assertThat(result.executionEvidence().hasPredicatePlan()).isTrue();
|
||||
assertThat(result.effectiveSql()).contains("WHERE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsRecentCursorCandidatesWhenNoObjectMatchWasFound() {
|
||||
ProbeResult result = new ProbeResult(
|
||||
ProbeStatus.SUCCESS,
|
||||
List.of(),
|
||||
List.of(),
|
||||
0,
|
||||
List.of(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
).withExecutionEvidence(null, "최근 cursor를 찾지 못했습니다.", List.of(
|
||||
new SqlExecutionCandidate(
|
||||
"candidate1", 0, "CB_ORDS", "SELECT 1 FROM dual", "2026-07-01T15:00", false)
|
||||
));
|
||||
|
||||
assertThat(result.hasExecutionEvidence()).isFalse();
|
||||
assertThat(result.hasExecutionCandidates()).isTrue();
|
||||
assertThat(result.executionCandidates()).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsAuditedExecutionEvidenceSeparateFromTheReconstructedSql() {
|
||||
FgaExecutionEvidence evidence = new FgaExecutionEvidence(
|
||||
"2026-07-01T10:00:00Z",
|
||||
"CB_ORDS",
|
||||
"probe-123",
|
||||
"SELECT",
|
||||
"SELECT d.doc_id FROM ADMIN.CB_V_SEARCH_DOCUMENTS d",
|
||||
"CB_AGENT_DOC_POLICY: (DEPT_CODE = 'HR')"
|
||||
);
|
||||
ProbeResult result = new ProbeResult(
|
||||
ProbeStatus.SUCCESS,
|
||||
List.of("DOC_ID"),
|
||||
List.of(Map.of("DOC_ID", 1)),
|
||||
1,
|
||||
List.of(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
).withSqlTrace("DEPT_CODE = SYS_CONTEXT(...)", "SELECT ... WHERE (...)")
|
||||
.withFgaExecutionEvidence(evidence, null);
|
||||
|
||||
assertThat(result.hasFgaExecutionEvidence()).isTrue();
|
||||
assertThat(result.fgaExecutionEvidence().clientId()).isEqualTo("probe-123");
|
||||
assertThat(result.fgaExecutionEvidence().hasRlsInfo()).isTrue();
|
||||
assertThat(result.effectiveSql()).contains("WHERE");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.protectedobject;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ProtectedColumnTest {
|
||||
|
||||
@Test
|
||||
void explainsPublicColumnAsDisplayDefaultWithoutCallingItAccessPermission() {
|
||||
ProtectedColumn column = new ProtectedColumn(1L, 1L, "TITLE", "N", null, "PUBLIC", "NONE");
|
||||
|
||||
assertThat(column.sensitive()).isFalse();
|
||||
assertThat(column.sensitivityLabel()).isEqualTo("기본 표시");
|
||||
assertThat(column.redactionLabel()).isEqualTo("마스킹 없음");
|
||||
assertThat(column.displayPolicyLabel()).isEqualTo("기본 표시 · 마스킹 없음");
|
||||
}
|
||||
|
||||
@Test
|
||||
void explainsProtectedColumnAsPostAccessDisplayHandling() {
|
||||
ProtectedColumn column = new ProtectedColumn(2L, 1L, "EMAIL", "Y", null, "CONFIDENTIAL", "NULLIFY");
|
||||
|
||||
assertThat(column.sensitive()).isTrue();
|
||||
assertThat(column.displayPolicyLabel()).isEqualTo("기밀 · 값 숨김(NULL)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.vpd;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class VpdPolicyFormOptionsTest {
|
||||
|
||||
@Test
|
||||
void choosesHmmFilterForHmmLeaveObjects() {
|
||||
VpdPolicyFormOptions options = new VpdPolicyFormOptions(
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of(
|
||||
new VpdFunctionOption("ADMIN", null, "CB_AGENT_DOC_VPD_FILTER", "FUNCTION"),
|
||||
new VpdFunctionOption("ADMIN", null, "HMM_LEAVE_VPD_FILTER", "FUNCTION")
|
||||
),
|
||||
List.of(),
|
||||
List.of("SELECT")
|
||||
);
|
||||
|
||||
assertThat(options.defaultPermissionFunctionKey("HMM_LEAVE_BALANCES"))
|
||||
.isEqualTo("ADMIN.HMM_LEAVE_VPD_FILTER");
|
||||
assertThat(options.defaultPermissionFunctionKey("HMM_LEAVE_REQUESTS"))
|
||||
.isEqualTo("ADMIN.HMM_LEAVE_VPD_FILTER");
|
||||
assertThat(options.defaultPermissionFunctionKey("CB_AGENT_DOCUMENTS"))
|
||||
.isEqualTo("ADMIN.CB_AGENT_DOC_VPD_FILTER");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.effective.EffectiveMatrixView;
|
||||
import com.cloudhandson.vpdbackoffice.domain.group.AppGroup;
|
||||
import com.cloudhandson.vpdbackoffice.domain.group.GroupRoleView;
|
||||
import com.cloudhandson.vpdbackoffice.domain.group.GroupUserView;
|
||||
import com.cloudhandson.vpdbackoffice.domain.permission.AppRole;
|
||||
import com.cloudhandson.vpdbackoffice.domain.permission.PermissionView;
|
||||
import com.cloudhandson.vpdbackoffice.domain.user.AppUser;
|
||||
import com.cloudhandson.vpdbackoffice.domain.user.UserRoleView;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class EffectiveMatrixServiceTest {
|
||||
|
||||
@Test
|
||||
void combinesDirectAndGroupInheritedRoles() {
|
||||
EffectiveMatrixService service = new EffectiveMatrixService(
|
||||
new UserService(null, null) {
|
||||
@Override
|
||||
public List<AppUser> findAll() {
|
||||
return List.of(new AppUser(1L, "agent_ops", "E100", "OPS", "N", "Y"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<UserRoleView> findUserRoles() {
|
||||
return List.of(new UserRoleView(1L, "agent_ops", 10L, "DIRECT_ROLE"));
|
||||
}
|
||||
},
|
||||
new GroupService(null, null) {
|
||||
@Override
|
||||
public List<AppGroup> findAll() {
|
||||
return List.of(new AppGroup(100L, "OPS_GROUP", "운영그룹", null, "Y"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<GroupUserView> findGroupUsers() {
|
||||
return List.of(new GroupUserView(100L, "OPS_GROUP", "운영그룹", 1L, "agent_ops"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<GroupRoleView> findGroupRoles() {
|
||||
return List.of(new GroupRoleView(100L, "OPS_GROUP", "운영그룹", 20L, "GROUP_ROLE"));
|
||||
}
|
||||
},
|
||||
new PermissionService(null, null, null) {
|
||||
@Override
|
||||
public List<AppRole> findRoles() {
|
||||
return List.of(
|
||||
new AppRole(10L, "DIRECT_ROLE", null, "PUBLIC"),
|
||||
new AppRole(20L, "GROUP_ROLE", null, "INTERNAL")
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<PermissionView> findPermissionViews() {
|
||||
return List.of(
|
||||
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", "컬럼 원문/마스킹은 컬럼 마스킹에서 관리")
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
EffectiveMatrixView matrix = service.matrix();
|
||||
|
||||
assertThat(matrix.users()).hasSize(1);
|
||||
assertThat(matrix.users().getFirst().directRoles()).containsExactly("DIRECT_ROLE");
|
||||
assertThat(matrix.users().getFirst().inheritedRoles()).containsExactly("GROUP_ROLE");
|
||||
assertThat(matrix.users().getFirst().effectiveRoles()).containsExactly("DIRECT_ROLE", "GROUP_ROLE");
|
||||
assertThat(matrix.users().getFirst().permissionCount()).isEqualTo(2);
|
||||
assertThat(matrix.groups().getFirst().users()).containsExactly("agent_ops");
|
||||
assertThat(matrix.groups().getFirst().roles()).containsExactly("GROUP_ROLE");
|
||||
assertThat(matrix.roles().get(1).affectedUsers()).containsExactly("agent_ops");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.setting.BackofficeSetting;
|
||||
import com.cloudhandson.vpdbackoffice.mapper.SettingMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.boot.DefaultApplicationArguments;
|
||||
|
||||
class HmmRuntimeSettingsInitializerTest {
|
||||
|
||||
@Test
|
||||
void removesOnlyTheRetiredKbOrdsSetting() {
|
||||
SettingMapper mapper = Mockito.mock(SettingMapper.class);
|
||||
when(mapper.findByKey(SettingService.ORDS_BASE_URL)).thenReturn(new BackofficeSetting(
|
||||
SettingService.ORDS_BASE_URL,
|
||||
"https://g329127dfd380ad-kbaipoc.adb.ap-osaka-1.oraclecloudapps.com/ords/"));
|
||||
|
||||
new HmmRuntimeSettingsInitializer(mapper).run(new DefaultApplicationArguments());
|
||||
|
||||
verify(mapper).deleteByKey(SettingService.ORDS_BASE_URL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsAnOperatorConfiguredLegacyOrdsSetting() {
|
||||
SettingMapper mapper = Mockito.mock(SettingMapper.class);
|
||||
when(mapper.findByKey(SettingService.ORDS_BASE_URL)).thenReturn(new BackofficeSetting(
|
||||
SettingService.ORDS_BASE_URL, "https://ords.hmm.example/ords"));
|
||||
|
||||
new HmmRuntimeSettingsInitializer(mapper).run(new DefaultApplicationArguments());
|
||||
|
||||
verify(mapper, never()).deleteByKey(SettingService.ORDS_BASE_URL);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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.jdbc.core.JdbcTemplate;
|
||||
|
||||
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 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),
|
||||
dataCatalog,
|
||||
policyCatalog);
|
||||
|
||||
assertThat(synchronizer.owner()).isEqualTo("ADMIN");
|
||||
assertThat(synchronizer.managedPolicyName("HMM_HR_EMPLOYEES"))
|
||||
.isEqualTo("HMM_EMPLOYEE_PII_REDACT");
|
||||
assertThat(synchronizer.managedObjectNames())
|
||||
.doesNotContain("KB_CUSTOMERS", "KB_CLAIMS", "KB_CONTRACTS");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
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 static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
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;
|
||||
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 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,
|
||||
mock(SelectAiService.class),
|
||||
bearerAuthenticator,
|
||||
toolCatalog,
|
||||
mcpProperties,
|
||||
new BackofficeProperties(null, null, null, null, null),
|
||||
objectMapper);
|
||||
|
||||
@Test
|
||||
void listsEnvironmentConfiguredTermDataAndPolicyTools() {
|
||||
ObjectNode response =
|
||||
service.handle("default", request(1, "tools/list"), "valid-token");
|
||||
|
||||
var tools = response.path("result").path("tools");
|
||||
assertThat(tools).hasSize(3);
|
||||
assertThat(tools).extracting(node -> node.path("name").asText())
|
||||
.containsExactly("resolve_hr_term", "search_hr_data", "search_hr_policy");
|
||||
assertThat(tools.get(0).path("inputSchema").path("required"))
|
||||
.extracting(JsonNode::asText)
|
||||
.containsExactly("term");
|
||||
assertThat(tools.get(1).path("inputSchema").path("required"))
|
||||
.extracting(JsonNode::asText)
|
||||
.containsExactly("query");
|
||||
}
|
||||
|
||||
@Test
|
||||
void callsConfiguredAgentToolWithItsDeclaredInputName() {
|
||||
ObjectNode request = request(2, "tools/call");
|
||||
ObjectNode params = request.putObject("params");
|
||||
params.put("name", "resolve_hr_term");
|
||||
params.putObject("arguments").put("term", "연차 이월");
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@Test
|
||||
void routesSelectAiToolThroughTheVpdAwareExecutor() {
|
||||
String selectAiTools = """
|
||||
[
|
||||
{
|
||||
"name":"search_hr_data",
|
||||
"label":"HMM HR 데이터 조회",
|
||||
"description":"VPD가 적용된 HMM HR 데이터를 조회합니다.",
|
||||
"argumentName":"query",
|
||||
"argumentDescription":"완전한 자연어 질문입니다.",
|
||||
"executionType":"SELECT_AI"
|
||||
}
|
||||
]
|
||||
""";
|
||||
McpProperties selectAiProperties =
|
||||
new McpProperties("", "", "", "", "", "", selectAiTools);
|
||||
SelectAiService selectAiService = mock(SelectAiService.class);
|
||||
when(selectAiService.generateAndExecute(
|
||||
"valid-token", "내 휴가 신청 내역을 보여줘"))
|
||||
.thenReturn(objectMapper.createObjectNode().put("vpdEnforced", true));
|
||||
McpSseService selectAiMcp = new McpSseService(
|
||||
agentToolRunner,
|
||||
selectAiService,
|
||||
bearerAuthenticator,
|
||||
new EnvironmentMcpToolCatalog(selectAiProperties, objectMapper),
|
||||
selectAiProperties,
|
||||
new BackofficeProperties(null, null, null, null, null),
|
||||
objectMapper);
|
||||
ObjectNode request = request(5, "tools/call");
|
||||
request.putObject("params")
|
||||
.put("name", "search_hr_data")
|
||||
.putObject("arguments")
|
||||
.put("query", "내 휴가 신청 내역을 보여줘");
|
||||
|
||||
ObjectNode response =
|
||||
selectAiMcp.handle("default", request, "valid-token");
|
||||
|
||||
verify(selectAiService).generateAndExecute(
|
||||
"valid-token", "내 휴가 신청 내역을 보여줘");
|
||||
assertThat(response.path("result").path("isError").asBoolean()).isFalse();
|
||||
assertThat(response.path("result").path("content").get(0).path("text").asText())
|
||||
.contains("\"vpdEnforced\" : true");
|
||||
}
|
||||
|
||||
@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(() ->
|
||||
rejectingService.handle("default", request(4, "tools/list"), "invalid-token"))
|
||||
.isInstanceOf(McpUnauthorizedException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnknownToolsWithoutCallingTheRunner() {
|
||||
ObjectNode request = request(3, "tools/call");
|
||||
ObjectNode params = request.putObject("params");
|
||||
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("등록되지 않은 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) {
|
||||
ObjectNode request = objectMapper.createObjectNode();
|
||||
request.put("jsonrpc", "2.0");
|
||||
request.put("id", id);
|
||||
request.put("method", method);
|
||||
return request;
|
||||
}
|
||||
|
||||
private final class CapturingHmmAiAgentToolRunner
|
||||
implements HmmAiAgentToolRunner {
|
||||
|
||||
private String toolName;
|
||||
private ObjectNode input;
|
||||
private String bearerToken;
|
||||
|
||||
@Override
|
||||
public JsonNode run(
|
||||
String requestedToolName,
|
||||
ObjectNode requestedInput,
|
||||
String bearerToken
|
||||
) {
|
||||
toolName = requestedToolName;
|
||||
input = requestedInput.deepCopy();
|
||||
this.bearerToken = bearerToken;
|
||||
return objectMapper.createObjectNode().put("status", "ok");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn;
|
||||
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
|
||||
import com.cloudhandson.vpdbackoffice.mapper.OrdsMetadataMapper;
|
||||
import java.util.List;
|
||||
import javax.sql.DataSource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
class OrdsMetadataServiceTest {
|
||||
|
||||
@Test
|
||||
void generatedObjectHandlerIncludesTokenSpecificVpdSqlTrace() {
|
||||
OrdsMetadataMapper mapper = mock(OrdsMetadataMapper.class);
|
||||
ProtectedObjectService protectedObjectService = mock(ProtectedObjectService.class);
|
||||
DataSource dataSource = mock(DataSource.class);
|
||||
ProtectedObject object = new ProtectedObject(
|
||||
7L,
|
||||
"ADMIN",
|
||||
"CB_V_SEARCH_DOCUMENTS",
|
||||
"cb-ords/cb-object-query/admin/cb_v_search_documents",
|
||||
"Y"
|
||||
);
|
||||
when(protectedObjectService.assertEnabled(7L)).thenReturn(object);
|
||||
when(protectedObjectService.findColumns(7L)).thenReturn(List.of(
|
||||
new ProtectedColumn(1L, 7L, "DOC_ID", "N", null, "PUBLIC", "NONE"),
|
||||
new ProtectedColumn(2L, 7L, "DEPT_CODE", "N", null, "INTERNAL", "NONE")
|
||||
));
|
||||
|
||||
OrdsMetadataService service = new OrdsMetadataService(
|
||||
mapper,
|
||||
new JdbcTemplate(dataSource),
|
||||
protectedObjectService,
|
||||
dataSource,
|
||||
"",
|
||||
"",
|
||||
""
|
||||
);
|
||||
|
||||
String source = service.objectQueryHandlerSource(7L);
|
||||
|
||||
assertThat(source)
|
||||
.contains("cb_ords_handler_pkg.set_vpd_context(:auth_header, :probe_id)")
|
||||
.contains("admin.cb_agent_doc_vpd_filter('ADMIN', 'CB_V_SEARCH_DOCUMENTS')")
|
||||
.contains("v_vpd_predicate")
|
||||
.contains("APEX_JSON.WRITE('effective_sql', v_effective_sql)")
|
||||
.contains("SELECT o.DOC_ID, o.DEPT_CODE FROM ADMIN.CB_V_SEARCH_DOCUMENTS o");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class OrdsProbeServiceTest {
|
||||
|
||||
@Test
|
||||
void buildsVectorEffectiveSqlWithVpdPredicateAndDistancePlaceholder() {
|
||||
ProtectedObject object = new ProtectedObject(
|
||||
5L, "ADMIN", "CB_VECTOR_SEARCH_DOCUMENTS", "cb-agent-vector/search", "Y");
|
||||
|
||||
String sql = OrdsProbeService.vectorEffectiveSql(
|
||||
object, "INSTR(',' || UPPER(d.TECH_TAG) || ',', ',ORACLE_VPD,') > 0");
|
||||
|
||||
assertThat(sql)
|
||||
.contains("FROM ADMIN.CB_VECTOR_SEARCH_DOCUMENTS d")
|
||||
.contains("VECTOR_DISTANCE(d.embedding, TO_VECTOR(:embedding), COSINE)")
|
||||
.contains("WHERE d.embedding IS NOT NULL AND (INSTR")
|
||||
.contains("ORDER BY score")
|
||||
.contains("ROWNUM <= LEAST(GREATEST(NVL(:row_limit, 10), 1), 100)")
|
||||
.doesNotContain("Bearer", "0.10", "0.20");
|
||||
}
|
||||
|
||||
@Test
|
||||
void omitsNoopAllowAllPredicateFromVectorEffectiveSql() {
|
||||
ProtectedObject object = new ProtectedObject(
|
||||
5L, "ADMIN", "CB_VECTOR_SEARCH_DOCUMENTS", "cb-agent-vector/search", "Y");
|
||||
|
||||
String sql = OrdsProbeService.vectorEffectiveSql(object, " 1 = 1 ");
|
||||
|
||||
assertThat(sql)
|
||||
.contains("WHERE d.embedding IS NOT NULL ORDER BY score")
|
||||
.doesNotContain("AND (1 = 1)", "REGEXP_LIKE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void filtersRecentCursorCandidatesInJavaByTheProtectedObjectName() {
|
||||
ProtectedObject object = new ProtectedObject(
|
||||
1L, "ADMIN", "CB_V_SEARCH_DOCUMENTS", "cb-ords/cb-object-query/admin/documents", "Y");
|
||||
|
||||
assertThat(OrdsProbeService.referencesProtectedObject(
|
||||
"SELECT *\nFROM ADMIN.CB_V_SEARCH_DOCUMENTS o WHERE ROWNUM <= :1", object)).isTrue();
|
||||
assertThat(OrdsProbeService.referencesProtectedObject(
|
||||
"SELECT * FROM ADMIN.CB_VECTOR_SEARCH_DOCUMENTS", object)).isFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.assertj.core.groups.Tuple.tuple;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent;
|
||||
import com.cloudhandson.vpdbackoffice.domain.permission.AppRole;
|
||||
import com.cloudhandson.vpdbackoffice.domain.permission.PermissionRule;
|
||||
import com.cloudhandson.vpdbackoffice.domain.permission.PermissionSet;
|
||||
import com.cloudhandson.vpdbackoffice.domain.permission.PermissionSetCommand;
|
||||
import com.cloudhandson.vpdbackoffice.domain.permission.PermissionView;
|
||||
import com.cloudhandson.vpdbackoffice.domain.permission.RuleCommand;
|
||||
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn;
|
||||
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
|
||||
import com.cloudhandson.vpdbackoffice.mapper.AuditMapper;
|
||||
import com.cloudhandson.vpdbackoffice.mapper.PermissionMapper;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class PermissionServiceTest {
|
||||
|
||||
private PermissionMapper permissionMapper;
|
||||
private ProtectedObjectService protectedObjectService;
|
||||
private PermissionService permissionService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
permissionMapper = new FakePermissionMapper();
|
||||
AuditService auditService = new AuditService(new NoopAuditMapper());
|
||||
protectedObjectService = new ProtectedObjectService(null, auditService) {
|
||||
@Override
|
||||
public ProtectedObject assertEnabled(long objectId) {
|
||||
return new ProtectedObject(1L, "ADMIN", "CB_V_SEARCH_DOCUMENTS", "cb-agent-security/vpd/documents", "Y");
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ProtectedColumn> findColumns(long objectId) {
|
||||
return List.of(
|
||||
new ProtectedColumn(1L, 1L, "DEPT_CODE", "N", null, "INTERNAL", "NONE"),
|
||||
new ProtectedColumn(2L, 1L, "OWNER_EMP_NO", "N", null, "INTERNAL", "NONE"),
|
||||
new ProtectedColumn(3L, 1L, "TECH_TAG", "N", null, "PUBLIC", "NONE")
|
||||
);
|
||||
}
|
||||
};
|
||||
permissionService = new PermissionService(permissionMapper, protectedObjectService, auditService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAllRuleMixedWithSpecificRule() {
|
||||
var command = new PermissionSetCommand(
|
||||
10L,
|
||||
1L,
|
||||
"SELECT",
|
||||
"ALLOW",
|
||||
List.of(new RuleCommand(null, "ALL", null), new RuleCommand("DEPT_CODE", "=", "APAC")),
|
||||
List.of()
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> permissionService.savePermissionSet(command))
|
||||
.isInstanceOf(AppException.class)
|
||||
.hasMessageContaining("ALL");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCustomPredicateRule() {
|
||||
var command = new PermissionSetCommand(
|
||||
10L,
|
||||
1L,
|
||||
"SELECT",
|
||||
"ALLOW",
|
||||
List.of(new RuleCommand("DEPT_CODE", "CUSTOM_PREDICATE", "1=1")),
|
||||
List.of()
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> permissionService.savePermissionSet(command))
|
||||
.isInstanceOf(AppException.class)
|
||||
.hasMessageContaining("허용되지 않은");
|
||||
}
|
||||
|
||||
@Test
|
||||
void allowsDefaultColumnsForContextRules() {
|
||||
var command = new PermissionSetCommand(
|
||||
10L,
|
||||
1L,
|
||||
"SELECT",
|
||||
"ALLOW",
|
||||
List.of(new RuleCommand(null, "MY_DEPT", null), new RuleCommand(null, "SELF", null)),
|
||||
List.of()
|
||||
);
|
||||
|
||||
permissionService.savePermissionSet(command);
|
||||
|
||||
FakePermissionMapper mapper = (FakePermissionMapper) permissionMapper;
|
||||
assertThat(mapper.insertedRules)
|
||||
.extracting(PermissionRule::ruleType)
|
||||
.containsExactly("MY_DEPT", "SELF");
|
||||
assertThat(mapper.insertedRules)
|
||||
.extracting(PermissionRule::ruleColumn)
|
||||
.containsExactly(null, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsDeptAndEmpNoRulesWithDefaultColumnsAndValues() {
|
||||
var command = new PermissionSetCommand(
|
||||
10L,
|
||||
1L,
|
||||
"SELECT",
|
||||
"ALLOW",
|
||||
List.of(new RuleCommand(null, "DEPT", "HR"), new RuleCommand(null, "EMP_NO", "E2001")),
|
||||
List.of()
|
||||
);
|
||||
|
||||
permissionService.savePermissionSet(command);
|
||||
|
||||
FakePermissionMapper mapper = (FakePermissionMapper) permissionMapper;
|
||||
assertThat(mapper.insertedRules)
|
||||
.extracting(PermissionRule::ruleType)
|
||||
.containsExactly("DEPT", "EMP_NO");
|
||||
assertThat(mapper.insertedRules)
|
||||
.extracting(PermissionRule::ruleValue)
|
||||
.containsExactly("HR", "E2001");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDeptRuleWithoutValue() {
|
||||
var command = new PermissionSetCommand(
|
||||
10L,
|
||||
1L,
|
||||
"SELECT",
|
||||
"ALLOW",
|
||||
List.of(new RuleCommand(null, "DEPT", "")),
|
||||
List.of()
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> permissionService.savePermissionSet(command))
|
||||
.isInstanceOf(AppException.class)
|
||||
.hasMessageContaining("값이 필요");
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsMultipleTagRulesAsOrCandidates() {
|
||||
var command = new PermissionSetCommand(
|
||||
10L,
|
||||
1L,
|
||||
"SELECT",
|
||||
"ALLOW",
|
||||
List.of(
|
||||
new RuleCommand(null, "TAG", "spring_boot"),
|
||||
new RuleCommand(null, "TAG", "oracle_vpd")
|
||||
),
|
||||
List.of()
|
||||
);
|
||||
|
||||
permissionService.savePermissionSet(command);
|
||||
|
||||
FakePermissionMapper mapper = (FakePermissionMapper) permissionMapper;
|
||||
assertThat(mapper.insertedRules)
|
||||
.extracting(PermissionRule::ruleType)
|
||||
.containsExactly("TAG", "TAG");
|
||||
assertThat(mapper.insertedRules)
|
||||
.extracting(PermissionRule::ruleColumn)
|
||||
.containsExactly(null, null);
|
||||
assertThat(mapper.insertedRules)
|
||||
.extracting(PermissionRule::ruleValue)
|
||||
.containsExactly("SPRING_BOOT", "ORACLE_VPD");
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsStakeholderContextRulesWithAnExplicitObjectColumn() {
|
||||
var command = new PermissionSetCommand(
|
||||
10L,
|
||||
1L,
|
||||
"SELECT",
|
||||
"ALLOW",
|
||||
List.of(
|
||||
new RuleCommand("DEPT_CODE", "STAKEHOLDER_SELF", "설계사"),
|
||||
new RuleCommand("OWNER_EMP_NO", "STAKEHOLDER_CHANNEL", "지점장")
|
||||
),
|
||||
List.of()
|
||||
);
|
||||
|
||||
permissionService.savePermissionSet(command);
|
||||
|
||||
FakePermissionMapper mapper = (FakePermissionMapper) permissionMapper;
|
||||
assertThat(mapper.insertedRules)
|
||||
.extracting(PermissionRule::ruleType)
|
||||
.containsExactly("STAKEHOLDER_SELF", "STAKEHOLDER_CHANNEL");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsStakeholderContextRuleWithoutAnObjectColumn() {
|
||||
var command = new PermissionSetCommand(
|
||||
10L,
|
||||
1L,
|
||||
"SELECT",
|
||||
"ALLOW",
|
||||
List.of(new RuleCommand(null, "STAKEHOLDER_SELF", null)),
|
||||
List.of()
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> permissionService.savePermissionSet(command))
|
||||
.isInstanceOf(AppException.class)
|
||||
.hasMessageContaining("컬럼이 필요");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsStakeholderContextRuleWithoutExpectedRole() {
|
||||
var command = new PermissionSetCommand(
|
||||
10L,
|
||||
1L,
|
||||
"SELECT",
|
||||
"ALLOW",
|
||||
List.of(new RuleCommand("DEPT_CODE", "STAKEHOLDER_SELF", null)),
|
||||
List.of()
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> permissionService.savePermissionSet(command))
|
||||
.isInstanceOf(AppException.class)
|
||||
.hasMessageContaining("값이 필요");
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsKbFilterConditionCodesWithoutAStaticRuleValue() {
|
||||
var command = new PermissionSetCommand(
|
||||
10L,
|
||||
1L,
|
||||
"SELECT",
|
||||
"ALLOW",
|
||||
List.of(
|
||||
new RuleCommand("DEPT_CODE", "OWN_CONTRACT", null),
|
||||
new RuleCommand("OWNER_EMP_NO", "CHANNEL_CUSTOMER", null)
|
||||
),
|
||||
List.of()
|
||||
);
|
||||
|
||||
permissionService.savePermissionSet(command);
|
||||
|
||||
FakePermissionMapper mapper = (FakePermissionMapper) permissionMapper;
|
||||
assertThat(mapper.insertedRules)
|
||||
.extracting(PermissionRule::ruleType)
|
||||
.containsExactly("OWN_CONTRACT", "CHANNEL_CUSTOMER");
|
||||
assertThat(mapper.insertedRules)
|
||||
.extracting(PermissionRule::ruleValue)
|
||||
.containsExactly("", "");
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsStaticSqlPredicateAlongsideAContextCondition() {
|
||||
var command = new PermissionSetCommand(
|
||||
10L,
|
||||
1L,
|
||||
"SELECT",
|
||||
"ALLOW",
|
||||
List.of(
|
||||
new RuleCommand("DEPT_CODE", "OWN_CONTRACT", null),
|
||||
new RuleCommand(null, "STATIC_SQL", "DEPT_CODE IN ('HR', 'FIN')")
|
||||
),
|
||||
List.of()
|
||||
);
|
||||
|
||||
permissionService.savePermissionSet(command);
|
||||
|
||||
FakePermissionMapper mapper = (FakePermissionMapper) permissionMapper;
|
||||
assertThat(mapper.insertedRules)
|
||||
.extracting(PermissionRule::ruleType)
|
||||
.containsExactly("OWN_CONTRACT", "STATIC_SQL");
|
||||
assertThat(mapper.insertedRules)
|
||||
.extracting(PermissionRule::ruleValue)
|
||||
.containsExactly("", "DEPT_CODE IN ('HR', 'FIN')");
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsExplicitUnconditionalStaticSqlPredicateForAdminRole() {
|
||||
var command = new PermissionSetCommand(
|
||||
10L,
|
||||
1L,
|
||||
"SELECT",
|
||||
"ALLOW",
|
||||
List.of(new RuleCommand(null, "STATIC_SQL", "1 = 1")),
|
||||
List.of()
|
||||
);
|
||||
|
||||
permissionService.savePermissionSet(command);
|
||||
|
||||
FakePermissionMapper mapper = (FakePermissionMapper) permissionMapper;
|
||||
assertThat(mapper.insertedRules)
|
||||
.extracting(PermissionRule::ruleType, PermissionRule::ruleValue)
|
||||
.containsExactly(tuple("STATIC_SQL", "1 = 1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsStaticSqlStatementOrUnknownColumn() {
|
||||
var statementCommand = new PermissionSetCommand(
|
||||
10L,
|
||||
1L,
|
||||
"SELECT",
|
||||
"ALLOW",
|
||||
List.of(new RuleCommand(null, "STATIC_SQL", "DEPT_CODE = 'HR'; DELETE FROM CB_APP_USER")),
|
||||
List.of()
|
||||
);
|
||||
var unknownColumnCommand = new PermissionSetCommand(
|
||||
10L,
|
||||
1L,
|
||||
"SELECT",
|
||||
"ALLOW",
|
||||
List.of(new RuleCommand(null, "STATIC_SQL", "UNSUPPORTED_COLUMN = 'HR'")),
|
||||
List.of()
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> permissionService.savePermissionSet(statementCommand))
|
||||
.isInstanceOf(AppException.class)
|
||||
.hasMessageContaining("단일 WHERE");
|
||||
assertThatThrownBy(() -> permissionService.savePermissionSet(unknownColumnCommand))
|
||||
.isInstanceOf(AppException.class)
|
||||
.hasMessageContaining("보호 객체 컬럼");
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresVisibleColumnsBecauseAsoHandlesColumnMasking() {
|
||||
var command = new PermissionSetCommand(
|
||||
10L,
|
||||
1L,
|
||||
"SELECT",
|
||||
"ALLOW",
|
||||
List.of(new RuleCommand("DEPT_CODE", "=", "HR")),
|
||||
List.of("DEPT_CODE", "OWNER_EMP_NO")
|
||||
);
|
||||
|
||||
permissionService.savePermissionSet(command);
|
||||
|
||||
FakePermissionMapper mapper = (FakePermissionMapper) permissionMapper;
|
||||
assertThat(mapper.insertedVisibleColumns).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsTagRuleWithoutValue() {
|
||||
var command = new PermissionSetCommand(
|
||||
10L,
|
||||
1L,
|
||||
"SELECT",
|
||||
"ALLOW",
|
||||
List.of(new RuleCommand(null, "TAG", "")),
|
||||
List.of()
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> permissionService.savePermissionSet(command))
|
||||
.isInstanceOf(AppException.class)
|
||||
.hasMessageContaining("값이 필요");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnsafeTagValue() {
|
||||
var command = new PermissionSetCommand(
|
||||
10L,
|
||||
1L,
|
||||
"SELECT",
|
||||
"ALLOW",
|
||||
List.of(new RuleCommand(null, "TAG", "SPRING' OR '1'='1")),
|
||||
List.of()
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> permissionService.savePermissionSet(command))
|
||||
.isInstanceOf(AppException.class)
|
||||
.hasMessageContaining("영문 대문자");
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsDenyPermissionEffect() {
|
||||
var command = new PermissionSetCommand(
|
||||
10L,
|
||||
1L,
|
||||
"SELECT",
|
||||
"DENY",
|
||||
List.of(new RuleCommand("DEPT_CODE", "=", "HR")),
|
||||
List.of()
|
||||
);
|
||||
|
||||
permissionService.savePermissionSet(command);
|
||||
|
||||
FakePermissionMapper mapper = (FakePermissionMapper) permissionMapper;
|
||||
assertThat(mapper.insertedEffect).isEqualTo("DENY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void disablesProtectedObjectWhenLastPermissionIsDeleted() {
|
||||
var mapper = new FakePermissionMapper();
|
||||
boolean[] disabled = {false};
|
||||
AuditService auditService = new AuditService(new NoopAuditMapper());
|
||||
ProtectedObjectService objectService = new ProtectedObjectService(null, auditService) {
|
||||
@Override
|
||||
public void disableObject(long objectId) {
|
||||
disabled[0] = objectId == 1L;
|
||||
}
|
||||
};
|
||||
PermissionService service = new PermissionService(mapper, objectService, auditService);
|
||||
|
||||
service.deletePermission(1000L);
|
||||
|
||||
assertThat(disabled[0]).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsPermissionDeleteWithoutImpactConfirmation() {
|
||||
assertThatThrownBy(() -> permissionService.deletePermission(1000L, false))
|
||||
.isInstanceOf(AppException.class)
|
||||
.hasMessageContaining("영향 확인");
|
||||
}
|
||||
|
||||
@Test
|
||||
void blocksRoleDeleteWhenDependenciesRemain() {
|
||||
var mapper = new FakePermissionMapper();
|
||||
mapper.userRoleCount = 1;
|
||||
PermissionService service = new PermissionService(mapper, protectedObjectService, new AuditService(new NoopAuditMapper()));
|
||||
|
||||
assertThatThrownBy(() -> service.deleteRole(10L, true))
|
||||
.isInstanceOf(AppException.class)
|
||||
.hasMessageContaining("먼저 해제");
|
||||
}
|
||||
|
||||
private static class NoopAuditMapper implements AuditMapper {
|
||||
@Override
|
||||
public void insert(AuditEvent event) {
|
||||
}
|
||||
}
|
||||
|
||||
private static class FakePermissionMapper implements PermissionMapper {
|
||||
private final List<PermissionRule> insertedRules = new ArrayList<>();
|
||||
private final List<String> insertedVisibleColumns = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public List<AppRole> findRoles() {
|
||||
return List.of(new AppRole(10L, "HR_DEPT_ROLE", null, "INTERNAL"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public AppRole findRole(long roleId) {
|
||||
return roleId == 10L ? new AppRole(10L, "HR_DEPT_ROLE", null, "INTERNAL") : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long nextRoleId() {
|
||||
return 40L;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void insertRole(long roleId, String roleName, String description, String maxSensitivityLevel) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public int updateRoleMaxSensitivity(long roleId, String maxSensitivityLevel) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int deleteRole(long roleId) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
private int userRoleCount;
|
||||
private int groupRoleCount;
|
||||
private int permissionCountByRole;
|
||||
|
||||
@Override
|
||||
public int countUserRolesByRoleId(long roleId) {
|
||||
return userRoleCount;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int countGroupRolesByRoleId(long roleId) {
|
||||
return groupRoleCount;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int countPermissionsByRoleId(long roleId) {
|
||||
return permissionCountByRole;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<PermissionView> findPermissionViews() {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public PermissionSet findPermissionSet(long roleId, long objectId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long findPermissionId(long roleId, long objectId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long findObjectIdByPermissionId(long permissionId) {
|
||||
return permissionId == 1000L ? 1L : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int countPermissionsByObjectId(long objectId) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
private String insertedEffect;
|
||||
|
||||
@Override
|
||||
public void insertPermission(long permissionId, long roleId, long objectId, String action, String permissionEffect) {
|
||||
insertedEffect = permissionEffect;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updatePermissionAction(long permissionId, String action) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updatePermissionEffect(long permissionId, String permissionEffect) {
|
||||
insertedEffect = permissionEffect;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteRules(long permissionId) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void insertRule(PermissionRule rule) {
|
||||
insertedRules.add(rule);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteVisibleColumns(long permissionId) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void insertVisibleColumn(long permissionId, String columnName) {
|
||||
insertedVisibleColumns.add(columnName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int deletePermission(long permissionId) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long nextPermissionId() {
|
||||
return 1000L;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long nextRuleId() {
|
||||
return 10000L;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeStatus;
|
||||
import java.net.ConnectException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
|
||||
class ProbeErrorClassifierTest {
|
||||
|
||||
private final ProbeErrorClassifier classifier = new ProbeErrorClassifier();
|
||||
|
||||
@Test
|
||||
void classifiesInvalidBearerKey() {
|
||||
assertThat(classifier.classify(HttpStatus.FORBIDDEN, "{\"error\":\"ORA-20002\"}"))
|
||||
.isEqualTo(ProbeStatus.INVALID_TOKEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifiesObjectAccessFailure() {
|
||||
assertThat(classifier.classify(HttpStatus.FORBIDDEN, "ORA-00942: table or view does not exist"))
|
||||
.isEqualTo(ProbeStatus.OBJECT_NOT_ACCESSIBLE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifiesHttpUnauthorizedAsInvalidToken() {
|
||||
assertThat(classifier.classify(HttpStatus.UNAUTHORIZED, "unauthorized"))
|
||||
.isEqualTo(ProbeStatus.INVALID_TOKEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifiesOrdsNotFound() {
|
||||
assertThat(classifier.classify(HttpStatus.NOT_FOUND, "{\"code\":\"NotFound\"}"))
|
||||
.isEqualTo(ProbeStatus.ORDS_PATH_NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifiesBrokenVpdPolicyFunction() {
|
||||
assertThat(classifier.classify(
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
"SQL Error Code 28110, Error Message: ORA-28110: The VPD policy function has error"
|
||||
)).isEqualTo(ProbeStatus.VPD_FILTER_ERROR);
|
||||
}
|
||||
|
||||
@Test
|
||||
void detectsOrdsConnectionRefused() {
|
||||
assertThat(classifier.isUnavailable(new ResourceAccessException(
|
||||
"I/O error",
|
||||
new ConnectException("Connection refused")
|
||||
))).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void detectsOrdsTimeout() {
|
||||
assertThat(classifier.isTimeout(new ResourceAccessException(
|
||||
"I/O error",
|
||||
new SocketTimeoutException("Read timed out")
|
||||
))).isTrue();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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(
|
||||
new SecuritySqlScriptProperties("""
|
||||
[{
|
||||
"scriptId":"hmm-leave-vpd",
|
||||
"category":"HMM / VPD",
|
||||
"fileName":"72_hmm_leave_team_vpd.sql",
|
||||
"title":"HMM 휴가 팀 접근 정책",
|
||||
"description":"팀장과 팀원 휴가 행 접근 정책"
|
||||
}]
|
||||
"""),
|
||||
new ObjectMapper());
|
||||
|
||||
@Test
|
||||
void exposesOnlyEnvironmentAllowlistedBundledScripts() {
|
||||
assertThat(service.list())
|
||||
.extracting(item -> item.fileName())
|
||||
.containsExactly("72_hmm_leave_team_vpd.sql");
|
||||
|
||||
assertThat(service.find("hmm-leave-vpd").source())
|
||||
.contains("HMM_ACCESS_CTX_PKG")
|
||||
.contains("HMM_LEAVE_VPD_FILTER");
|
||||
}
|
||||
|
||||
@Test
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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.CatalogProperties;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
class StructuredDataServiceTest {
|
||||
|
||||
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 exposesOnlyEnvironmentConfiguredTablesAndViews() {
|
||||
assertThat(service.owner()).isEqualTo("ADMIN");
|
||||
assertThat(service.tables())
|
||||
.extracting(table -> table.tableName())
|
||||
.containsExactly(
|
||||
"HMM_ORG_TEAMS",
|
||||
"HMM_HR_EMPLOYEES",
|
||||
"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 rejectsAnyObjectOutsideTheServerSideAllowlist() {
|
||||
assertThatThrownBy(() -> service.requireTable("KB_SECURITY_AUDIT_LOG"))
|
||||
.isInstanceOf(AppException.class)
|
||||
.hasMessage("선택할 수 없는 카탈로그 객체입니다.");
|
||||
}
|
||||
|
||||
@Test
|
||||
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 EnvironmentDataCatalog(
|
||||
new CatalogProperties("ADMIN", duplicate), new ObjectMapper()))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("BACKOFFICE_CATALOG_OBJECTS");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class TokenGeneratorTest {
|
||||
|
||||
private final TokenGenerator tokenGenerator = new TokenGenerator();
|
||||
|
||||
@Test
|
||||
void generateCreatesVpdLiveTokenAndSixteenCharacterPrefix() {
|
||||
String token = tokenGenerator.generate();
|
||||
|
||||
assertThat(token).startsWith("vpd_live_");
|
||||
assertThat(token).hasSizeGreaterThan(40);
|
||||
assertThat(tokenGenerator.prefix(token)).hasSize(16);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class TokenHasherTest {
|
||||
|
||||
private final TokenHasher tokenHasher = new TokenHasher();
|
||||
|
||||
@Test
|
||||
void sha256MatchesOracleStandardHashSeedValue() {
|
||||
assertThat(tokenHasher.sha256("cb_hr_key"))
|
||||
.isEqualTo("240CD9BCE11219EAFB691226D5868C2BE4D1600F6460C77C0485CB38D56FC053");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
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 VectorChunkerTest {
|
||||
|
||||
@Test
|
||||
void keepsParagraphsTogetherUntilTheConfiguredLimit() {
|
||||
var chunks = VectorChunker.chunk(
|
||||
"첫 번째 문단입니다.\n\n두 번째 문단은 같은 지식자료입니다.", 80);
|
||||
|
||||
assertThat(chunks).hasSize(1);
|
||||
assertThat(chunks.get(0).chunkNo()).isEqualTo(1);
|
||||
assertThat(chunks.get(0).text()).contains("첫 번째", "두 번째");
|
||||
}
|
||||
|
||||
@Test
|
||||
void splitsLongTextAndNumbersChunksFromOne() {
|
||||
var chunks = VectorChunker.chunk("문장 하나입니다. ".repeat(30), 100);
|
||||
|
||||
assertThat(chunks).hasSizeGreaterThan(1);
|
||||
assertThat(chunks).extracting("chunkNo").containsExactlyElementsOf(
|
||||
java.util.stream.IntStream.rangeClosed(1, chunks.size()).boxed().toList());
|
||||
assertThat(chunks).allSatisfy(chunk -> assertThat(chunk.text()).isNotBlank());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsBlankContent() {
|
||||
assertThatThrownBy(() -> VectorChunker.chunk(" ", 600))
|
||||
.isInstanceOf(AppException.class)
|
||||
.hasMessageContaining("청킹할 문서 본문");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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.domain.vector.VectorQueryEmbedding;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
class VectorKnowledgeServiceTest {
|
||||
|
||||
@Test
|
||||
void vectorizesPlainTextWithTheLocalEmbeddingAndBuildsOrdsBody() {
|
||||
OpenAiCompatibleClient embeddingClient = mock(OpenAiCompatibleClient.class);
|
||||
VectorKnowledgeService service = service(embeddingClient);
|
||||
|
||||
VectorQueryEmbedding result = service.vectorizeQuery(" Oracle VPD에서 ORDS 권한 확인 ", "demo");
|
||||
|
||||
assertThat(result.query()).isEqualTo("Oracle VPD에서 ORDS 권한 확인");
|
||||
assertThat(result.embeddingMode()).isEqualTo("DEMO");
|
||||
assertThat(result.embeddingModel()).isEqualTo("로컬 임베딩(개발용)");
|
||||
assertThat(result.requestBody()).startsWith("{\"embedding\":[").contains("]}");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAiModeWhenEmbeddingConfigurationIsMissing() {
|
||||
OpenAiCompatibleClient embeddingClient = mock(OpenAiCompatibleClient.class);
|
||||
VectorKnowledgeService service = service(embeddingClient);
|
||||
|
||||
assertThatThrownBy(() -> service.vectorizeQuery("검색어", "AI"))
|
||||
.isInstanceOf(AppException.class)
|
||||
.hasMessageContaining("AI 임베딩이 설정되지 않았습니다");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsBlankPlainTextBeforeCallingOrds() {
|
||||
VectorKnowledgeService service = service(mock(OpenAiCompatibleClient.class));
|
||||
|
||||
assertThatThrownBy(() -> service.vectorizeQuery(" ", "DEMO"))
|
||||
.isInstanceOf(AppException.class)
|
||||
.hasMessageContaining("검색 질문");
|
||||
}
|
||||
|
||||
private VectorKnowledgeService service(OpenAiCompatibleClient embeddingClient) {
|
||||
return new VectorKnowledgeService(
|
||||
mock(JdbcTemplate.class),
|
||||
new ObjectMapper(),
|
||||
embeddingClient,
|
||||
mock(ProtectedObjectService.class),
|
||||
mock(BearerTokenService.class),
|
||||
mock(OrdsProbeService.class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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.domain.vpd.VpdFunctionOption;
|
||||
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyFormOptions;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
class VpdPolicyServiceTest {
|
||||
|
||||
@Test
|
||||
void protectsTheDynamicPermissionFunctionFromManualOverwrite() {
|
||||
RecordingJdbcTemplate jdbcTemplate = new RecordingJdbcTemplate();
|
||||
VpdPolicyService service = new VpdPolicyService(null, jdbcTemplate, null);
|
||||
|
||||
assertThatThrownBy(() -> service.saveFilterFunction(
|
||||
"ADMIN",
|
||||
"CB_AGENT_DOC_VPD_FILTER",
|
||||
"1=1"
|
||||
))
|
||||
.isInstanceOf(AppException.class)
|
||||
.hasMessageContaining("권한체계")
|
||||
.hasMessageContaining("수정할 수 없습니다");
|
||||
|
||||
assertThat(jdbcTemplate.updateCount).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultApplyAlwaysUsesTheDynamicPermissionFunction() {
|
||||
RecordingJdbcTemplate jdbcTemplate = new RecordingJdbcTemplate();
|
||||
VpdPolicyService service = new VpdPolicyService(null, jdbcTemplate, null) {
|
||||
@Override
|
||||
public VpdPolicyFormOptions formOptions() {
|
||||
return new VpdPolicyFormOptions(
|
||||
List.of(),
|
||||
List.of("ADMIN"),
|
||||
List.of("ADMIN"),
|
||||
List.of(new VpdFunctionOption("ADMIN", null, "CB_AGENT_DOC_VPD_FILTER", "FUNCTION")),
|
||||
List.of(),
|
||||
List.of("SELECT")
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
service.createDefaultPermissionPolicy("ADMIN.DOCUMENTS");
|
||||
|
||||
assertThat(jdbcTemplate.lastSql).contains("DBMS_RLS.ADD_POLICY").contains("DBMS_RLS.DYNAMIC");
|
||||
assertThat(jdbcTemplate.lastArgs).containsExactly(
|
||||
"ADMIN",
|
||||
"DOCUMENTS",
|
||||
"CB_PERMISSION_SELECT_POLICY",
|
||||
"ADMIN",
|
||||
"CB_AGENT_DOC_VPD_FILTER",
|
||||
"SELECT"
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultApplyFailsClosedWhenPermissionFunctionIsNotInstalled() {
|
||||
VpdPolicyService service = new VpdPolicyService(null, new RecordingJdbcTemplate(), null) {
|
||||
@Override
|
||||
public VpdPolicyFormOptions formOptions() {
|
||||
return new VpdPolicyFormOptions(
|
||||
List.of(), List.of(), List.of(), List.of(), List.of(), List.of("SELECT")
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
assertThatThrownBy(() -> service.createDefaultPermissionPolicy("ADMIN.DOCUMENTS"))
|
||||
.isInstanceOf(AppException.class)
|
||||
.hasMessageContaining("동적 권한 필터")
|
||||
.hasMessageContaining("설치");
|
||||
}
|
||||
|
||||
private static class RecordingJdbcTemplate extends JdbcTemplate {
|
||||
private String lastSql;
|
||||
private Object[] lastArgs = new Object[0];
|
||||
private int updateCount;
|
||||
|
||||
@Override
|
||||
public <T> T queryForObject(String sql, Class<T> requiredType) {
|
||||
return requiredType.cast("ADMIN");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int update(String sql, Object... args) {
|
||||
lastSql = sql;
|
||||
lastArgs = args;
|
||||
updateCount++;
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.cloudhandson.vpdbackoffice.web;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.effective.EffectiveMatrixView;
|
||||
import com.cloudhandson.vpdbackoffice.domain.effective.GroupEffectiveAccessView;
|
||||
import com.cloudhandson.vpdbackoffice.domain.effective.RoleEffectiveImpactView;
|
||||
import com.cloudhandson.vpdbackoffice.domain.effective.UserEffectiveAccessView;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.thymeleaf.context.Context;
|
||||
import org.thymeleaf.spring6.SpringTemplateEngine;
|
||||
import org.thymeleaf.templateresolver.FileTemplateResolver;
|
||||
|
||||
class EffectiveMatrixTemplateRenderTest {
|
||||
|
||||
@Test
|
||||
void rendersTheUserFocusedEffectiveMatrixWithItsLayoutFragments() {
|
||||
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 matrix = new EffectiveMatrixView(
|
||||
List.of(new UserEffectiveAccessView(
|
||||
103L, "김어드민", "E99999", "HQ", true,
|
||||
List.of("ALL_DOC_ROLE"), List.of(), List.of(), List.of("ALL_DOC_ROLE"),
|
||||
1, List.of("CB_VECTOR_SEARCH_DOCUMENTS"))),
|
||||
List.of(new GroupEffectiveAccessView(
|
||||
1L, "OPS", "운영", true, List.of("김어드민"), List.of("ALL_DOC_ROLE"),
|
||||
1, List.of("CB_VECTOR_SEARCH_DOCUMENTS"))),
|
||||
List.of(new RoleEffectiveImpactView(
|
||||
30L, "ALL_DOC_ROLE", "CONFIDENTIAL", List.of("김어드민"), List.of(), List.of(),
|
||||
List.of("김어드민"), 1, List.of("CB_VECTOR_SEARCH_DOCUMENTS"))),
|
||||
1
|
||||
);
|
||||
var context = new Context(Locale.KOREAN);
|
||||
context.setVariable("matrix", matrix);
|
||||
context.setVariable("_csrf", new CsrfFixture("_csrf", "test-token"));
|
||||
|
||||
String rendered = engine.process("effective-matrix", context);
|
||||
|
||||
assertThat(rendered)
|
||||
.contains("선택한 사용자의 최종 권한")
|
||||
.contains("김어드민")
|
||||
.contains("data-effective-user-focus")
|
||||
.contains("data-inheritance=\"그룹 없음 / 상속 역할 없음\"");
|
||||
}
|
||||
|
||||
private record CsrfFixture(String parameterName, String token) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
package com.cloudhandson.vpdbackoffice.web;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class GuidedFlowTemplateTest {
|
||||
|
||||
@Test
|
||||
void dashboardPrioritizesActionsAndKeepsDetailCollapsed() throws IOException {
|
||||
String html = template("dashboard.html");
|
||||
String layout = template("fragments/layout.html");
|
||||
String javascript = Files.readString(Path.of("src/main/resources/static/js/app.js"));
|
||||
|
||||
assertThat(html)
|
||||
.contains("th:replace=\"~{fragments/layout :: head('데이터 접근 제어')}\"")
|
||||
.contains("행 접근과 컬럼 마스킹 운영 흐름")
|
||||
.contains("업무 흐름")
|
||||
.contains("dashboard-menu-flow")
|
||||
.contains("도움말: 메뉴와 권한 적용 구조 보기")
|
||||
.contains("CB_APP_USER")
|
||||
.contains("CB_ORDS")
|
||||
.contains("CB_AGENT_CTX")
|
||||
.contains("권한 데이터 모델")
|
||||
.contains("접근 검증")
|
||||
.contains("행 접근 규칙")
|
||||
.contains("현재 상태")
|
||||
.contains("등록 대상 상세")
|
||||
.contains("<details class=\"dashboard-details\">")
|
||||
.doesNotContain("journey-card")
|
||||
.doesNotContain("dashboard-workflow")
|
||||
.doesNotContain(">01<", ">02<");
|
||||
assertThat(layout)
|
||||
.contains("Data & AI Backoffice")
|
||||
.contains("사용자")
|
||||
.contains("접근 그룹")
|
||||
.contains("역할")
|
||||
.contains("토큰")
|
||||
.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");
|
||||
}
|
||||
|
||||
@Test
|
||||
void productPagesKeepBackgroundGuidanceBehindAConsistentHelpControl() throws IOException {
|
||||
for (String page : List.of(
|
||||
"permissions.html", "vpd-policies.html", "tokens.html", "probe.html", "objects.html",
|
||||
"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", "masking-rules.html", "user-masking-rules.html",
|
||||
"vpd-filter-runtime.html", "security-sql-scripts.html")) {
|
||||
assertThat(template(page))
|
||||
.contains("<summary>도움말</summary>")
|
||||
.doesNotContain("context-summary")
|
||||
.doesNotContain("architectureStrip");
|
||||
}
|
||||
assertThat(template("vector-knowledge.html"))
|
||||
.doesNotContain("1 · CONTENT", "2 · ACCESS POLICY", "3 · SEARCH");
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginOffersRememberMeOnlyThroughTheSpringSecurityParameter() throws IOException {
|
||||
assertThat(template("login.html"))
|
||||
.contains("name=\"remember-me\"")
|
||||
.contains("product?.displayName()")
|
||||
.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");
|
||||
String tokens = template("tokens.html");
|
||||
String probe = template("probe.html");
|
||||
String client = template("mcp-client-demo.html");
|
||||
|
||||
assertThat(permissions)
|
||||
.contains("data-wizard-target=\"1\">역할")
|
||||
.contains("data-wizard-target=\"2\">대상")
|
||||
.doesNotContain(">1 역할<", ">2 객체<", "wizard-step-number");
|
||||
assertThat(tokens)
|
||||
.contains("직원 토큰 발급", "검증 세션 발급")
|
||||
.doesNotContain("1. 검증할 사용자 선택", "2. 검증 세션 발급");
|
||||
assertThat(probe)
|
||||
.contains("접근 검증 실행", "<strong>검증 결과</strong>")
|
||||
.doesNotContain("1. 발급받은 토큰 원문", "2. 확인할 데이터", "3. 접근 검증 실행");
|
||||
assertThat(client)
|
||||
.contains("연결 시작", "도구 목록", "도구 호출")
|
||||
.doesNotContain("<span>1</span>", "<span>2</span>", "<span>3</span>");
|
||||
}
|
||||
|
||||
@Test
|
||||
void probeUsesOneTokenInputAndHidesTechnicalExchangeByDefault() throws IOException {
|
||||
String probe = template("probe.html");
|
||||
String result = template("fragments/probe-result.html");
|
||||
|
||||
assertThat(probe)
|
||||
.contains("name=\"bearerToken\"")
|
||||
.contains("검증 세션 사용자")
|
||||
.contains("벡터 검색어 (평문)")
|
||||
.contains("임베딩 방식")
|
||||
.doesNotContain("벡터 검색 요청 본문 (JSON)")
|
||||
.doesNotContain("name=\"tokenKeyId\"");
|
||||
assertThat(result)
|
||||
.contains("적용된 사용자와 권한")
|
||||
.contains("DB 감사 실행 증적")
|
||||
.contains("DB가 감사한 실행 SQL")
|
||||
.contains("DB가 감사한 VPD predicate")
|
||||
.contains("참고용 SQL 재현")
|
||||
.contains("set_vpd_context 사용자 컨텍스트")
|
||||
.contains("CB_AGENT_CTX.USER_ID")
|
||||
.contains("벡터 유사도 검색 기준")
|
||||
.contains("역할 기반 권한 필터 (재현)")
|
||||
.contains("VECTOR_DISTANCE")
|
||||
.contains("벡터 검색 Top-K")
|
||||
.contains("벡터 거리 (SCORE)")
|
||||
.contains("vpd_predicate")
|
||||
.contains("다음에 할 일")
|
||||
.contains("<details")
|
||||
.contains("기술 상세");
|
||||
}
|
||||
|
||||
@Test
|
||||
void vpdDefaultApplyAndAdvancedCustomFiltersAreVisuallySeparated() throws IOException {
|
||||
String policies = template("vpd-policies.html");
|
||||
String filters = template("vpd-filter-policies.html");
|
||||
|
||||
assertThat(policies)
|
||||
.contains("action=\"/vpd-policies/default\"")
|
||||
.contains("권한체계 자동 적용")
|
||||
.contains("객체만 선택");
|
||||
assertThat(filters)
|
||||
.contains("기본 동작: 권한체계 자동 반영")
|
||||
.contains("고급: 별도 Filter 만들기")
|
||||
.contains("fail-closed")
|
||||
.contains("CB_AGENT_DOC_VPD_FILTER");
|
||||
}
|
||||
|
||||
@Test
|
||||
void ordsObjectPageSeparatesAccessFromColumnDisplayProtection() throws IOException {
|
||||
String html = template("objects.html");
|
||||
|
||||
assertThat(html)
|
||||
.contains("누가 어떤 행을 볼 수 있는지는 행 접근 규칙과 Oracle VPD 정책에서 결정")
|
||||
.contains("지식 검색 연결 흐름")
|
||||
.contains("행 접근 context 설정과 기본 SELECT")
|
||||
.contains("전용 벡터 Handler 사용")
|
||||
.contains("컬럼 마스킹")
|
||||
.doesNotContain("표시 보호 등급", "마스킹 방식");
|
||||
}
|
||||
|
||||
@Test
|
||||
void permissionWizardExplainsConditionCodeAndStaticSqlSemantics() throws IOException {
|
||||
String html = template("permissions.html");
|
||||
String javascript = Files.readString(Path.of("src/main/resources/static/js/app.js"));
|
||||
|
||||
assertThat(html)
|
||||
.contains("value=\"MANAGED_TEAM\">본인 및 직접 보고 팀원")
|
||||
.contains("value=\"SELF\">본인 직원 행")
|
||||
.contains("value=\"STATIC_SQL\">정적 SQL 조건식")
|
||||
.contains("HMM_LEAVE_VPD_FILTER")
|
||||
.contains("행 규칙의 두 가지 적용 방식 보기")
|
||||
.contains("한 권한 안의 규칙은 모두 AND")
|
||||
.contains("서로 다른 역할의 ALLOW 권한은 OR")
|
||||
.contains("data-wizard-validation")
|
||||
.contains("다른 조건 규칙은 한 권한에 함께 저장할 수 없습니다")
|
||||
.contains("저장 영향")
|
||||
.contains("되돌리기");
|
||||
assertThat(javascript)
|
||||
.contains("validatePermissionWizardStep")
|
||||
.contains("ALL 규칙은 다른 조건 규칙과 함께 저장할 수 없습니다")
|
||||
.contains("movePermissionWizard");
|
||||
}
|
||||
|
||||
@Test
|
||||
void roleTokenAndVpdPagesExplainTheirBoundaries() throws IOException {
|
||||
String roles = template("roles.html");
|
||||
String tokens = template("tokens.html");
|
||||
String policies = template("vpd-policies.html");
|
||||
String filters = template("vpd-filter-policies.html");
|
||||
|
||||
assertThat(roles)
|
||||
.contains("데이터 취급 등급");
|
||||
assertThat(tokens)
|
||||
.contains("회수·만료 포함")
|
||||
.contains("현재 사용 가능한 토큰만");
|
||||
assertThat(policies)
|
||||
.contains("어느 객체의 어떤 SQL에 어떤 Filter function을 붙일지")
|
||||
.contains("이 policy가 하는 일");
|
||||
assertThat(filters)
|
||||
.contains("이 Filter가 보호하는 내용")
|
||||
.contains("기술 태그 비교 예시");
|
||||
}
|
||||
|
||||
@Test
|
||||
void connectionSettingsAreSeparatedFromDatabasePreparation() throws IOException {
|
||||
String settings = template("settings.html");
|
||||
String database = template("settings-database.html");
|
||||
|
||||
assertThat(settings)
|
||||
.contains("시스템 설정")
|
||||
.contains("/settings/database")
|
||||
.doesNotContain("action=\"/settings/database/initialize\"");
|
||||
assertThat(database)
|
||||
.contains("DB 준비 상태")
|
||||
.contains("/settings/database/initialize")
|
||||
.contains("확인 문구 입력")
|
||||
.contains("DB 준비 실행")
|
||||
.doesNotContain("action=\"/settings/schema/initialize\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void effectiveMatrixStartsWithOneUserAndKeepsGroupAndRoleAsSecondaryViews() throws IOException {
|
||||
String html = template("effective-matrix.html");
|
||||
String javascript = Files.readString(Path.of("src/main/resources/static/js/app.js"));
|
||||
|
||||
assertThat(html)
|
||||
.contains("선택한 사용자의 최종 권한")
|
||||
.contains("data-effective-user-focus")
|
||||
.contains("data-effective-tab=\"user\"")
|
||||
.contains("data-effective-pane=\"group\"")
|
||||
.contains("data-effective-pane=\"role\"");
|
||||
assertThat(javascript)
|
||||
.contains("updateEffectiveUserFocus")
|
||||
.contains("initEffectiveMatrixTabs");
|
||||
}
|
||||
|
||||
@Test
|
||||
void mcpPagesDescribeReasoningAndProtocolBoundaries() throws IOException {
|
||||
String chatbot = template("mcp-chatbot.html");
|
||||
String reasoning = template("mcp-reasoning.html");
|
||||
String client = template("mcp-client-demo.html");
|
||||
String sse = template("mcp-sse.html");
|
||||
|
||||
assertThat(chatbot).contains("검증 세션 사용자").contains("태그 벡터 검색");
|
||||
assertThat(reasoning).contains("MCP tool을 고르고").contains("검증 세션 사용자").contains("MCP Tool");
|
||||
assertThat(client).contains("tool 선택과 호출은 reasoning 결과").doesNotContain("Context Path");
|
||||
assertThat(sse)
|
||||
.contains("tools/list.inputSchema")
|
||||
.contains("환경 설정으로 승인한 MCP 도구")
|
||||
.doesNotContain(
|
||||
"resolve_hr_term",
|
||||
"search_hr_data",
|
||||
"search_hr_policy",
|
||||
"kb_select_ai_vpd",
|
||||
"KB_CLAIMS");
|
||||
}
|
||||
|
||||
@Test
|
||||
void vectorKnowledgePageShowsIngestPermissionAndSearchFlow() throws IOException {
|
||||
String vector = template("vector-knowledge.html");
|
||||
String result = template("fragments/vector-search-result.html");
|
||||
|
||||
assertThat(vector)
|
||||
.contains("지식자료 등록")
|
||||
.contains("ALLOW TAG SPRING_BOOT")
|
||||
.contains("DENY TAG ORDS")
|
||||
.contains("권한 기반 지식 검색")
|
||||
.contains("로컬 임베딩(개발용)")
|
||||
.contains("/vector-knowledge/ingest")
|
||||
.contains("/vector-knowledge/search");
|
||||
assertThat(result)
|
||||
.contains("TECH_TAG")
|
||||
.contains("검색 기술 상세 보기");
|
||||
}
|
||||
|
||||
@Test
|
||||
void probeResultSeparatesDbExecutionEvidenceFromReconstructedSql() throws IOException {
|
||||
String result = template("fragments/probe-result.html");
|
||||
String vectorSql = Files.readString(Path.of("../database/adb/29_agent_ords_vector_search_ords.sql"));
|
||||
|
||||
assertThat(result)
|
||||
.contains("DB 감사 실행 증적")
|
||||
.contains("DB가 감사한 실행 SQL")
|
||||
.contains("DB가 감사한 VPD predicate")
|
||||
.contains("RLS_INFO")
|
||||
.contains("참고용 SQL 재현")
|
||||
.contains("SQL (재현)")
|
||||
.contains("행 접근 조건 (VPD predicate 재현)")
|
||||
.contains("ALL: 추가 행 필터 없음")
|
||||
.contains("기본 whitelist 역할");
|
||||
assertThat(vectorSql)
|
||||
.contains("VECTOR_DISTANCE(d.embedding, TO_VECTOR(:embedding), COSINE)")
|
||||
.contains("cb_ords_handler_pkg.set_vpd_context(:auth_header, :probe_id)")
|
||||
.contains("IF TRIM(v_vpd_predicate) <> '1 = 1' THEN")
|
||||
.contains("' AND (' || v_vpd_predicate || ')'");
|
||||
}
|
||||
|
||||
@Test
|
||||
void vectorDefaultRolesAreSeededAsWhitelistAllRules() throws IOException {
|
||||
String setup = Files.readString(Path.of("../database/adb/32_dds_vector_tag_setup.sql"));
|
||||
|
||||
assertThat(setup)
|
||||
.contains("ensure_vector_all_permission(10)")
|
||||
.contains("ensure_vector_all_permission(20)")
|
||||
.contains("ensure_vector_all_permission(30)")
|
||||
.contains("UPPER(TRIM(rule_type)) = 'TAG'")
|
||||
.contains("NULL, 'ALL', NULL")
|
||||
.doesNotContain("SYS.ODCIVARCHAR2LIST('SPRING_BOOT', 'ORDS')");
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultVerificationUsersUseHumanNamesInsteadOfRoleLikeAgentNames() throws IOException {
|
||||
String seed = Files.readString(Path.of("../database/adb/17_agent_ords_security_local_vpd_setup.sql"));
|
||||
String users = template("users.html");
|
||||
String result = template("fragments/probe-result.html");
|
||||
|
||||
assertThat(seed)
|
||||
.contains("(101, '이에이치알'")
|
||||
.contains("(102, '박파이넨스'")
|
||||
.contains("(103, '김어드민'");
|
||||
assertThat(users).contains("이에이치알").doesNotContain(">agent_hr<");
|
||||
assertThat(result).contains("김어드민").doesNotContain(">agent_all<");
|
||||
}
|
||||
|
||||
@Test
|
||||
void vectorOrdsHandlerReadsBodyStreamOnlyOnce() throws IOException {
|
||||
String sql = Files.readString(Path.of("../database/adb/29_agent_ords_vector_search_ords.sql"));
|
||||
|
||||
assertThat(sql)
|
||||
.contains("v_body_text CLOB")
|
||||
.contains("v_body_text := :body_text")
|
||||
.contains("Duplicate stream parameter")
|
||||
.doesNotContain("JSON_QUERY(\n :body_text")
|
||||
.doesNotContain("JSON_VALUE(\n :body_text");
|
||||
assertThat(sql.split("v_body_text := :body_text", -1)).hasSize(2);
|
||||
assertThat(sql).contains("vpd_predicate").contains("effective_sql");
|
||||
}
|
||||
|
||||
@Test
|
||||
void existingOrdsSetupCanReturnTheVpdPredicateTrace() throws IOException {
|
||||
String setup = Files.readString(Path.of("../database/adb/22_agent_ords_security_ords_handler_setup.sql"));
|
||||
String grant = Files.readString(Path.of("../database/adb/33_agent_ords_sql_trace_grant.sql"));
|
||||
|
||||
assertThat(setup)
|
||||
.contains("admin.cb_agent_doc_vpd_filter('ADMIN', 'CB_V_SEARCH_DOCUMENTS')")
|
||||
.contains("APEX_JSON.WRITE('effective_sql', v_effective_sql)");
|
||||
assertThat(grant).contains("GRANT EXECUTE ON cb_agent_doc_vpd_filter TO cb_ords");
|
||||
}
|
||||
|
||||
private String template(String relativePath) throws IOException {
|
||||
return Files.readString(Path.of("src/main/resources/templates").resolve(relativePath));
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.cloudhandson.vpdbackoffice.web;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.permission.AppRole;
|
||||
import com.cloudhandson.vpdbackoffice.domain.protectedobject.DatabaseObjectOption;
|
||||
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
|
||||
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 PermissionTemplateRenderTest {
|
||||
|
||||
@Test
|
||||
void rendersThePermissionSafetyReviewWithLayoutFragments() {
|
||||
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 role = new AppRole(30L, "ALL_DOC_ROLE", "전체 문서 역할", "CONFIDENTIAL");
|
||||
var object = new ProtectedObject(3L, "ADMIN", "CB_VECTOR_SEARCH_DOCUMENTS", "vector/search", "Y");
|
||||
var context = new Context(Locale.KOREAN);
|
||||
context.setVariable("_csrf", new CsrfFixture("_csrf", "test-token"));
|
||||
context.setVariable("roles", List.of(role));
|
||||
context.setVariable("objects", List.of(object));
|
||||
context.setVariable("columnsByObject", Map.of(3L, List.of("CHUNK_TEXT", "TECH_TAG")));
|
||||
context.setVariable("maskableColumnsByObject", Map.of(3L, List.of("CHUNK_TEXT")));
|
||||
context.setVariable("maskableColumnLabelsByObject", Map.of(3L, List.of("CHUNK_TEXT [CONFIDENTIAL/NULLIFY]")));
|
||||
context.setVariable("directUsersByRole", Map.of(30L, List.of("김어드민")));
|
||||
context.setVariable("groupsByRole", Map.of(30L, List.of("OPS / 운영")));
|
||||
context.setVariable("groupUsersByRole", Map.of(30L, List.of("박파이넨스")));
|
||||
context.setVariable("dbObjects", List.of(new DatabaseObjectOption("ADMIN", "BOARD_POSTS", "TABLE")));
|
||||
context.setVariable("permissions", List.of());
|
||||
context.setVariable("lastPermissionByPermissionId", Map.of());
|
||||
|
||||
String rendered = engine.process("permissions", context);
|
||||
|
||||
assertThat(rendered)
|
||||
.contains("권한을 적용할 역할 선택")
|
||||
.contains("권한을 적용할 보호 객체 선택")
|
||||
.contains("data-wizard-validation")
|
||||
.contains("저장 영향")
|
||||
.contains("되돌리기")
|
||||
.contains("김어드민");
|
||||
}
|
||||
|
||||
private record CsrfFixture(String parameterName, String token) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.cloudhandson.vpdbackoffice.web;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.probe.FgaExecutionEvidence;
|
||||
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeResult;
|
||||
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeStatus;
|
||||
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 ProbeResultTemplateRenderTest {
|
||||
|
||||
@Test
|
||||
void rendersFgaEvidenceAndUnavailableMessageAsTextNotAsABoolean() {
|
||||
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);
|
||||
|
||||
ProbeResult result = new ProbeResult(
|
||||
ProbeStatus.SUCCESS,
|
||||
List.of("DOC_ID"),
|
||||
List.of(Map.of("DOC_ID", 1)),
|
||||
1,
|
||||
List.of(),
|
||||
null,
|
||||
null,
|
||||
"{}",
|
||||
"{}",
|
||||
"{}",
|
||||
"{}"
|
||||
).withFgaExecutionEvidence(new FgaExecutionEvidence(
|
||||
"2026-07-01T15:00:00Z",
|
||||
"CB_ORDS",
|
||||
"8db9fc64-c71b-4b9e-9514-2fcaa355d9f3",
|
||||
"SELECT",
|
||||
"SELECT * FROM ADMIN.CB_V_SEARCH_DOCUMENTS",
|
||||
"CB_AGENT_DOC_POLICY: (DEPT_CODE = 'HR')"
|
||||
), null);
|
||||
var context = new Context(Locale.KOREAN);
|
||||
context.setVariable("result", result);
|
||||
context.setVariable("vectorSearch", false);
|
||||
context.setVariable("tokenContext", null);
|
||||
context.setVariable("selectedObject", null);
|
||||
|
||||
String rendered = engine.process("fragments/probe-result", context);
|
||||
|
||||
assertThat(rendered)
|
||||
.contains("DB 감사 실행 증적")
|
||||
.contains("8db9fc64-c71b-4b9e-9514-2fcaa355d9f3")
|
||||
.contains("SELECT * FROM ADMIN.CB_V_SEARCH_DOCUMENTS")
|
||||
.contains("CB_AGENT_DOC_POLICY: (DEPT_CODE = 'HR')");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.cloudhandson.vpdbackoffice.web;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Locale;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.thymeleaf.context.Context;
|
||||
import org.thymeleaf.spring6.SpringTemplateEngine;
|
||||
import org.thymeleaf.templateresolver.FileTemplateResolver;
|
||||
|
||||
class SettingsTemplateRenderTest {
|
||||
|
||||
@Test
|
||||
void rendersConnectionAndDatabasePreparationPagesSeparately() {
|
||||
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 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-backoffice.cloud-handson.com/mcp");
|
||||
|
||||
String connection = engine.process("settings", context);
|
||||
String database = engine.process("settings-database", context);
|
||||
|
||||
assertThat(connection)
|
||||
.contains("설정 기반 MCP 도구")
|
||||
.contains("https://hmm-backoffice.cloud-handson.com/mcp")
|
||||
.contains("Legacy ORDS Base URL")
|
||||
.contains("/settings/database")
|
||||
.doesNotContain("g329127dfd380ad-kbaipoc")
|
||||
.doesNotContain("/settings/database/initialize");
|
||||
assertThat(database)
|
||||
.contains("DB 준비 상태")
|
||||
.contains("/settings/database/initialize")
|
||||
.contains("DB 준비 실행");
|
||||
}
|
||||
|
||||
private record CsrfFixture(String parameterName, String token) {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user