forked from baron/baron-sso
정합성 위반사항 확인 및 조치기능 추가
This commit is contained in:
@@ -1,7 +1,12 @@
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { fetchDataIntegrityReport, fetchMe } from "../../lib/adminApi";
|
import {
|
||||||
|
deleteOrphanUserLoginIDs,
|
||||||
|
fetchDataIntegrityReport,
|
||||||
|
fetchMe,
|
||||||
|
fetchOrphanUserLoginIDs,
|
||||||
|
} from "../../lib/adminApi";
|
||||||
import DataIntegrityPage from "./DataIntegrityPage";
|
import DataIntegrityPage from "./DataIntegrityPage";
|
||||||
|
|
||||||
let currentRole = "super_admin";
|
let currentRole = "super_admin";
|
||||||
@@ -36,6 +41,35 @@ vi.mock("../../lib/adminApi", () => ({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
})),
|
})),
|
||||||
|
fetchOrphanUserLoginIDs: vi.fn(async () => ({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "login-id-1",
|
||||||
|
userId: "user-1",
|
||||||
|
userEmail: "missing@example.com",
|
||||||
|
tenantId: "tenant-1",
|
||||||
|
tenantSlug: "deleted-tenant",
|
||||||
|
fieldKey: "emp_id",
|
||||||
|
loginId: "EMP001",
|
||||||
|
reasons: ["deleted_tenant"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
})),
|
||||||
|
deleteOrphanUserLoginIDs: vi.fn(async () => ({
|
||||||
|
deletedCount: 1,
|
||||||
|
deleted: [
|
||||||
|
{
|
||||||
|
id: "login-id-1",
|
||||||
|
userId: "user-1",
|
||||||
|
tenantId: "tenant-1",
|
||||||
|
fieldKey: "emp_id",
|
||||||
|
loginId: "EMP001",
|
||||||
|
reasons: ["deleted_tenant"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
skippedIds: [],
|
||||||
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function renderPage() {
|
function renderPage() {
|
||||||
@@ -69,6 +103,26 @@ describe("DataIntegrityPage", () => {
|
|||||||
expect(fetchDataIntegrityReport).toHaveBeenCalledTimes(1);
|
expect(fetchDataIntegrityReport).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows orphan login ID targets and deletes selected rows", async () => {
|
||||||
|
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText("유령 로그인 ID 정리")).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText("EMP001")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("삭제된 테넌트")).toBeInTheDocument();
|
||||||
|
expect(fetchOrphanUserLoginIDs).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("checkbox", { name: "EMP001 선택" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "선택 삭제" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(deleteOrphanUserLoginIDs).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
expect(vi.mocked(deleteOrphanUserLoginIDs).mock.calls[0][0]).toEqual([
|
||||||
|
"login-id-1",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("blocks non-super admins", async () => {
|
it("blocks non-super admins", async () => {
|
||||||
currentRole = "tenant_admin";
|
currentRole = "tenant_admin";
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Database,
|
Database,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
import { RoleGuard } from "../../components/auth/RoleGuard";
|
import { RoleGuard } from "../../components/auth/RoleGuard";
|
||||||
import { Badge } from "../../components/ui/badge";
|
import { Badge } from "../../components/ui/badge";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
type DataIntegrityCheck,
|
type DataIntegrityCheck,
|
||||||
type DataIntegrityStatus,
|
type DataIntegrityStatus,
|
||||||
|
type OrphanUserLoginID,
|
||||||
|
deleteOrphanUserLoginIDs,
|
||||||
fetchDataIntegrityReport,
|
fetchDataIntegrityReport,
|
||||||
|
fetchOrphanUserLoginIDs,
|
||||||
} from "../../lib/adminApi";
|
} from "../../lib/adminApi";
|
||||||
|
|
||||||
function statusLabel(status: DataIntegrityStatus) {
|
function statusLabel(status: DataIntegrityStatus) {
|
||||||
@@ -58,11 +62,138 @@ function CheckIcon({ check }: { check: DataIntegrityCheck }) {
|
|||||||
return <ShieldAlert className="text-destructive" size={18} />;
|
return <ShieldAlert className="text-destructive" size={18} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reasonLabel(reason: string) {
|
||||||
|
switch (reason) {
|
||||||
|
case "missing_user":
|
||||||
|
return "사용자 없음";
|
||||||
|
case "deleted_user":
|
||||||
|
return "삭제된 사용자";
|
||||||
|
case "missing_tenant":
|
||||||
|
return "테넌트 없음";
|
||||||
|
case "deleted_tenant":
|
||||||
|
return "삭제된 테넌트";
|
||||||
|
default:
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function OrphanLoginIDTable({
|
||||||
|
items,
|
||||||
|
selectedIds,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
items: OrphanUserLoginID[];
|
||||||
|
selectedIds: string[];
|
||||||
|
onToggle: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded border border-border/60 px-3 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
삭제할 유령 로그인 ID가 없습니다.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedSet = new Set(selectedIds);
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto rounded border border-border/60">
|
||||||
|
<table className="w-full min-w-[760px] text-sm">
|
||||||
|
<thead className="bg-muted/50 text-left text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="w-12 px-3 py-2">선택</th>
|
||||||
|
<th className="px-3 py-2">Login ID</th>
|
||||||
|
<th className="px-3 py-2">Field</th>
|
||||||
|
<th className="px-3 py-2">User</th>
|
||||||
|
<th className="px-3 py-2">Tenant</th>
|
||||||
|
<th className="px-3 py-2">사유</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{items.map((item) => (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={`${item.loginId} 선택`}
|
||||||
|
checked={selectedSet.has(item.id)}
|
||||||
|
onChange={() => onToggle(item.id)}
|
||||||
|
className="h-4 w-4 rounded border-input"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 font-medium">{item.loginId}</td>
|
||||||
|
<td className="px-3 py-2 text-muted-foreground">
|
||||||
|
{item.fieldKey}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div>{item.userEmail || "-"}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{item.userId}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div>{item.tenantSlug || "-"}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{item.tenantId}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{item.reasons.map((reason) => (
|
||||||
|
<Badge key={reason} variant="warning">
|
||||||
|
{reasonLabel(reason)}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function DataIntegrityContent() {
|
function DataIntegrityContent() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [selectedOrphanIds, setSelectedOrphanIds] = useState<string[]>([]);
|
||||||
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,
|
||||||
});
|
});
|
||||||
|
const orphanLoginIDsQuery = useQuery({
|
||||||
|
queryKey: ["orphan-user-login-ids"],
|
||||||
|
queryFn: fetchOrphanUserLoginIDs,
|
||||||
|
});
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: deleteOrphanUserLoginIDs,
|
||||||
|
onSuccess: async () => {
|
||||||
|
setSelectedOrphanIds([]);
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["data-integrity-report"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["orphan-user-login-ids"] }),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const orphanItems = orphanLoginIDsQuery.data?.items ?? [];
|
||||||
|
const toggleOrphanID = (id: string) => {
|
||||||
|
setSelectedOrphanIds((current) =>
|
||||||
|
current.includes(id)
|
||||||
|
? current.filter((selectedID) => selectedID !== id)
|
||||||
|
: [...current, id],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const handleDeleteSelected = () => {
|
||||||
|
if (selectedOrphanIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`선택한 ${selectedOrphanIds.length}개의 유령 로그인 ID를 삭제하시겠습니까?`,
|
||||||
|
);
|
||||||
|
if (confirmed) {
|
||||||
|
deleteMutation.mutate(selectedOrphanIds);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="space-y-6 p-6 md:p-8">
|
<main className="space-y-6 p-6 md:p-8">
|
||||||
@@ -184,6 +315,44 @@ function DataIntegrityContent() {
|
|||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-border bg-card p-5">
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold">유령 로그인 ID 정리</h3>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를
|
||||||
|
확인한 뒤 선택 삭제합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDeleteSelected}
|
||||||
|
disabled={
|
||||||
|
selectedOrphanIds.length === 0 || deleteMutation.isPending
|
||||||
|
}
|
||||||
|
>
|
||||||
|
선택 삭제
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{orphanLoginIDsQuery.isError ? (
|
||||||
|
<div className="mb-3 rounded border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
유령 로그인 ID 대상을 불러오지 못했습니다.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{deleteMutation.data ? (
|
||||||
|
<div className="mb-3 rounded border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200">
|
||||||
|
{deleteMutation.data.deletedCount}개의 유령 로그인 ID를
|
||||||
|
삭제했습니다.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<OrphanLoginIDTable
|
||||||
|
items={orphanItems}
|
||||||
|
selectedIds={selectedOrphanIds}
|
||||||
|
onToggle={toggleOrphanID}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -648,12 +648,6 @@ function UserCreatePage() {
|
|||||||
<option value="super_admin">
|
<option value="super_admin">
|
||||||
{t("ui.admin.role.super_admin", "시스템 관리자")}
|
{t("ui.admin.role.super_admin", "시스템 관리자")}
|
||||||
</option>
|
</option>
|
||||||
<option value="tenant_admin">
|
|
||||||
{t("ui.admin.role.tenant_admin", "테넌트 관리자")}
|
|
||||||
</option>
|
|
||||||
<option value="rp_admin">
|
|
||||||
{t("ui.admin.role.rp_admin", "서비스 관리자")}
|
|
||||||
</option>
|
|
||||||
<option value="user">
|
<option value="user">
|
||||||
{t("ui.admin.role.user", "일반 사용자")}
|
{t("ui.admin.role.user", "일반 사용자")}
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ import {
|
|||||||
} from "../../lib/adminApi";
|
} from "../../lib/adminApi";
|
||||||
import type { PasswordPolicyResponse } from "../../lib/adminApi";
|
import type { PasswordPolicyResponse } from "../../lib/adminApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
import { isSuperAdminRole, normalizeAdminRole } from "../../lib/roles";
|
import { normalizeAdminRole } from "../../lib/roles";
|
||||||
import { generateSecurePassword } from "../../lib/utils";
|
import { generateSecurePassword } from "../../lib/utils";
|
||||||
import {
|
import {
|
||||||
type OrgChartTenantSelection,
|
type OrgChartTenantSelection,
|
||||||
@@ -740,6 +740,7 @@ function UserDetailPage() {
|
|||||||
...data,
|
...data,
|
||||||
metadata,
|
metadata,
|
||||||
};
|
};
|
||||||
|
payload.role = undefined;
|
||||||
|
|
||||||
if (userCategory === "personal") {
|
if (userCategory === "personal") {
|
||||||
try {
|
try {
|
||||||
@@ -1059,38 +1060,6 @@ function UserDetailPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="role"
|
|
||||||
className="text-xs font-bold uppercase text-muted-foreground"
|
|
||||||
>
|
|
||||||
{t("ui.admin.users.detail.form.role", "역할")}
|
|
||||||
</Label>
|
|
||||||
<div className="flex h-11 items-center gap-3">
|
|
||||||
<select
|
|
||||||
id="role"
|
|
||||||
className="flex h-11 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:opacity-50"
|
|
||||||
{...register("role")}
|
|
||||||
disabled={
|
|
||||||
!isSuperAdminRole(profile?.role) ||
|
|
||||||
profile?.id === user?.id
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="super_admin">
|
|
||||||
{t("ui.admin.role.super_admin", "시스템 관리자")}
|
|
||||||
</option>
|
|
||||||
<option value="tenant_admin">
|
|
||||||
{t("ui.admin.role.tenant_admin", "테넌트 관리자")}
|
|
||||||
</option>
|
|
||||||
<option value="rp_admin">
|
|
||||||
{t("ui.admin.role.rp_admin", "서비스 관리자")}
|
|
||||||
</option>
|
|
||||||
<option value="user">
|
|
||||||
{t("ui.admin.role.user", "일반 사용자")}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
|
|||||||
@@ -92,16 +92,6 @@ const bulkPermissionOptions = [
|
|||||||
labelKey: "ui.admin.role.super_admin",
|
labelKey: "ui.admin.role.super_admin",
|
||||||
fallback: "시스템 관리자",
|
fallback: "시스템 관리자",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
value: "tenant_admin",
|
|
||||||
labelKey: "ui.admin.role.tenant_admin",
|
|
||||||
fallback: "테넌트 관리자",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "rp_admin",
|
|
||||||
labelKey: "ui.admin.role.rp_admin",
|
|
||||||
fallback: "서비스 관리자",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
value: "user",
|
value: "user",
|
||||||
labelKey: "ui.admin.role.user",
|
labelKey: "ui.admin.role.user",
|
||||||
@@ -109,6 +99,10 @@ const bulkPermissionOptions = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
function assignableSystemRoleValue(role?: string | null) {
|
||||||
|
return isSuperAdminRole(role) ? "super_admin" : "user";
|
||||||
|
}
|
||||||
|
|
||||||
function UserListPage() {
|
function UserListPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [page, setPage] = React.useState(1);
|
const [page, setPage] = React.useState(1);
|
||||||
@@ -771,7 +765,7 @@ function UserListPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Select
|
<Select
|
||||||
defaultValue={user.role}
|
value={assignableSystemRoleValue(user.role)}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
bulkUpdateMutation.mutate({
|
bulkUpdateMutation.mutate({
|
||||||
userIds: [user.id],
|
userIds: [user.id],
|
||||||
@@ -788,23 +782,14 @@ function UserListPage() {
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{isSuperAdminRole(profile?.role) && (
|
{bulkPermissionOptions.map((option) => (
|
||||||
<SelectItem value="super_admin">
|
<SelectItem
|
||||||
{t(
|
key={option.value}
|
||||||
"ui.admin.role.super_admin",
|
value={option.value}
|
||||||
"시스템 관리자",
|
>
|
||||||
)}
|
{t(option.labelKey, option.fallback)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
)}
|
))}
|
||||||
<SelectItem value="tenant_admin">
|
|
||||||
{t("ui.admin.role.tenant_admin", "테넌트 관리자")}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="rp_admin">
|
|
||||||
{t("ui.admin.role.rp_admin", "서비스 관리자")}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="user">
|
|
||||||
{t("ui.admin.role.user", "일반 사용자")}
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -180,6 +180,30 @@ export type DataIntegrityReport = {
|
|||||||
sections: DataIntegritySection[];
|
sections: DataIntegritySection[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type OrphanUserLoginID = {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
userEmail?: string;
|
||||||
|
userDeletedAt?: string;
|
||||||
|
tenantId: string;
|
||||||
|
tenantSlug?: string;
|
||||||
|
tenantDeletedAt?: string;
|
||||||
|
fieldKey: string;
|
||||||
|
loginId: string;
|
||||||
|
reasons: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrphanUserLoginIDListResponse = {
|
||||||
|
items: OrphanUserLoginID[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeleteOrphanUserLoginIDsResult = {
|
||||||
|
deletedCount: number;
|
||||||
|
deleted: OrphanUserLoginID[];
|
||||||
|
skippedIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export async function fetchAuditLogs(limit = 50, cursor?: string) {
|
export async function fetchAuditLogs(limit = 50, cursor?: string) {
|
||||||
const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", {
|
const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", {
|
||||||
params: { limit, cursor },
|
params: { limit, cursor },
|
||||||
@@ -199,6 +223,21 @@ export async function fetchDataIntegrityReport() {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchOrphanUserLoginIDs() {
|
||||||
|
const { data } = await apiClient.get<OrphanUserLoginIDListResponse>(
|
||||||
|
"/v1/admin/integrity/orphan-user-login-ids",
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteOrphanUserLoginIDs(ids: string[]) {
|
||||||
|
const { data } = await apiClient.delete<DeleteOrphanUserLoginIDsResult>(
|
||||||
|
"/v1/admin/integrity/orphan-user-login-ids",
|
||||||
|
{ data: { ids } },
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchUserProjectionStatus() {
|
export async function fetchUserProjectionStatus() {
|
||||||
const { data } = await apiClient.get<UserProjectionStatus>(
|
const { data } = await apiClient.get<UserProjectionStatus>(
|
||||||
"/v1/admin/projections/users",
|
"/v1/admin/projections/users",
|
||||||
|
|||||||
@@ -235,6 +235,135 @@ test.describe("Bulk Actions and Tree Search", () => {
|
|||||||
await expect(selectionBar).not.toBeVisible({ timeout: 10000 });
|
await expect(selectionBar).not.toBeVisible({ timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should only expose super admin grant and revoke options in bulk permission select", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/users");
|
||||||
|
await expect(page.locator("table")).toContainText("User One", {
|
||||||
|
timeout: 20000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.locator('table input[type="checkbox"]').nth(1).click();
|
||||||
|
await expect(page.getByTestId("bulk-action-bar")).toBeVisible({
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByTestId("bulk-permission-select").click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("option", { name: /시스템 관리자|Super Admin/i }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("option", { name: /일반 사용자|User/i }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("option", { name: /테넌트 관리자|Tenant Admin/i }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("option", {
|
||||||
|
name: /서비스 관리자|RP Admin|Service Admin/i,
|
||||||
|
}),
|
||||||
|
).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should let super admins revoke selected super admin permission", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
let capturedPayload: unknown = null;
|
||||||
|
await page.route("**/api/v1/admin/users/bulk", async (route) => {
|
||||||
|
if (route.request().method() === "PUT") {
|
||||||
|
capturedPayload = route.request().postDataJSON();
|
||||||
|
return route.fulfill({
|
||||||
|
json: { results: [{ id: "u-1", success: true }] },
|
||||||
|
headers: { "Access-Control-Allow-Origin": "*" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return route.fallback();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/users");
|
||||||
|
await expect(page.locator("table")).toContainText("User One", {
|
||||||
|
timeout: 20000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.locator('table input[type="checkbox"]').nth(1).click();
|
||||||
|
await expect(page.getByTestId("bulk-action-bar")).toBeVisible({
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByTestId("bulk-permission-select").click();
|
||||||
|
await page.getByRole("option", { name: /일반 사용자|User/i }).click();
|
||||||
|
await page.getByTestId("bulk-apply-permission-btn").click();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => capturedPayload)
|
||||||
|
.toEqual({
|
||||||
|
userIds: ["u-1"],
|
||||||
|
role: "user",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not render role field on user detail page", async ({ page }) => {
|
||||||
|
await page.unroute("**/api/v1/**");
|
||||||
|
await page.route("**/api/v1/**", async (route) => {
|
||||||
|
const url = route.request().url();
|
||||||
|
const headers = { "Access-Control-Allow-Origin": "*" };
|
||||||
|
|
||||||
|
if (url.includes("/user/me")) {
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
id: "admin",
|
||||||
|
role: "super_admin",
|
||||||
|
name: "Admin",
|
||||||
|
manageableTenants: [],
|
||||||
|
},
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes("/auth/password/policy")) {
|
||||||
|
return route.fulfill({ json: { minLength: 12 }, headers });
|
||||||
|
}
|
||||||
|
if (url.includes("/admin/users/u-1/rp-history")) {
|
||||||
|
return route.fulfill({ json: [], headers });
|
||||||
|
}
|
||||||
|
if (url.includes("/admin/users/u-1")) {
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
id: "u-1",
|
||||||
|
name: "User One",
|
||||||
|
email: "u1@test.com",
|
||||||
|
phone: "",
|
||||||
|
status: "active",
|
||||||
|
role: "user",
|
||||||
|
tenantSlug: "main",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
metadata: {},
|
||||||
|
},
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes("/admin/tenants")) {
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
items: [
|
||||||
|
{ id: "t-1", name: "Main Tenant", slug: "main", type: "COMPANY" },
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
},
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return route.fulfill({ json: { items: [], total: 0 }, headers });
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/users/u-1");
|
||||||
|
await expect(page.getByRole("heading", { name: "User One" })).toBeVisible({
|
||||||
|
timeout: 20000,
|
||||||
|
});
|
||||||
|
await expect(page.locator("#role")).toHaveCount(0);
|
||||||
|
await expect(page.getByLabel("역할")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
test("should let canonical super admin aliases promote selected users", async ({
|
test("should let canonical super admin aliases promote selected users", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ 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;
|
||||||
|
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
window.localStorage.setItem("locale", "ko");
|
window.localStorage.setItem("locale", "ko");
|
||||||
window.localStorage.setItem("admin_session", "fake-token");
|
window.localStorage.setItem("admin_session", "fake-token");
|
||||||
|
window.localStorage.setItem("RoleSwitcher-Collapsed", "true");
|
||||||
(
|
(
|
||||||
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
||||||
)._IS_TEST_MODE = true;
|
)._IS_TEST_MODE = true;
|
||||||
@@ -87,6 +90,48 @@ test.describe("Data integrity management", () => {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (url.includes("/api/v1/admin/integrity/orphan-user-login-ids")) {
|
||||||
|
if (route.request().method() === "DELETE") {
|
||||||
|
orphanLoginIDDeleted = true;
|
||||||
|
await route.fulfill({
|
||||||
|
json: {
|
||||||
|
deletedCount: 1,
|
||||||
|
deleted: [
|
||||||
|
{
|
||||||
|
id: "login-id-1",
|
||||||
|
userId: "user-1",
|
||||||
|
tenantId: "tenant-1",
|
||||||
|
fieldKey: "emp_id",
|
||||||
|
loginId: "EMP001",
|
||||||
|
reasons: ["deleted_tenant"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
skippedIds: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.fulfill({
|
||||||
|
json: orphanLoginIDDeleted
|
||||||
|
? { items: [], total: 0 }
|
||||||
|
: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "login-id-1",
|
||||||
|
userId: "user-1",
|
||||||
|
userEmail: "missing@example.com",
|
||||||
|
tenantId: "tenant-1",
|
||||||
|
tenantSlug: "deleted-tenant",
|
||||||
|
fieldKey: "emp_id",
|
||||||
|
loginId: "EMP001",
|
||||||
|
reasons: ["deleted_tenant"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (url.includes("/api/v1/admin/integrity")) {
|
if (url.includes("/api/v1/admin/integrity")) {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
json: {
|
json: {
|
||||||
@@ -139,6 +184,28 @@ test.describe("Data integrity management", () => {
|
|||||||
await expect(page.getByRole("button", { name: "다시 검사" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "다시 검사" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("deletes selected orphan login ID targets after confirmation", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
page.on("dialog", async (dialog) => {
|
||||||
|
await dialog.accept();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/system/data-integrity");
|
||||||
|
|
||||||
|
await expect(page.getByText("EMP001")).toBeVisible();
|
||||||
|
await expect(page.getByText("삭제된 테넌트")).toBeVisible();
|
||||||
|
await page.getByRole("checkbox", { name: "EMP001 선택" }).check();
|
||||||
|
await page.getByRole("button", { name: "선택 삭제" }).click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByText("1개의 유령 로그인 ID를 삭제했습니다."),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByText("삭제할 유령 로그인 ID가 없습니다."),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test("shows the latest integrity summary on the overview page", async ({
|
test("shows the latest integrity summary on the overview page", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -716,6 +716,8 @@ func main() {
|
|||||||
admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능)
|
admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능)
|
||||||
admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats)
|
admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats)
|
||||||
admin.Get("/integrity", requireSuperAdmin, adminHandler.GetDataIntegrity)
|
admin.Get("/integrity", requireSuperAdmin, adminHandler.GetDataIntegrity)
|
||||||
|
admin.Get("/integrity/orphan-user-login-ids", requireSuperAdmin, adminHandler.ListOrphanUserLoginIDs)
|
||||||
|
admin.Delete("/integrity/orphan-user-login-ids", requireSuperAdmin, adminHandler.DeleteOrphanUserLoginIDs)
|
||||||
admin.Get("/projections/users", requireSuperAdmin, adminHandler.GetUserProjectionStatus)
|
admin.Get("/projections/users", requireSuperAdmin, adminHandler.GetUserProjectionStatus)
|
||||||
admin.Post("/projections/users/reconcile", requireSuperAdmin, adminHandler.ReconcileUserProjection)
|
admin.Post("/projections/users/reconcile", requireSuperAdmin, adminHandler.ReconcileUserProjection)
|
||||||
admin.Post("/projections/users/reset", requireSuperAdmin, adminHandler.ResetUserProjection)
|
admin.Post("/projections/users/reset", requireSuperAdmin, adminHandler.ResetUserProjection)
|
||||||
|
|||||||
@@ -39,3 +39,22 @@ type DataIntegrityCheck struct {
|
|||||||
Severity string `json:"severity"`
|
Severity string `json:"severity"`
|
||||||
Count int64 `json:"count"`
|
Count int64 `json:"count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OrphanUserLoginID struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
UserEmail string `json:"userEmail,omitempty"`
|
||||||
|
UserDeletedAt *time.Time `json:"userDeletedAt,omitempty"`
|
||||||
|
TenantID string `json:"tenantId"`
|
||||||
|
TenantSlug string `json:"tenantSlug,omitempty"`
|
||||||
|
TenantDeletedAt *time.Time `json:"tenantDeletedAt,omitempty"`
|
||||||
|
FieldKey string `json:"fieldKey"`
|
||||||
|
LoginID string `json:"loginId"`
|
||||||
|
Reasons []string `json:"reasons"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeleteOrphanUserLoginIDsResult struct {
|
||||||
|
DeletedCount int64 `json:"deletedCount"`
|
||||||
|
Deleted []OrphanUserLoginID `json:"deleted"`
|
||||||
|
SkippedIDs []string `json:"skippedIds"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -169,6 +169,43 @@ func (h *AdminHandler) GetDataIntegrity(c *fiber.Ctx) error {
|
|||||||
return c.JSON(report)
|
return c.JSON(report)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) ListOrphanUserLoginIDs(c *fiber.Ctx) error {
|
||||||
|
if !requireSuperAdminProfile(c) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if h == nil || h.IntegrityChecker == nil {
|
||||||
|
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "data integrity checker unavailable"})
|
||||||
|
}
|
||||||
|
items, err := h.IntegrityChecker.ListOrphanUserLoginIDs(c.Context())
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"items": items,
|
||||||
|
"total": len(items),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) DeleteOrphanUserLoginIDs(c *fiber.Ctx) error {
|
||||||
|
if !requireSuperAdminProfile(c) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if h == nil || h.IntegrityChecker == nil {
|
||||||
|
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "data integrity checker unavailable"})
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
IDs []string `json:"ids"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||||
|
}
|
||||||
|
result, err := h.IntegrityChecker.DeleteOrphanUserLoginIDs(c.Context(), req.IDs)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(result)
|
||||||
|
}
|
||||||
|
|
||||||
// GetSystemStats returns runtime statistics for monitoring
|
// GetSystemStats returns runtime statistics for monitoring
|
||||||
func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error {
|
func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error {
|
||||||
var m runtime.MemStats
|
var m runtime.MemStats
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -14,9 +15,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type fakeDataIntegrityChecker struct {
|
type fakeDataIntegrityChecker struct {
|
||||||
calls int
|
calls int
|
||||||
report domain.DataIntegrityReport
|
listCalls int
|
||||||
err error
|
deleteCalls int
|
||||||
|
deletedIDs []string
|
||||||
|
report domain.DataIntegrityReport
|
||||||
|
orphans []domain.OrphanUserLoginID
|
||||||
|
deleteResult domain.DeleteOrphanUserLoginIDsResult
|
||||||
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeDataIntegrityChecker) CheckDataIntegrity(ctx context.Context) (domain.DataIntegrityReport, error) {
|
func (f *fakeDataIntegrityChecker) CheckDataIntegrity(ctx context.Context) (domain.DataIntegrityReport, error) {
|
||||||
@@ -24,6 +30,17 @@ func (f *fakeDataIntegrityChecker) CheckDataIntegrity(ctx context.Context) (doma
|
|||||||
return f.report, f.err
|
return f.report, f.err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeDataIntegrityChecker) ListOrphanUserLoginIDs(ctx context.Context) ([]domain.OrphanUserLoginID, error) {
|
||||||
|
f.listCalls++
|
||||||
|
return f.orphans, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeDataIntegrityChecker) DeleteOrphanUserLoginIDs(ctx context.Context, ids []string) (domain.DeleteOrphanUserLoginIDsResult, error) {
|
||||||
|
f.deleteCalls++
|
||||||
|
f.deletedIDs = append([]string(nil), ids...)
|
||||||
|
return f.deleteResult, f.err
|
||||||
|
}
|
||||||
|
|
||||||
func TestAdminHandler_GetDataIntegrityRequiresSuperAdmin(t *testing.T) {
|
func TestAdminHandler_GetDataIntegrityRequiresSuperAdmin(t *testing.T) {
|
||||||
checker := &fakeDataIntegrityChecker{}
|
checker := &fakeDataIntegrityChecker{}
|
||||||
h := &AdminHandler{IntegrityChecker: checker}
|
h := &AdminHandler{IntegrityChecker: checker}
|
||||||
@@ -90,3 +107,90 @@ func TestAdminHandler_GetDataIntegrityReturnsReportForSuperAdmin(t *testing.T) {
|
|||||||
require.Len(t, body.Sections, 1)
|
require.Len(t, body.Sections, 1)
|
||||||
require.Equal(t, "tenant_integrity", body.Sections[0].Key)
|
require.Equal(t, "tenant_integrity", body.Sections[0].Key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdminHandler_ListOrphanUserLoginIDsReturnsTargetsForSuperAdmin(t *testing.T) {
|
||||||
|
checker := &fakeDataIntegrityChecker{
|
||||||
|
orphans: []domain.OrphanUserLoginID{
|
||||||
|
{
|
||||||
|
ID: "login-id-1",
|
||||||
|
UserID: "user-1",
|
||||||
|
TenantID: "tenant-1",
|
||||||
|
FieldKey: "emp_id",
|
||||||
|
LoginID: "EMP001",
|
||||||
|
Reasons: []string{"missing_tenant"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := &AdminHandler{IntegrityChecker: checker}
|
||||||
|
app := fiber.New()
|
||||||
|
app.Use(func(c *fiber.Ctx) error {
|
||||||
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
|
||||||
|
return c.Next()
|
||||||
|
})
|
||||||
|
app.Get("/api/v1/admin/integrity/orphan-user-login-ids", h.ListOrphanUserLoginIDs)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/integrity/orphan-user-login-ids", nil)
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
require.Equal(t, 1, checker.listCalls)
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Items []domain.OrphanUserLoginID `json:"items"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||||
|
require.Equal(t, 1, body.Total)
|
||||||
|
require.Equal(t, "login-id-1", body.Items[0].ID)
|
||||||
|
require.Equal(t, []string{"missing_tenant"}, body.Items[0].Reasons)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminHandler_DeleteOrphanUserLoginIDsRequiresSuperAdminAndDeletesSelectedTargets(t *testing.T) {
|
||||||
|
checker := &fakeDataIntegrityChecker{
|
||||||
|
deleteResult: domain.DeleteOrphanUserLoginIDsResult{
|
||||||
|
DeletedCount: 1,
|
||||||
|
Deleted: []domain.OrphanUserLoginID{
|
||||||
|
{ID: "login-id-1", LoginID: "EMP001", Reasons: []string{"missing_user"}},
|
||||||
|
},
|
||||||
|
SkippedIDs: []string{"valid-login-id"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := &AdminHandler{IntegrityChecker: checker}
|
||||||
|
app := fiber.New()
|
||||||
|
app.Use(func(c *fiber.Ctx) error {
|
||||||
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
|
||||||
|
return c.Next()
|
||||||
|
})
|
||||||
|
app.Delete("/api/v1/admin/integrity/orphan-user-login-ids", h.DeleteOrphanUserLoginIDs)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/integrity/orphan-user-login-ids", strings.NewReader(`{"ids":["login-id-1","valid-login-id"]}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
require.Equal(t, 1, checker.deleteCalls)
|
||||||
|
require.Equal(t, []string{"login-id-1", "valid-login-id"}, checker.deletedIDs)
|
||||||
|
|
||||||
|
var body domain.DeleteOrphanUserLoginIDsResult
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||||
|
require.Equal(t, int64(1), body.DeletedCount)
|
||||||
|
require.Equal(t, []string{"valid-login-id"}, body.SkippedIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminHandler_DeleteOrphanUserLoginIDsRejectsTenantAdmin(t *testing.T) {
|
||||||
|
checker := &fakeDataIntegrityChecker{}
|
||||||
|
h := &AdminHandler{IntegrityChecker: checker}
|
||||||
|
app := fiber.New()
|
||||||
|
app.Use(func(c *fiber.Ctx) error {
|
||||||
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "tenant-admin", Role: domain.RoleTenantAdmin})
|
||||||
|
return c.Next()
|
||||||
|
})
|
||||||
|
app.Delete("/api/v1/admin/integrity/orphan-user-login-ids", h.DeleteOrphanUserLoginIDs)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/integrity/orphan-user-login-ids", strings.NewReader(`{"ids":["login-id-1"]}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
require.Equal(t, 0, checker.deleteCalls)
|
||||||
|
}
|
||||||
|
|||||||
@@ -210,6 +210,14 @@ func roleFromTraits(traits map[string]interface{}) string {
|
|||||||
return domain.RoleUser
|
return domain.RoleUser
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeAssignableSystemRole(value string) (string, bool) {
|
||||||
|
role, ok := domain.NormalizeRoleAlias(value)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return role, role == domain.RoleSuperAdmin || role == domain.RoleUser
|
||||||
|
}
|
||||||
|
|
||||||
func gradeFromTraits(traits map[string]interface{}) string {
|
func gradeFromTraits(traits map[string]interface{}) string {
|
||||||
value := strings.TrimSpace(extractTraitString(traits, "grade"))
|
value := strings.TrimSpace(extractTraitString(traits, "grade"))
|
||||||
if value == "" {
|
if value == "" {
|
||||||
@@ -661,9 +669,19 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
role := domain.NormalizeRole(req.Role)
|
role := domain.RoleUser
|
||||||
if role == "" {
|
if strings.TrimSpace(req.Role) != "" {
|
||||||
role = domain.RoleUser
|
normalizedRole, ok := normalizeAssignableSystemRole(req.Role)
|
||||||
|
if !ok {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, "invalid role")
|
||||||
|
}
|
||||||
|
if normalizedRole == domain.RoleSuperAdmin {
|
||||||
|
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||||
|
if requester == nil || domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
|
||||||
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can assign super admin role")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
role = normalizedRole
|
||||||
}
|
}
|
||||||
|
|
||||||
attributes := map[string]interface{}{
|
attributes := map[string]interface{}{
|
||||||
@@ -1532,7 +1550,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
|||||||
if domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
|
if domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
|
||||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can change user role")
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can change user role")
|
||||||
}
|
}
|
||||||
role, ok := domain.NormalizeRoleAlias(*req.Role)
|
role, ok := normalizeAssignableSystemRole(*req.Role)
|
||||||
if !ok {
|
if !ok {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, "invalid role")
|
return errorJSON(c, fiber.StatusBadRequest, "invalid role")
|
||||||
}
|
}
|
||||||
@@ -1841,7 +1859,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
if requester == nil || domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
|
if requester == nil || domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
|
||||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can change user role")
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can change user role")
|
||||||
}
|
}
|
||||||
role, ok := domain.NormalizeRoleAlias(*req.Role)
|
role, ok := normalizeAssignableSystemRole(*req.Role)
|
||||||
if !ok {
|
if !ok {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, "invalid role")
|
return errorJSON(c, fiber.StatusBadRequest, "invalid role")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1020,6 +1020,21 @@ func TestUserHandler_BulkUpdateUsers(t *testing.T) {
|
|||||||
assert.Equal(t, domain.UserStatusInactive, worksmobile.upserts[0].Status)
|
assert.Equal(t, domain.UserStatusInactive, worksmobile.upserts[0].Status)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("Fail - Super admin cannot assign tenant or RP admin roles", func(t *testing.T) {
|
||||||
|
for _, role := range []string{domain.RoleTenantAdmin, domain.RoleRPAdmin} {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"userIds": []string{"u-1"},
|
||||||
|
"role": role,
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest("PUT", "/users/bulk", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, _ := app.Test(req)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("Fail - Tenant admin cannot update role", func(t *testing.T) {
|
t.Run("Fail - Tenant admin cannot update role", func(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
h := &UserHandler{KratosAdmin: new(MockKratosAdmin)}
|
h := &UserHandler{KratosAdmin: new(MockKratosAdmin)}
|
||||||
@@ -1141,6 +1156,33 @@ func TestUserHandler_UpdateUser_AdminOnlyField(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUserHandler_UpdateUser_RejectsDeprecatedAdminRoles(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
mockKratos := new(MockKratosAdmin)
|
||||||
|
h := &UserHandler{KratosAdmin: mockKratos}
|
||||||
|
app.Put("/users/:id", func(c *fiber.Ctx) error {
|
||||||
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin-1", Role: domain.RoleSuperAdmin})
|
||||||
|
return h.UpdateUser(c)
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, role := range []string{domain.RoleTenantAdmin, domain.RoleRPAdmin} {
|
||||||
|
mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{
|
||||||
|
ID: "u-1",
|
||||||
|
Traits: map[string]interface{}{"email": "user@test.com", "role": domain.RoleUser},
|
||||||
|
State: "active",
|
||||||
|
}, nil).Once()
|
||||||
|
|
||||||
|
payload := map[string]interface{}{"role": role}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest("PUT", "/users/u-1", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, _ := app.Test(req)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
}
|
||||||
|
mockKratos.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) {
|
func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) {
|
||||||
t.Run("Success - Sync LoginID from namespaced metadata", func(t *testing.T) {
|
t.Run("Success - Sync LoginID from namespaced metadata", func(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
"context"
|
"context"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -10,6 +12,8 @@ import (
|
|||||||
|
|
||||||
type DataIntegrityChecker interface {
|
type DataIntegrityChecker interface {
|
||||||
CheckDataIntegrity(ctx context.Context) (domain.DataIntegrityReport, error)
|
CheckDataIntegrity(ctx context.Context) (domain.DataIntegrityReport, error)
|
||||||
|
ListOrphanUserLoginIDs(ctx context.Context) ([]domain.OrphanUserLoginID, error)
|
||||||
|
DeleteOrphanUserLoginIDs(ctx context.Context, ids []string) (domain.DeleteOrphanUserLoginIDsResult, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type dataIntegrityChecker struct {
|
type dataIntegrityChecker struct {
|
||||||
@@ -24,6 +28,14 @@ func (c *dataIntegrityChecker) CheckDataIntegrity(ctx context.Context) (domain.D
|
|||||||
return CheckDataIntegrity(ctx, c.db)
|
return CheckDataIntegrity(ctx, c.db)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *dataIntegrityChecker) ListOrphanUserLoginIDs(ctx context.Context) ([]domain.OrphanUserLoginID, error) {
|
||||||
|
return ListOrphanUserLoginIDs(ctx, c.db, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dataIntegrityChecker) DeleteOrphanUserLoginIDs(ctx context.Context, ids []string) (domain.DeleteOrphanUserLoginIDsResult, error) {
|
||||||
|
return DeleteOrphanUserLoginIDs(ctx, c.db, ids)
|
||||||
|
}
|
||||||
|
|
||||||
func CheckDataIntegrity(ctx context.Context, db *gorm.DB) (domain.DataIntegrityReport, error) {
|
func CheckDataIntegrity(ctx context.Context, db *gorm.DB) (domain.DataIntegrityReport, error) {
|
||||||
tenantChecks := []domain.DataIntegrityCheck{
|
tenantChecks := []domain.DataIntegrityCheck{
|
||||||
{
|
{
|
||||||
@@ -224,3 +236,145 @@ func summarizeSectionStatus(sections []domain.DataIntegritySection) domain.DataI
|
|||||||
}
|
}
|
||||||
return status
|
return status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ListOrphanUserLoginIDs(ctx context.Context, db *gorm.DB, ids []string) ([]domain.OrphanUserLoginID, error) {
|
||||||
|
type orphanRow struct {
|
||||||
|
ID string
|
||||||
|
UserID string
|
||||||
|
UserEmail string
|
||||||
|
UserDeletedAt *time.Time
|
||||||
|
TenantID string
|
||||||
|
TenantSlug string
|
||||||
|
TenantDeletedAt *time.Time
|
||||||
|
FieldKey string
|
||||||
|
LoginID string
|
||||||
|
MissingUser bool
|
||||||
|
DeletedUser bool
|
||||||
|
MissingTenant bool
|
||||||
|
DeletedTenant bool
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
uli.id,
|
||||||
|
uli.user_id,
|
||||||
|
COALESCE(u.email, '') AS user_email,
|
||||||
|
u.deleted_at AS user_deleted_at,
|
||||||
|
uli.tenant_id,
|
||||||
|
COALESCE(t.slug, '') AS tenant_slug,
|
||||||
|
t.deleted_at AS tenant_deleted_at,
|
||||||
|
uli.field_key,
|
||||||
|
uli.login_id,
|
||||||
|
(u.id IS NULL) AS missing_user,
|
||||||
|
(u.id IS NOT NULL AND u.deleted_at IS NOT NULL) AS deleted_user,
|
||||||
|
(t.id IS NULL) AS missing_tenant,
|
||||||
|
(t.id IS NOT NULL AND t.deleted_at IS NOT NULL) AS deleted_tenant
|
||||||
|
FROM user_login_ids AS uli
|
||||||
|
LEFT JOIN users AS u ON u.id = uli.user_id
|
||||||
|
LEFT JOIN tenants AS t ON t.id = uli.tenant_id
|
||||||
|
WHERE (
|
||||||
|
u.id IS NULL
|
||||||
|
OR u.deleted_at IS NOT NULL
|
||||||
|
OR t.id IS NULL
|
||||||
|
OR t.deleted_at IS NOT NULL
|
||||||
|
)
|
||||||
|
`
|
||||||
|
args := []any{}
|
||||||
|
if len(ids) > 0 {
|
||||||
|
query += " AND uli.id IN ?\n"
|
||||||
|
args = append(args, ids)
|
||||||
|
}
|
||||||
|
query += "ORDER BY uli.login_id, uli.id"
|
||||||
|
|
||||||
|
var rows []orphanRow
|
||||||
|
if err := db.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]domain.OrphanUserLoginID, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
reasons := make([]string, 0, 4)
|
||||||
|
if row.MissingUser {
|
||||||
|
reasons = append(reasons, "missing_user")
|
||||||
|
}
|
||||||
|
if row.DeletedUser {
|
||||||
|
reasons = append(reasons, "deleted_user")
|
||||||
|
}
|
||||||
|
if row.MissingTenant {
|
||||||
|
reasons = append(reasons, "missing_tenant")
|
||||||
|
}
|
||||||
|
if row.DeletedTenant {
|
||||||
|
reasons = append(reasons, "deleted_tenant")
|
||||||
|
}
|
||||||
|
items = append(items, domain.OrphanUserLoginID{
|
||||||
|
ID: row.ID,
|
||||||
|
UserID: row.UserID,
|
||||||
|
UserEmail: row.UserEmail,
|
||||||
|
UserDeletedAt: row.UserDeletedAt,
|
||||||
|
TenantID: row.TenantID,
|
||||||
|
TenantSlug: row.TenantSlug,
|
||||||
|
TenantDeletedAt: row.TenantDeletedAt,
|
||||||
|
FieldKey: row.FieldKey,
|
||||||
|
LoginID: row.LoginID,
|
||||||
|
Reasons: reasons,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteOrphanUserLoginIDs(ctx context.Context, db *gorm.DB, ids []string) (domain.DeleteOrphanUserLoginIDsResult, error) {
|
||||||
|
ids = normalizeIDList(ids)
|
||||||
|
result := domain.DeleteOrphanUserLoginIDsResult{
|
||||||
|
Deleted: []domain.OrphanUserLoginID{},
|
||||||
|
SkippedIDs: []string{},
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
items, err := ListOrphanUserLoginIDs(ctx, tx, ids)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
deletableIDs := make([]string, 0, len(items))
|
||||||
|
deletableSet := make(map[string]bool, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
deletableIDs = append(deletableIDs, item.ID)
|
||||||
|
deletableSet[item.ID] = true
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
if !deletableSet[id] {
|
||||||
|
result.SkippedIDs = append(result.SkippedIDs, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(deletableIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteResult := tx.Exec("DELETE FROM user_login_ids WHERE id IN ?", deletableIDs)
|
||||||
|
if deleteResult.Error != nil {
|
||||||
|
return deleteResult.Error
|
||||||
|
}
|
||||||
|
result.Deleted = items
|
||||||
|
result.DeletedCount = deleteResult.RowsAffected
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeIDList(ids []string) []string {
|
||||||
|
normalized := make([]string, 0, len(ids))
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, id := range ids {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" || seen[id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
normalized = append(normalized, id)
|
||||||
|
}
|
||||||
|
slices.Sort(normalized)
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|||||||
@@ -88,6 +88,110 @@ func TestCheckDataIntegrityDetectsTenantAndUserProblems(t *testing.T) {
|
|||||||
requireIntegrityCheck(t, report, "user_integrity", "orphan_user_login_id_users", domain.DataIntegrityStatusFail, 1)
|
requireIntegrityCheck(t, report, "user_integrity", "orphan_user_login_id_users", domain.DataIntegrityStatusFail, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListAndDeleteOrphanUserLoginIDsOnlyDeletesRevalidatedTargets(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
suffix := uuid.NewString()
|
||||||
|
|
||||||
|
validTenant := domain.Tenant{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
Name: "Valid Tenant " + suffix,
|
||||||
|
Slug: "valid-tenant-" + suffix,
|
||||||
|
Type: domain.TenantTypeCompany,
|
||||||
|
Status: domain.TenantStatusActive,
|
||||||
|
}
|
||||||
|
deletedTenant := domain.Tenant{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
Name: "Deleted Tenant " + suffix,
|
||||||
|
Slug: "deleted-tenant-" + suffix,
|
||||||
|
Type: domain.TenantTypeCompany,
|
||||||
|
Status: domain.TenantStatusActive,
|
||||||
|
}
|
||||||
|
require.NoError(t, testDB.Create(&validTenant).Error)
|
||||||
|
require.NoError(t, testDB.Create(&deletedTenant).Error)
|
||||||
|
|
||||||
|
validUser := domain.User{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
Email: "valid-login-" + suffix + "@example.com",
|
||||||
|
Name: "Valid Login User",
|
||||||
|
Role: domain.RoleUser,
|
||||||
|
TenantID: &validTenant.ID,
|
||||||
|
Status: domain.UserStatusActive,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
UpdatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
deletedUser := domain.User{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
Email: "deleted-login-" + suffix + "@example.com",
|
||||||
|
Name: "Deleted Login User",
|
||||||
|
Role: domain.RoleUser,
|
||||||
|
TenantID: &validTenant.ID,
|
||||||
|
Status: domain.UserStatusActive,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
UpdatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
require.NoError(t, testDB.Create(&validUser).Error)
|
||||||
|
require.NoError(t, testDB.Create(&deletedUser).Error)
|
||||||
|
|
||||||
|
validLogin := domain.UserLoginID{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
UserID: validUser.ID,
|
||||||
|
TenantID: validTenant.ID,
|
||||||
|
FieldKey: "emp_id",
|
||||||
|
LoginID: "VALID-" + suffix,
|
||||||
|
}
|
||||||
|
deletedTenantLogin := domain.UserLoginID{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
UserID: validUser.ID,
|
||||||
|
TenantID: deletedTenant.ID,
|
||||||
|
FieldKey: "emp_id",
|
||||||
|
LoginID: "DELETED-TENANT-" + suffix,
|
||||||
|
}
|
||||||
|
deletedUserLogin := domain.UserLoginID{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
UserID: deletedUser.ID,
|
||||||
|
TenantID: validTenant.ID,
|
||||||
|
FieldKey: "emp_id",
|
||||||
|
LoginID: "DELETED-USER-" + suffix,
|
||||||
|
}
|
||||||
|
require.NoError(t, testDB.Create(&validLogin).Error)
|
||||||
|
require.NoError(t, testDB.Create(&deletedTenantLogin).Error)
|
||||||
|
require.NoError(t, testDB.Create(&deletedUserLogin).Error)
|
||||||
|
require.NoError(t, testDB.Delete(&domain.Tenant{}, "id = ?", deletedTenant.ID).Error)
|
||||||
|
require.NoError(t, testDB.Delete(&domain.User{}, "id = ?", deletedUser.ID).Error)
|
||||||
|
|
||||||
|
items, err := ListOrphanUserLoginIDs(ctx, testDB, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
orphanReasons := map[string][]string{}
|
||||||
|
for _, item := range items {
|
||||||
|
orphanReasons[item.ID] = item.Reasons
|
||||||
|
}
|
||||||
|
require.Equal(t, []string{"deleted_tenant"}, orphanReasons[deletedTenantLogin.ID])
|
||||||
|
require.Equal(t, []string{"deleted_user"}, orphanReasons[deletedUserLogin.ID])
|
||||||
|
require.NotContains(t, orphanReasons, validLogin.ID)
|
||||||
|
|
||||||
|
result, err := DeleteOrphanUserLoginIDs(ctx, testDB, []string{
|
||||||
|
deletedTenantLogin.ID,
|
||||||
|
validLogin.ID,
|
||||||
|
"00000000-0000-0000-0000-000000000000",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, int64(1), result.DeletedCount)
|
||||||
|
require.Len(t, result.Deleted, 1)
|
||||||
|
require.Equal(t, deletedTenantLogin.ID, result.Deleted[0].ID)
|
||||||
|
require.ElementsMatch(t, []string{
|
||||||
|
validLogin.ID,
|
||||||
|
"00000000-0000-0000-0000-000000000000",
|
||||||
|
}, result.SkippedIDs)
|
||||||
|
|
||||||
|
var deletedTenantLoginCount int64
|
||||||
|
require.NoError(t, testDB.Model(&domain.UserLoginID{}).Where("id = ?", deletedTenantLogin.ID).Count(&deletedTenantLoginCount).Error)
|
||||||
|
require.Equal(t, int64(0), deletedTenantLoginCount)
|
||||||
|
|
||||||
|
var validLoginCount int64
|
||||||
|
require.NoError(t, testDB.Model(&domain.UserLoginID{}).Where("id = ?", validLogin.ID).Count(&validLoginCount).Error)
|
||||||
|
require.Equal(t, int64(1), validLoginCount)
|
||||||
|
}
|
||||||
|
|
||||||
func requireIntegrityCheck(t *testing.T, report domain.DataIntegrityReport, sectionKey, checkKey string, status domain.DataIntegrityStatus, count int64) {
|
func requireIntegrityCheck(t *testing.T, report domain.DataIntegrityReport, sectionKey, checkKey string, status domain.DataIntegrityStatus, count int64) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
for _, section := range report.Sections {
|
for _, section := range report.Sections {
|
||||||
|
|||||||
@@ -14,6 +14,22 @@ Baron SSO의 신원/권한 SoT는 Ory Stack(Kratos, Keto, Hydra)입니다. 이
|
|||||||
|
|
||||||
응답은 전체 상태, 검사 시각, 요약, 섹션별 검사 결과를 포함합니다.
|
응답은 전체 상태, 검사 시각, 요약, 섹션별 검사 결과를 포함합니다.
|
||||||
|
|
||||||
|
### 유령 로그인 ID 대상 조회
|
||||||
|
|
||||||
|
- Method: `GET`
|
||||||
|
- Path: `/api/v1/admin/integrity/orphan-user-login-ids`
|
||||||
|
- 권한: `super_admin`
|
||||||
|
|
||||||
|
`user_login_ids`가 존재하지 않거나 soft-deleted 된 `users`, `tenants`를 참조하는 행을 반환합니다. 각 행은 `loginId`, `fieldKey`, 사용자/테넌트 식별 정보, `missing_user`, `deleted_user`, `missing_tenant`, `deleted_tenant` 중 하나 이상의 사유를 포함합니다.
|
||||||
|
|
||||||
|
### 유령 로그인 ID 삭제
|
||||||
|
|
||||||
|
- Method: `DELETE`
|
||||||
|
- Path: `/api/v1/admin/integrity/orphan-user-login-ids`
|
||||||
|
- 권한: `super_admin`
|
||||||
|
|
||||||
|
요청 본문은 `{ "ids": ["..."] }` 형식입니다. 서버는 삭제 직전에 같은 트랜잭션 안에서 대상 행이 여전히 유령 로그인 ID인지 재검증하고, 재검증을 통과한 `user_login_ids` 행만 삭제합니다. 정상화되었거나 존재하지 않는 ID는 `skippedIds`로 반환합니다.
|
||||||
|
|
||||||
## 검사 항목
|
## 검사 항목
|
||||||
|
|
||||||
### 테넌트 정합성
|
### 테넌트 정합성
|
||||||
@@ -30,6 +46,7 @@ Baron SSO의 신원/권한 SoT는 Ory Stack(Kratos, Keto, Hydra)입니다. 이
|
|||||||
## adminfront 동작
|
## adminfront 동작
|
||||||
|
|
||||||
- `super_admin`은 사이드바의 `데이터 정합성` 메뉴에서 리포트를 볼 수 있습니다.
|
- `super_admin`은 사이드바의 `데이터 정합성` 메뉴에서 리포트를 볼 수 있습니다.
|
||||||
|
- `super_admin`은 같은 메뉴에서 유령 로그인 ID 대상을 확인하고, 체크박스로 선택한 뒤 확인 대화상자를 거쳐 삭제할 수 있습니다.
|
||||||
- `super_admin`은 adminfront 개요 화면 하단에서도 최종 검증 상태, 실패 건수, 검사 시각, 섹션별 상태 요약을 볼 수 있습니다.
|
- `super_admin`은 adminfront 개요 화면 하단에서도 최종 검증 상태, 실패 건수, 검사 시각, 섹션별 상태 요약을 볼 수 있습니다.
|
||||||
- `tenant_admin` 등 non-super role은 화면 접근 시 권한 없음 메시지만 봅니다.
|
- `tenant_admin` 등 non-super role은 화면 접근 시 권한 없음 메시지만 봅니다.
|
||||||
- 개요 화면의 전체 테넌트 수는 `fetchAllTenants()`로 실제 cursor pagination을 끝까지 수집한 리스트 수를 우선 사용합니다. 이로써 super가 보는 전체 테넌트 수와 리스트 기반 수치가 같은 소스에서 나오도록 맞춥니다.
|
- 개요 화면의 전체 테넌트 수는 `fetchAllTenants()`로 실제 cursor pagination을 끝까지 수집한 리스트 수를 우선 사용합니다. 이로써 super가 보는 전체 테넌트 수와 리스트 기반 수치가 같은 소스에서 나오도록 맞춥니다.
|
||||||
@@ -37,7 +54,14 @@ Baron SSO의 신원/권한 SoT는 Ory Stack(Kratos, Keto, Hydra)입니다. 이
|
|||||||
|
|
||||||
## 운영 주의
|
## 운영 주의
|
||||||
|
|
||||||
현재 기능은 read-only 검증입니다. 자동 정리 작업은 별도 이슈와 승인된 maintenance action으로 분리해야 합니다.
|
정합성 검증 리포트는 read-only입니다. 단, 유령 로그인 ID 삭제는 `super_admin`이 대상 행을 확인하고 선택한 경우에만 수행되는 명시적 maintenance action입니다.
|
||||||
|
|
||||||
|
삭제 작업의 기본 운영 순서는 다음과 같습니다.
|
||||||
|
|
||||||
|
1. `데이터 정합성` 메뉴에서 실패 항목과 유령 로그인 ID 대상 행을 확인합니다.
|
||||||
|
2. 사용자/테넌트가 실제로 복구 대상인지 먼저 판단합니다.
|
||||||
|
3. 복구 대상이 아니라 read model 잔여 데이터가 맞는 행만 선택합니다.
|
||||||
|
4. `선택 삭제` 실행 후 정합성 리포트가 다시 조회되어 실패 건수가 줄었는지 확인합니다.
|
||||||
|
|
||||||
이미 존재하는 orphan 사용자 소속 정리 경로는 다음과 같습니다.
|
이미 존재하는 orphan 사용자 소속 정리 경로는 다음과 같습니다.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user