1
0
forked from baron/baron-sso

권한부여 및 정합성 검사 추가

This commit is contained in:
2026-05-14 08:45:48 +09:00
parent f6f8e88342
commit 9ca73e8774
36 changed files with 1772 additions and 105 deletions

View File

@@ -0,0 +1,81 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchDataIntegrityReport, fetchMe } from "../../lib/adminApi";
import DataIntegrityPage from "./DataIntegrityPage";
let currentRole = "super_admin";
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,
},
],
},
],
})),
}));
function renderPage() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<DataIntegrityPage />
</QueryClientProvider>,
);
}
describe("DataIntegrityPage", () => {
beforeEach(() => {
currentRole = "super_admin";
vi.clearAllMocks();
});
it("renders integrity report for super_admin", async () => {
renderPage();
expect(await screen.findByText("데이터 정합성 검증")).toBeInTheDocument();
expect(await screen.findByText("테넌트 정합성")).toBeInTheDocument();
expect(screen.getByText("중복 테넌트 slug")).toBeInTheDocument();
expect(screen.getAllByText("1").length).toBeGreaterThan(0);
expect(fetchDataIntegrityReport).toHaveBeenCalledTimes(1);
});
it("blocks non-super admins", async () => {
currentRole = "tenant_admin";
renderPage();
expect(await screen.findByText("접근 권한이 없습니다")).toBeInTheDocument();
expect(fetchMe).toHaveBeenCalled();
expect(fetchDataIntegrityReport).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,209 @@
import { useQuery } from "@tanstack/react-query";
import {
AlertTriangle,
CheckCircle2,
Database,
ShieldAlert,
} from "lucide-react";
import { RoleGuard } from "../../components/auth/RoleGuard";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
type DataIntegrityCheck,
type DataIntegrityStatus,
fetchDataIntegrityReport,
} from "../../lib/adminApi";
function statusLabel(status: DataIntegrityStatus) {
switch (status) {
case "pass":
return "정상";
case "warning":
return "주의";
case "fail":
return "실패";
default:
return status;
}
}
function statusBadgeVariant(status: DataIntegrityStatus) {
switch (status) {
case "pass":
return "success";
case "warning":
return "warning";
default:
return "warning";
}
}
function formatDateTime(value?: string) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("ko-KR", {
dateStyle: "medium",
timeStyle: "medium",
}).format(date);
}
function CheckIcon({ check }: { check: DataIntegrityCheck }) {
if (check.status === "pass") {
return <CheckCircle2 className="text-emerald-600" size={18} />;
}
if (check.status === "warning") {
return <AlertTriangle className="text-amber-600" size={18} />;
}
return <ShieldAlert className="text-destructive" size={18} />;
}
function DataIntegrityContent() {
const { data, isLoading, isError, error, refetch, isFetching } = useQuery({
queryKey: ["data-integrity-report"],
queryFn: fetchDataIntegrityReport,
});
return (
<main className="space-y-6 p-6 md:p-8">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm text-muted-foreground">System</p>
<h2 className="text-2xl font-semibold tracking-tight">
</h2>
</div>
<Button
type="button"
variant="outline"
onClick={() => refetch()}
disabled={isFetching}
>
<Database size={16} />
</Button>
</div>
{isError ? (
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{(error as Error)?.message || "정합성 리포트를 불러오지 못했습니다."}
</section>
) : null}
<section className="rounded-lg border border-border bg-card p-5">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-4">
<div className="flex items-center gap-3">
<div className="grid h-10 w-10 place-items-center rounded-lg bg-primary/10 text-primary">
<ShieldAlert size={18} />
</div>
<div>
<h3 className="text-base font-semibold">Read model integrity</h3>
<p className="text-sm text-muted-foreground">
Ory SoT를 backend DB read model의
.
</p>
</div>
</div>
{data ? (
<Badge variant={statusBadgeVariant(data.status)}>
{statusLabel(data.status)}
</Badge>
) : null}
</div>
{isLoading ? (
<div className="py-8 text-sm text-muted-foreground"> </div>
) : (
<dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
<div>
<dt className="text-sm text-muted-foreground"> </dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.summary.totalChecks ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground"></dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.summary.passed ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground"> </dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.summary.failures ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground"> </dt>
<dd className="mt-1 text-sm">
{formatDateTime(data?.checkedAt)}
</dd>
</div>
</dl>
)}
</section>
<div className="space-y-4">
{(data?.sections ?? []).map((section) => (
<section
key={section.key}
className="rounded-lg border border-border bg-card p-5"
>
<div className="mb-4 flex items-center justify-between gap-3">
<h3 className="text-base font-semibold">{section.label}</h3>
<Badge variant={statusBadgeVariant(section.status)}>
{statusLabel(section.status)}
</Badge>
</div>
<div className="divide-y divide-border">
{section.checks.map((check) => (
<div
key={check.key}
className="grid gap-3 py-4 md:grid-cols-[1fr_auto]"
>
<div className="flex gap-3">
<CheckIcon check={check} />
<div>
<div className="font-medium">{check.label}</div>
<p className="mt-1 text-sm text-muted-foreground">
{check.description}
</p>
</div>
</div>
<div className="flex items-center gap-3 md:justify-end">
<Badge variant={statusBadgeVariant(check.status)}>
{statusLabel(check.status)}
</Badge>
<span className="min-w-12 text-right text-lg font-semibold tabular-nums">
{check.count}
</span>
</div>
</div>
))}
</div>
</section>
))}
</div>
</main>
);
}
export default function DataIntegrityPage() {
return (
<RoleGuard
roles={["super_admin"]}
fallback={
<main className="p-6 md:p-8">
<section className="rounded-lg border border-border bg-card p-5">
<h2 className="text-lg font-semibold"> </h2>
<p className="mt-2 text-sm text-muted-foreground">
super_admin .
</p>
</section>
</main>
}
>
<DataIntegrityContent />
</RoleGuard>
);
}