refs #741 #742: reorganize repository by application

This commit is contained in:
devmrko
2026-08-03 10:20:36 +09:00
parent 2e44ed0b97
commit e9d50e6a32
445 changed files with 1523 additions and 1885 deletions

View File

@@ -1,226 +0,0 @@
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";
}
}
}

View File

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

View File

@@ -1,29 +0,0 @@
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("본인 직원 행");
}
}

View File

@@ -1,214 +0,0 @@
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");
}
}

View File

@@ -1,26 +0,0 @@
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)");
}
}

View File

@@ -1,31 +0,0 @@
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");
}
}

View File

@@ -1,78 +0,0 @@
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");
}
}

View File

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

View File

@@ -1,37 +0,0 @@
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);
}
}

View File

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

View File

@@ -1,57 +0,0 @@
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");
}
}

View File

@@ -1,235 +0,0 @@
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");
}
}
}

View File

@@ -1,54 +0,0 @@
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");
}
}

View File

@@ -1,49 +0,0 @@
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();
}
}

View File

@@ -1,555 +0,0 @@
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;
}
}
}

View File

@@ -1,63 +0,0 @@
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();
}
}

View File

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

View File

@@ -1,53 +0,0 @@
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");
}
}

View File

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

View File

@@ -1,75 +0,0 @@
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");
}
}

View File

@@ -1,19 +0,0 @@
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);
}
}

View File

@@ -1,16 +0,0 @@
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");
}
}

View File

@@ -1,36 +0,0 @@
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("청킹할 문서 본문");
}
}

View File

@@ -1,55 +0,0 @@
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));
}
}

View File

@@ -1,96 +0,0 @@
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;
}
}
}

View File

@@ -1,57 +0,0 @@
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) {
}
}

View File

@@ -1,373 +0,0 @@
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 &amp; 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("sql/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("sql/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("sql/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("sql/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("sql/adb/22_agent_ords_security_ords_handler_setup.sql"));
String grant = Files.readString(Path.of("sql/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));
}
}

View File

@@ -1,87 +0,0 @@
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) {
}
}

View File

@@ -1,58 +0,0 @@
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) {
}
}

View File

@@ -1,63 +0,0 @@
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 = &#39;HR&#39;)");
}
}

View File

@@ -1,47 +0,0 @@
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) {
}
}

View File

@@ -1 +0,0 @@
mock-maker-subclass