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";
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", () => ({
fetchMe: vi.fn(async () => ({ role: currentRole })),
fetchDataIntegrityReport: vi.fn(async () => ({
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,
},
],
},
],
})),
fetchDataIntegrityReport: vi.fn(async () => integrityReport),
fetchOrphanUserLoginIDs: vi.fn(async () => ({
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 () => {
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({
items,
selectedIds,
@@ -156,6 +169,9 @@ function OrphanLoginIDTable({
function DataIntegrityContent() {
const queryClient = useQueryClient();
const [selectedOrphanIds, setSelectedOrphanIds] = useState<string[]>([]);
const [recheckStatus, setRecheckStatus] = useState<
"idle" | "running" | "success" | "error"
>("idle");
const { data, isLoading, isError, error, refetch, isFetching } = useQuery({
queryKey: ["data-integrity-report"],
queryFn: fetchDataIntegrityReport,
@@ -194,6 +210,16 @@ function DataIntegrityContent() {
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 (
<main className="space-y-6 p-6 md:p-8">
@@ -204,15 +230,25 @@ function DataIntegrityContent() {
</h2>
</div>
<Button
type="button"
variant="outline"
onClick={() => refetch()}
disabled={isFetching}
>
<Database size={16} />
</Button>
<div className="flex flex-col items-end gap-1">
<Button
type="button"
variant="outline"
onClick={handleRecheck}
disabled={isLoading || isFetching || isManualRechecking}
>
<Database size={16} />
{isManualRechecking ? "검사 중" : "다시 검사"}
</Button>
{recheckMessage ? (
<output
aria-live="polite"
className="text-xs text-muted-foreground"
>
{recheckMessage}
</output>
) : null}
</div>
</div>
{isError ? (

View File

@@ -3,6 +3,7 @@ import { expect, test } from "@playwright/test";
test.describe("Data integrity management", () => {
test.beforeEach(async ({ page }) => {
let orphanLoginIDDeleted = false;
let integrityReportRequests = 0;
await page.addInitScript(() => {
window.localStorage.setItem("locale", "ko");
@@ -133,6 +134,10 @@ test.describe("Data integrity management", () => {
return;
}
if (url.includes("/api/v1/admin/integrity")) {
integrityReportRequests += 1;
if (integrityReportRequests > 1) {
await new Promise((resolve) => setTimeout(resolve, 150));
}
await route.fulfill({
json: {
status: "fail",
@@ -184,6 +189,18 @@ test.describe("Data integrity management", () => {
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 ({
page,
}) => {

View File

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