1
0
forked from baron/baron-sso

정합성 검사 중복실행 방지

This commit is contained in:
2026-05-14 09:23:54 +09:00
parent df543d6203
commit 92e607aee8
4 changed files with 120 additions and 37 deletions

View File

@@ -11,36 +11,37 @@ import DataIntegrityPage from "./DataIntegrityPage";
let currentRole = "super_admin"; let currentRole = "super_admin";
const integrityReport = {
status: "fail",
checkedAt: "2026-05-14T00:00:00Z",
summary: {
totalChecks: 2,
passed: 1,
warnings: 0,
failures: 1,
},
sections: [
{
key: "tenant_integrity",
label: "테넌트 정합성",
status: "fail",
checks: [
{
key: "duplicate_tenant_slugs",
label: "중복 테넌트 slug",
description: "active tenant slug의 대소문자 무시 중복을 검사합니다.",
status: "fail",
severity: "error",
count: 1,
},
],
},
],
};
vi.mock("../../lib/adminApi", () => ({ vi.mock("../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({ role: currentRole })), fetchMe: vi.fn(async () => ({ role: currentRole })),
fetchDataIntegrityReport: vi.fn(async () => ({ fetchDataIntegrityReport: vi.fn(async () => integrityReport),
status: "fail",
checkedAt: "2026-05-14T00:00:00Z",
summary: {
totalChecks: 2,
passed: 1,
warnings: 0,
failures: 1,
},
sections: [
{
key: "tenant_integrity",
label: "테넌트 정합성",
status: "fail",
checks: [
{
key: "duplicate_tenant_slugs",
label: "중복 테넌트 slug",
description:
"active tenant slug의 대소문자 무시 중복을 검사합니다.",
status: "fail",
severity: "error",
count: 1,
},
],
},
],
})),
fetchOrphanUserLoginIDs: vi.fn(async () => ({ fetchOrphanUserLoginIDs: vi.fn(async () => ({
items: [ items: [
{ {
@@ -123,6 +124,34 @@ describe("DataIntegrityPage", () => {
]); ]);
}); });
it("disables recheck button and shows manual recheck progress", async () => {
let finishRecheck: (value: typeof integrityReport) => void = () => {};
const pendingRecheck = new Promise<typeof integrityReport>((resolve) => {
finishRecheck = resolve;
});
renderPage();
expect(await screen.findByText("중복 테넌트 slug")).toBeInTheDocument();
vi.mocked(fetchDataIntegrityReport).mockImplementationOnce(
() => pendingRecheck,
);
fireEvent.click(screen.getByRole("button", { name: "다시 검사" }));
expect(screen.getByRole("button", { name: "검사 중" })).toBeDisabled();
expect(
screen.getByText("정합성 검사를 실행 중입니다."),
).toBeInTheDocument();
finishRecheck(integrityReport);
await waitFor(() => {
expect(screen.getByRole("button", { name: "다시 검사" })).toBeEnabled();
});
expect(screen.getByText("검사가 완료되었습니다.")).toBeInTheDocument();
});
it("blocks non-super admins", async () => { it("blocks non-super admins", async () => {
currentRole = "tenant_admin"; currentRole = "tenant_admin";

View File

@@ -77,6 +77,19 @@ function reasonLabel(reason: string) {
} }
} }
function recheckStatusText(status: "idle" | "running" | "success" | "error") {
switch (status) {
case "running":
return "정합성 검사를 실행 중입니다.";
case "success":
return "검사가 완료되었습니다.";
case "error":
return "검사에 실패했습니다.";
default:
return "";
}
}
function OrphanLoginIDTable({ function OrphanLoginIDTable({
items, items,
selectedIds, selectedIds,
@@ -156,6 +169,9 @@ function OrphanLoginIDTable({
function DataIntegrityContent() { function DataIntegrityContent() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [selectedOrphanIds, setSelectedOrphanIds] = useState<string[]>([]); const [selectedOrphanIds, setSelectedOrphanIds] = useState<string[]>([]);
const [recheckStatus, setRecheckStatus] = useState<
"idle" | "running" | "success" | "error"
>("idle");
const { data, isLoading, isError, error, refetch, isFetching } = useQuery({ const { data, isLoading, isError, error, refetch, isFetching } = useQuery({
queryKey: ["data-integrity-report"], queryKey: ["data-integrity-report"],
queryFn: fetchDataIntegrityReport, queryFn: fetchDataIntegrityReport,
@@ -194,6 +210,16 @@ function DataIntegrityContent() {
deleteMutation.mutate(selectedOrphanIds); deleteMutation.mutate(selectedOrphanIds);
} }
}; };
const isManualRechecking = recheckStatus === "running";
const handleRecheck = async () => {
if (isManualRechecking) {
return;
}
setRecheckStatus("running");
const result = await refetch();
setRecheckStatus(result.isError ? "error" : "success");
};
const recheckMessage = recheckStatusText(recheckStatus);
return ( return (
<main className="space-y-6 p-6 md:p-8"> <main className="space-y-6 p-6 md:p-8">
@@ -204,15 +230,25 @@ function DataIntegrityContent() {
</h2> </h2>
</div> </div>
<Button <div className="flex flex-col items-end gap-1">
type="button" <Button
variant="outline" type="button"
onClick={() => refetch()} variant="outline"
disabled={isFetching} onClick={handleRecheck}
> disabled={isLoading || isFetching || isManualRechecking}
<Database size={16} /> >
<Database size={16} />
</Button> {isManualRechecking ? "검사 중" : "다시 검사"}
</Button>
{recheckMessage ? (
<output
aria-live="polite"
className="text-xs text-muted-foreground"
>
{recheckMessage}
</output>
) : null}
</div>
</div> </div>
{isError ? ( {isError ? (

View File

@@ -3,6 +3,7 @@ import { expect, test } from "@playwright/test";
test.describe("Data integrity management", () => { test.describe("Data integrity management", () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
let orphanLoginIDDeleted = false; let orphanLoginIDDeleted = false;
let integrityReportRequests = 0;
await page.addInitScript(() => { await page.addInitScript(() => {
window.localStorage.setItem("locale", "ko"); window.localStorage.setItem("locale", "ko");
@@ -133,6 +134,10 @@ test.describe("Data integrity management", () => {
return; return;
} }
if (url.includes("/api/v1/admin/integrity")) { if (url.includes("/api/v1/admin/integrity")) {
integrityReportRequests += 1;
if (integrityReportRequests > 1) {
await new Promise((resolve) => setTimeout(resolve, 150));
}
await route.fulfill({ await route.fulfill({
json: { json: {
status: "fail", status: "fail",
@@ -184,6 +189,18 @@ test.describe("Data integrity management", () => {
await expect(page.getByRole("button", { name: "다시 검사" })).toBeVisible(); await expect(page.getByRole("button", { name: "다시 검사" })).toBeVisible();
}); });
test("shows manual recheck progress and completion", async ({ page }) => {
await page.goto("/system/data-integrity");
await expect(page.getByText("중복 테넌트 slug")).toBeVisible();
await page.getByRole("button", { name: "다시 검사" }).click();
await expect(page.getByRole("button", { name: "검사 중" })).toBeDisabled();
await expect(page.getByText("정합성 검사를 실행 중입니다.")).toBeVisible();
await expect(page.getByText("검사가 완료되었습니다.")).toBeVisible();
await expect(page.getByRole("button", { name: "다시 검사" })).toBeEnabled();
});
test("deletes selected orphan login ID targets after confirmation", async ({ test("deletes selected orphan login ID targets after confirmation", async ({
page, page,
}) => { }) => {

View File

@@ -46,6 +46,7 @@ Baron SSO의 신원/권한 SoT는 Ory Stack(Kratos, Keto, Hydra)입니다. 이
## adminfront 동작 ## adminfront 동작
- `super_admin`은 사이드바의 `데이터 정합성` 메뉴에서 리포트를 볼 수 있습니다. - `super_admin`은 사이드바의 `데이터 정합성` 메뉴에서 리포트를 볼 수 있습니다.
- `다시 검사` 실행 중에는 버튼이 비활성화되고 `검사 중` 상태가 표시됩니다. 요청이 끝나면 완료 또는 실패 상태 문구가 화면에 남습니다.
- `super_admin`은 같은 메뉴에서 유령 로그인 ID 대상을 확인하고, 체크박스로 선택한 뒤 확인 대화상자를 거쳐 삭제할 수 있습니다. - `super_admin`은 같은 메뉴에서 유령 로그인 ID 대상을 확인하고, 체크박스로 선택한 뒤 확인 대화상자를 거쳐 삭제할 수 있습니다.
- `super_admin`은 adminfront 개요 화면 하단에서도 최종 검증 상태, 실패 건수, 검사 시각, 섹션별 상태 요약을 볼 수 있습니다. - `super_admin`은 adminfront 개요 화면 하단에서도 최종 검증 상태, 실패 건수, 검사 시각, 섹션별 상태 요약을 볼 수 있습니다.
- `tenant_admin` 등 non-super role은 화면 접근 시 권한 없음 메시지만 봅니다. - `tenant_admin` 등 non-super role은 화면 접근 시 권한 없음 메시지만 봅니다.