test #458: automate ORDS VPD regression matrix

This commit is contained in:
devmrko
2026-06-25 21:40:10 +09:00
parent e347c1bc2f
commit 76227552d7
3 changed files with 245 additions and 1 deletions

View File

@@ -0,0 +1,87 @@
# 설계서: VPD/Redaction 회귀 테스트 매트릭스 자동화 (#458)
> **상태**: Approved
> **작성**: [AI] Architect · **최종수정**: 2026-06-25
> **추적성** — Redmine: #458 · 관련 ADR: 없음
> · 구현 파일: `scripts/vpd-ords-regression-test.sh`, `run.sh` · 테스트: `./run.sh backoffice-vpd-ords-test`, `mvn test`
## 1. 목적 (Why)
VPD filter, ORDS handler, Redaction 설정 변경 후에도 Bearer Token별 조회 행 수와 민감 컬럼 노출 결과가 유지되는지 자동으로 검증한다.
## 2. 범위 (Scope)
- **포함**: 임시 bearer token 생성, ORDS POST 호출, 응답 JSON 검증, token revoke, 실패 증거 파일 저장.
- **제외**: ORDS metadata 생성, VPD policy 생성, UI E2E 브라우저 테스트.
## 3. 인수조건 (Acceptance Criteria)
- [ ] SQLcl로 임시 token을 생성하고 종료 시 revoke한다.
- [ ] HR token은 3행 `[1,2,6]`, `CONTENTS` 미노출을 검증한다.
- [ ] SELF token은 1행 `[3]`, `CONTENTS` 미노출을 검증한다.
- [ ] ALL token은 6행 `[1,2,3,4,5,6]`, `CONTENTS` 노출을 검증한다.
- [ ] 실패 시 request/response 파일 위치를 출력한다.
- [ ] `./run.sh backoffice-vpd-ords-test`로 실행할 수 있다.
## 4. 컨텍스트 & 제약
- SQLcl이 필요하다. `SQLCL_BIN`이 있으면 그 값을 쓰고, 없으면 PATH의 `sql`을 찾는다.
- DB 접속은 `.env``BACKOFFICE_DB_USERNAME`, `BACKOFFICE_DB_PASSWORD`, `ADB_TNS`를 사용한다.
- ORDS base URL은 `BACKOFFICE_ORDS_BASE_URL`이 있으면 쓰고, 없으면 현재 백오피스 기본값을 사용한다.
- token 원문은 출력하지 않는다.
## 5. 아키텍처 개요
```
run.sh backoffice-vpd-ords-test
-> scripts/vpd-ords-regression-test.sh
-> SQLcl INSERT temp tokens
-> curl POST ORDS documents endpoint
-> Python JSON assertions
-> SQLcl revoke temp tokens
```
## 6. 데이터 모델
- 테스트 token description: `codex issue458 regression`.
- 사용자 매트릭스:
- `101`: HR 부서 권한
- `102`: SELF 권한
- `103`: ALL 권한 + `CONTENTS` 노출 권한
## 7. 함수 명세 (Function Specs)
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|------|-----------|----------------|------|------|-----------|-------|
| `run_sqlcl` | SQLcl에 SQL stdin을 전달 | bash function | SQL text | SQLcl output | SQLcl exit code | 단순 |
| `call_and_assert` | ORDS 호출 후 JSON 결과 검증 | bash function | label, token, expected rows | PASS/FAIL | 실패 시 exit 1 | **복잡** |
## 8. 흐름 / 알고리즘
1. `.env`를 로드한다.
2. SQLcl/JDK/DB/ORDS 환경을 확인한다.
3. UUID 기반 token 3개를 생성한다.
4. SQLcl로 token hash를 DB에 insert한다.
5. ORDS endpoint를 호출해 `items` 행 수, DOC_ID, CONTENTS 노출 수를 검증한다.
6. 성공/실패와 관계없이 token을 revoke한다.
## 9. 엣지케이스 & 에러 처리
- SQLcl 없음: 실행 전 명확한 메시지로 실패.
- ORDS 200 아님: response body 파일 경로 출력.
- JSON parse 실패: response body 일부와 파일 경로 출력.
- 중간 실패: cleanup trap으로 token revoke 시도.
## 10. 테스트 계획
- `SQLCL_BIN=/tmp/sqlcl/sqlcl/bin/sql ./run.sh backoffice-vpd-ords-test`
- `mvn test`
## 11. 리스크 & 대안 검토
- 선택: bash + SQLcl + curl + Python 표준 json. 배포 의존성을 추가하지 않고 운영자가 그대로 실행할 수 있다.
- 대안: JUnit 통합 테스트. Maven에서 외부 ORDS/ADB를 항상 요구하게 되어 로컬 단위 테스트가 불안정해진다.
## 12. 미해결 질문 (Open Questions)
- SQLcl/JDK 경로 자동 탐지는 #460 운영 런북에서 더 정리한다.

10
run.sh
View File

@@ -10,6 +10,7 @@
# ./run.sh tests # 4-user (my/pg/both/none) 로 접속해서 행 필터 검증
# ./run.sh audit # admin 으로 정책/뷰/유저 상태 점검
# ./run.sh backoffice-support # Spring Boot 백오피스 보조 객체 생성
# ./run.sh backoffice-vpd-ords-test # SQLcl + ORDS VPD/Redaction 회귀 테스트
# ./run.sh all # source → adb → tests → audit
# ./run.sh teardown # ADB 측 객체 + 원격 link/cred 만 정리 (원격 PG/MySQL 데이터는 보존)
#
@@ -174,6 +175,12 @@ do_backoffice_support() {
ok "backoffice-support 완료"
}
do_backoffice_vpd_ords_test() {
log "=== backoffice-vpd-ords-test: SQLcl + ORDS VPD/Redaction 회귀 테스트 ==="
bash "$ROOT/scripts/vpd-ords-regression-test.sh"
ok "backoffice-vpd-ords-test 완료"
}
do_teardown() {
log "=== teardown: ADB 측 객체 + dblink/credential 정리 ==="
warn "원격 PG/MySQL 의 customers 테이블은 건드리지 않습니다 (수동으로 DROP 하세요)"
@@ -226,6 +233,7 @@ case "$CMD" in
tests) do_prereq; do_tests ;;
audit) do_prereq; do_audit ;;
backoffice-support) do_prereq; do_backoffice_support ;;
backoffice-vpd-ords-test) do_backoffice_vpd_ords_test ;;
teardown) do_prereq; do_teardown ;;
all)
do_prereq
@@ -245,6 +253,6 @@ case "$CMD" in
ok "=== DDS DONE — Deep Data Security 변형 셋업 + 검증 통과 ==="
;;
*)
die "알 수 없는 명령: $CMD (사용: prereq|source|adb|tests|audit|backoffice-support|all|teardown | dds|dds-setup|dds-tests|dds-teardown)"
die "알 수 없는 명령: $CMD (사용: prereq|source|adb|tests|audit|backoffice-support|backoffice-vpd-ords-test|all|teardown | dds|dds-setup|dds-tests|dds-teardown)"
;;
esac

View File

@@ -0,0 +1,149 @@
#!/usr/bin/env bash
# VPD/Redaction ORDS regression test for the backoffice demo objects.
set -Eeuo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
if [[ -f "$ROOT/.env" ]]; then
# shellcheck disable=SC1091
set -a; . "$ROOT/.env"; set +a
fi
SQLCL_BIN="${SQLCL_BIN:-}"
if [[ -z "$SQLCL_BIN" ]]; then
SQLCL_BIN="$(command -v sql || true)"
fi
if [[ -z "$SQLCL_BIN" || ! -x "$SQLCL_BIN" ]]; then
echo "[FAIL] SQLcl 실행 파일을 찾을 수 없습니다. SQLCL_BIN=/path/to/sql 로 지정하세요." >&2
exit 1
fi
if [[ -n "${SQLCL_JAVA_HOME:-}" ]]; then
export JAVA_HOME="$SQLCL_JAVA_HOME"
fi
DB_USER="${BACKOFFICE_DB_USERNAME:-${ADB_USER:-}}"
DB_PASSWORD="${BACKOFFICE_DB_PASSWORD:-${ADB_PASSWORD:-}}"
DB_TNS="${ADB_TNS:-}"
ORDS_BASE="${BACKOFFICE_ORDS_BASE_URL:-https://yh0olybn5pqce4n-d8aukro81636mon0.adb.ap-seoul-1.oraclecloudapps.com/ords}"
ORDS_PATH="${BACKOFFICE_REGRESSION_ORDS_PATH:-cb-ords/cb-agent-security/vpd/documents}"
DESCRIPTION="codex issue458 regression"
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/vpd-ords-regression.XXXXXX")"
require_value() {
local name="$1" value="$2"
if [[ -z "$value" ]]; then
echo "[FAIL] $name 값이 필요합니다." >&2
exit 1
fi
}
require_value BACKOFFICE_DB_USERNAME "$DB_USER"
require_value BACKOFFICE_DB_PASSWORD "$DB_PASSWORD"
require_value ADB_TNS "$DB_TNS"
TOKEN_HR="codex_hr_$(uuidgen)"
TOKEN_SELF="codex_self_$(uuidgen)"
TOKEN_ALL="codex_all_$(uuidgen)"
run_sqlcl() {
"$SQLCL_BIN" -s "$DB_USER/$DB_PASSWORD@$DB_TNS"
}
cleanup_tokens() {
{
printf "%s\n" "WHENEVER SQLERROR CONTINUE"
printf "%s\n" "UPDATE cb_agent_bearer_key SET revoked_at = SYSDATE, active = 'N' WHERE description = '$DESCRIPTION';"
printf "%s\n" "COMMIT;"
printf "%s\n" "exit"
} | run_sqlcl >/dev/null || true
}
trap cleanup_tokens EXIT
insert_tokens() {
{
printf "%s\n" "WHENEVER SQLERROR EXIT SQL.SQLCODE"
printf "%s\n" "INSERT INTO cb_agent_bearer_key(key_id, user_id, key_hash, key_prefix, expires_at, active, description) VALUES (cb_agent_bearer_key_seq.NEXTVAL, 101, STANDARD_HASH('$TOKEN_HR', 'SHA256'), 'reg_hr', SYSDATE + (10/1440), 'Y', '$DESCRIPTION');"
printf "%s\n" "INSERT INTO cb_agent_bearer_key(key_id, user_id, key_hash, key_prefix, expires_at, active, description) VALUES (cb_agent_bearer_key_seq.NEXTVAL, 102, STANDARD_HASH('$TOKEN_SELF', 'SHA256'), 'reg_self', SYSDATE + (10/1440), 'Y', '$DESCRIPTION');"
printf "%s\n" "INSERT INTO cb_agent_bearer_key(key_id, user_id, key_hash, key_prefix, expires_at, active, description) VALUES (cb_agent_bearer_key_seq.NEXTVAL, 103, STANDARD_HASH('$TOKEN_ALL', 'SHA256'), 'reg_all', SYSDATE + (10/1440), 'Y', '$DESCRIPTION');"
printf "%s\n" "COMMIT;"
printf "%s\n" "exit"
} | run_sqlcl >"$WORK_DIR/token-insert.out"
}
call_and_assert() {
local label="$1" token="$2" expected_count="$3" expected_ids="$4" expected_contents="$5"
local response_file="$WORK_DIR/${label}.response.json"
local request_file="$WORK_DIR/${label}.request.txt"
local url="${ORDS_BASE%/}/${ORDS_PATH}?limit=50"
{
printf "POST %s\n" "$url"
printf "Authorization: Bearer ****%s\n" "${token: -6}"
printf "Content-Type: application/json\n"
printf "{}\n"
} >"$request_file"
local status
status="$(curl -sS -o "$response_file" -w "%{http_code}" \
-X POST "$url" \
-H "Authorization: Bearer $token" \
-H "Content-Type: application/json" \
-d "{}")"
python3 - "$label" "$status" "$response_file" "$request_file" "$expected_count" "$expected_ids" "$expected_contents" <<'PY'
import json
import sys
label, status, response_file, request_file, expected_count, expected_ids, expected_contents = sys.argv[1:8]
if status != "200":
print(f"[FAIL] {label}: HTTP {status}")
print(f" request={request_file}")
print(f" response={response_file}")
raise SystemExit(1)
try:
with open(response_file, encoding="utf-8") as handle:
data = json.load(handle)
except Exception as exc:
print(f"[FAIL] {label}: JSON parse failed: {exc}")
print(f" request={request_file}")
print(f" response={response_file}")
raise SystemExit(1)
rows = data.get("items", data.get("rows", []))
ids = [row.get("DOC_ID", row.get("doc_id")) for row in rows]
contents_count = sum(1 for row in rows if "CONTENTS" in row or "contents" in row)
expected_ids_list = [int(value) for value in expected_ids.split(",") if value]
failures = []
if len(rows) != int(expected_count):
failures.append(f"rows expected {expected_count}, actual {len(rows)}")
if ids != expected_ids_list:
failures.append(f"ids expected {expected_ids_list}, actual {ids}")
if contents_count != int(expected_contents):
failures.append(f"rows_with_contents expected {expected_contents}, actual {contents_count}")
if failures:
print(f"[FAIL] {label}: " + "; ".join(failures))
print(f" request={request_file}")
print(f" response={response_file}")
raise SystemExit(1)
print(f"[PASS] {label}: rows={len(rows)} ids={ids} rows_with_contents={contents_count}")
PY
}
echo "[INFO] SQLcl: $SQLCL_BIN"
echo "[INFO] ORDS: ${ORDS_BASE%/}/${ORDS_PATH}"
echo "[INFO] Evidence: $WORK_DIR"
insert_tokens
call_and_assert "HR" "$TOKEN_HR" "3" "1,2,6" "0"
call_and_assert "SELF" "$TOKEN_SELF" "1" "3" "0"
call_and_assert "ALL" "$TOKEN_ALL" "6" "1,2,3,4,5,6" "6"
echo "[PASS] VPD/Redaction ORDS regression passed"