fix #557: guide permission-driven VPD flow

This commit is contained in:
devmrko
2026-06-29 07:01:53 +09:00
parent 8dc77f87ab
commit fbe3d4682b
24 changed files with 1351 additions and 489 deletions

View File

@@ -0,0 +1,100 @@
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("VPD");
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("VPD Filter");
assertThat(result.plainSummary()).contains("토큰과 사용자 권한은 확인");
assertThat(result.nextAction()).contains("토큰이나 권한을 바꾸지 말고").contains("자동 Filter");
}
}

View File

@@ -37,6 +37,14 @@ class ProbeErrorClassifierTest {
.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(

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,55 @@
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 org.junit.jupiter.api.Test;
class GuidedFlowTemplateTest {
@Test
void dashboardExplainsTheFourStepPermissionJourney() throws IOException {
String html = template("dashboard.html");
assertThat(html)
.contains("1. 권한 설계")
.contains("2. DB 보호 연결")
.contains("3. 토큰 발급")
.contains("4. 결과 확인");
}
@Test
void probeUsesOneTokenInputAndHidesTechnicalExchangeByDefault() throws IOException {
String probe = template("probe.html");
String result = template("fragments/probe-result.html");
assertThat(probe).contains("name=\"bearerToken\"").doesNotContain("name=\"tokenKeyId\"");
assertThat(result)
.contains("적용된 사용자와 권한")
.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");
}
private String template(String relativePath) throws IOException {
return Files.readString(Path.of("src/main/resources/templates").resolve(relativePath));
}
}