forked from baron/baron-sso
네이버 계정 정합성 맞춤
This commit is contained in:
@@ -80,7 +80,6 @@ jobs:
|
||||
AUDIT_WORKER_COUNT=5
|
||||
AUDIT_QUEUE_SIZE=2000
|
||||
PROFILE_CACHE_TTL=${{ vars.PROFILE_CACHE_TTL }}
|
||||
ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=${{ vars.ORGFRONT_ORGCHART_CACHE_TTL_SECONDS }}
|
||||
NAVER_CLOUD_ACCESS_KEY=${{ vars.NAVER_CLOUD_ACCESS_KEY }}
|
||||
NAVER_CLOUD_SECRET_KEY=${{ secrets.NAVER_CLOUD_SECRET_KEY }}
|
||||
NAVER_CLOUD_SERVICE_ID=${{ vars.NAVER_CLOUD_SERVICE_ID }}
|
||||
@@ -143,10 +142,6 @@ jobs:
|
||||
LOKI_URL=${{ vars.LOKI_URL || 'http://loki:3100/loki/api/v1/push' }}
|
||||
EOF
|
||||
|
||||
if ! grep -Eq "^ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=.+" .env; then
|
||||
sed -i "s/^ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=.*/ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=3600/" .env
|
||||
fi
|
||||
|
||||
# 코드 업데이트 (Git)
|
||||
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p '${DEPLOY_PATH}' && cd '${DEPLOY_PATH}' && \
|
||||
if [ ! -d .git ]; then
|
||||
|
||||
@@ -90,7 +90,6 @@ jobs:
|
||||
AUDIT_WORKER_COUNT=5
|
||||
AUDIT_QUEUE_SIZE=2000
|
||||
PROFILE_CACHE_TTL=${{ vars.PROFILE_CACHE_TTL }}
|
||||
ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=${{ vars.ORGFRONT_ORGCHART_CACHE_TTL_SECONDS }}
|
||||
NAVER_CLOUD_ACCESS_KEY=${{ vars.NAVER_CLOUD_ACCESS_KEY }}
|
||||
NAVER_CLOUD_SECRET_KEY=${{ secrets.NAVER_CLOUD_SECRET_KEY }}
|
||||
NAVER_CLOUD_SERVICE_ID=${{ vars.NAVER_CLOUD_SERVICE_ID }}
|
||||
@@ -143,16 +142,11 @@ jobs:
|
||||
# OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
|
||||
EOF
|
||||
|
||||
if ! grep -Eq "^ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=.+" .env; then
|
||||
sed -i "s/^ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=.*/ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=3600/" .env
|
||||
fi
|
||||
|
||||
required_dotenv_keys="
|
||||
APP_ENV BACKEND_LOG_LEVEL CLIENT_LOG_DEBUG WORKS_ADMIN_API_BASE_URL WORKS_ADMIN_OAUTH_TOKEN_URL TZ IDP_PROVIDER
|
||||
DB_PORT CLICKHOUSE_PORT_HTTP CLICKHOUSE_PORT_NATIVE CLICKHOUSE_HOST CLICKHOUSE_USER CLICKHOUSE_PASSWORD
|
||||
BACKEND_PORT ADMINFRONT_PORT DEVFRONT_PORT ORGFRONT_PORT USERFRONT_PORT OATHKEEPER_API_URL
|
||||
DB_USER DB_PASSWORD DB_NAME COOKIE_SECRET JWT_SECRET REDIS_ADDR CORS_ALLOWED_ORIGINS PROFILE_CACHE_TTL
|
||||
ORGFRONT_ORGCHART_CACHE_TTL_SECONDS
|
||||
NAVER_CLOUD_ACCESS_KEY NAVER_CLOUD_SECRET_KEY NAVER_CLOUD_SERVICE_ID NAVER_SENDER_PHONE_NUMBER
|
||||
AWS_REGION AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SES_SENDER ADMIN_EMAIL ADMIN_PASSWORD
|
||||
USERFRONT_URL ORGFRONT_URL BACKEND_PUBLIC_URL BACKEND_URL OATHKEEPER_PUBLIC_URL
|
||||
|
||||
BIN
adminfront/Trace-20260615T113806.json.gz
Normal file
BIN
adminfront/Trace-20260615T113806.json.gz
Normal file
Binary file not shown.
@@ -9,8 +9,8 @@ import AuthGuard from "../features/auth/AuthGuard";
|
||||
import AuthPage from "../features/auth/AuthPage";
|
||||
import LoginPage from "../features/auth/LoginPage";
|
||||
import DataIntegrityPage from "../features/integrity/DataIntegrityPage";
|
||||
import OrySSOTPage from "../features/ory-ssot/OrySSOTPage";
|
||||
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
|
||||
import UserProjectionPage from "../features/projections/UserProjectionPage";
|
||||
import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab";
|
||||
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
|
||||
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
|
||||
@@ -67,7 +67,7 @@ export const adminRoutes: RouteObject[] = [
|
||||
},
|
||||
{ path: "api-keys", element: <ApiKeyListPage /> },
|
||||
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
|
||||
{ path: "system/ory-ssot", element: <UserProjectionPage /> },
|
||||
{ path: "system/ory-ssot", element: <OrySSOTPage /> },
|
||||
{ path: "system/data-integrity", element: <DataIntegrityPage /> },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -31,4 +31,27 @@ describe("LocaleRefreshBoundary", () => {
|
||||
|
||||
expect(screen.getByText("2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ignores storage events unrelated to locale changes", async () => {
|
||||
render(
|
||||
<LocaleRefreshBoundary>
|
||||
<RenderCounter />
|
||||
</LocaleRefreshBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new StorageEvent("storage", {
|
||||
key: "admin_session",
|
||||
newValue: "token",
|
||||
oldValue: null,
|
||||
storageArea: window.localStorage,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Fragment, type ReactNode, useEffect, useState } from "react";
|
||||
import { LOCALE_STORAGE_KEY } from "../../../../common/core/i18n";
|
||||
|
||||
type LocaleRefreshBoundaryProps = {
|
||||
children: ReactNode;
|
||||
@@ -12,12 +13,19 @@ function LocaleRefreshBoundary({ children }: LocaleRefreshBoundaryProps) {
|
||||
setLocaleVersion((current) => current + 1);
|
||||
};
|
||||
|
||||
const syncLocaleFromStorage = (event: StorageEvent) => {
|
||||
if (event.key !== LOCALE_STORAGE_KEY && event.key !== null) {
|
||||
return;
|
||||
}
|
||||
syncLocale();
|
||||
};
|
||||
|
||||
window.addEventListener("localechange", syncLocale);
|
||||
window.addEventListener("storage", syncLocale);
|
||||
window.addEventListener("storage", syncLocaleFromStorage);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("localechange", syncLocale);
|
||||
window.removeEventListener("storage", syncLocale);
|
||||
window.removeEventListener("storage", syncLocaleFromStorage);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ describe("ApiKeyListPage", () => {
|
||||
});
|
||||
|
||||
it("updates scopes without changing client_id", async () => {
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("client-id-stable")).toBeInTheDocument();
|
||||
@@ -88,7 +88,7 @@ describe("ApiKeyListPage", () => {
|
||||
scopes: expect.arrayContaining(["audit:read", "org-context:read"]),
|
||||
});
|
||||
});
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
it("rotates only the secret and shows the one-time secret", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
function listSourceFiles(directory: string): string[] {
|
||||
const entries = readdirSync(directory);
|
||||
const files: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const path = join(directory, entry);
|
||||
const stat = statSync(path);
|
||||
if (stat.isDirectory()) {
|
||||
files.push(...listSourceFiles(path));
|
||||
continue;
|
||||
}
|
||||
if (path.endsWith(".tsx")) {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
describe("admin page animation policy", () => {
|
||||
it("does not use long enter fade animations on stable page containers", () => {
|
||||
const sourceRoot = join(process.cwd(), "src");
|
||||
const offenders = listSourceFiles(sourceRoot).filter((file) =>
|
||||
readFileSync(file, "utf8").includes("animate-in fade-in duration-500"),
|
||||
);
|
||||
|
||||
expect(offenders.map((file) => file.replace(`${sourceRoot}/`, ""))).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,8 +6,6 @@ import {
|
||||
fetchDataIntegrityReport,
|
||||
fetchMe,
|
||||
fetchOrphanUserLoginIDs,
|
||||
fetchOrySSOTSystemStatus,
|
||||
flushIdentityCache,
|
||||
} from "../../lib/adminApi";
|
||||
import { expectNoAnonymousFormFields } from "../../test/formFieldDiagnostics";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
@@ -63,21 +61,6 @@ vi.mock("../../lib/adminApi", () => ({
|
||||
],
|
||||
total: 1,
|
||||
})),
|
||||
fetchOrySSOTSystemStatus: vi.fn(async () => ({
|
||||
identityCache: {
|
||||
status: "ready",
|
||||
redisReady: true,
|
||||
observedCount: 151,
|
||||
keyCount: 153,
|
||||
lastRefreshedAt: "2026-05-11T03:00:00Z",
|
||||
updatedAt: "2026-05-11T03:00:10Z",
|
||||
},
|
||||
})),
|
||||
flushIdentityCache: vi.fn(async () => ({
|
||||
status: "success",
|
||||
flushedKeys: 153,
|
||||
updatedAt: "2026-05-11T03:02:00Z",
|
||||
})),
|
||||
deleteOrphanUserLoginIDs: vi.fn(async () => ({
|
||||
deletedCount: 1,
|
||||
deleted: [
|
||||
@@ -121,12 +104,6 @@ describe("DataIntegrityPage", () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("데이터 정합성 검증")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("tab", { name: "정합성 검사" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("tab", { name: "Ory SSOT 시스템" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"정합성 상태를 확인하고 데이터 모델 전반의 검증 결과를 살펴봅니다.",
|
||||
@@ -138,28 +115,6 @@ describe("DataIntegrityPage", () => {
|
||||
expect(fetchDataIntegrityReport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders Ory SSOT cache management inside data integrity", async () => {
|
||||
renderPage();
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole("tab", { name: "Ory SSOT 시스템" }),
|
||||
);
|
||||
|
||||
expect(
|
||||
(await screen.findAllByText("Ory SSOT 시스템")).length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(await screen.findByText("Redis identity cache")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("준비됨").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("151")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Local users")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Redis cache flush/ }));
|
||||
await waitFor(() => {
|
||||
expect(flushIdentityCache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(fetchOrySSOTSystemStatus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows orphan login ID targets and deletes selected rows", async () => {
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
const { container } = renderPage();
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { getAdminDateLocale } from "../../lib/locale";
|
||||
import { UserProjectionContent } from "../projections/UserProjectionPage";
|
||||
|
||||
function statusLabel(status: DataIntegrityStatus) {
|
||||
switch (status) {
|
||||
@@ -188,14 +187,6 @@ function recheckStatusText(status: "idle" | "running" | "success" | "error") {
|
||||
}
|
||||
}
|
||||
|
||||
function pageTabClassName(active: boolean) {
|
||||
return `relative px-6 py-3 text-sm font-medium transition-colors ${
|
||||
active
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`;
|
||||
}
|
||||
|
||||
function OrphanLoginIDTable({
|
||||
items,
|
||||
selectedIds,
|
||||
@@ -294,9 +285,6 @@ function OrphanLoginIDTable({
|
||||
|
||||
function DataIntegrityContent() {
|
||||
const queryClient = useQueryClient();
|
||||
const [activeTab, setActiveTab] = useState<"integrity" | "projection">(
|
||||
"integrity",
|
||||
);
|
||||
const [selectedOrphanIds, setSelectedOrphanIds] = useState<string[]>([]);
|
||||
const [recheckStatus, setRecheckStatus] = useState<
|
||||
"idle" | "running" | "success" | "error"
|
||||
@@ -373,243 +361,210 @@ function DataIntegrityContent() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{activeTab === "integrity" ? (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleRecheck}
|
||||
disabled={isLoading || isFetching || isManualRechecking}
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleRecheck}
|
||||
disabled={isLoading || isFetching || isManualRechecking}
|
||||
>
|
||||
<Database size={16} />
|
||||
{isManualRechecking
|
||||
? t("ui.admin.integrity.recheck.running", "검사 중")
|
||||
: t("ui.admin.integrity.recheck.run", "다시 검사")}
|
||||
</Button>
|
||||
{recheckMessage ? (
|
||||
<output
|
||||
aria-live="polite"
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
<Database size={16} />
|
||||
{isManualRechecking
|
||||
? t("ui.admin.integrity.recheck.running", "검사 중")
|
||||
: t("ui.admin.integrity.recheck.run", "다시 검사")}
|
||||
</Button>
|
||||
{recheckMessage ? (
|
||||
<output
|
||||
aria-live="polite"
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{recheckMessage}
|
||||
</output>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{recheckMessage}
|
||||
</output>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className="flex border-b border-border"
|
||||
role="tablist"
|
||||
aria-label="데이터 정합성 탭"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === "integrity"}
|
||||
className={pageTabClassName(activeTab === "integrity")}
|
||||
onClick={() => setActiveTab("integrity")}
|
||||
>
|
||||
{t("ui.admin.integrity.tab_checks", "정합성 검사")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === "projection"}
|
||||
className={pageTabClassName(activeTab === "projection")}
|
||||
onClick={() => setActiveTab("projection")}
|
||||
>
|
||||
{t("ui.admin.integrity.tab_ory_ssot", "Ory SSOT 시스템")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "integrity" ? (
|
||||
<div className="space-y-4 pb-6 animate-in fade-in duration-500">
|
||||
{isError ? (
|
||||
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
{(error as Error)?.message ||
|
||||
t(
|
||||
"msg.admin.integrity.report.load_error",
|
||||
"정합성 리포트를 불러오지 못했습니다.",
|
||||
)}
|
||||
</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>
|
||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||
{t(
|
||||
"ui.admin.integrity.read_model.title",
|
||||
"Read model integrity",
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.integrity.read_model.description",
|
||||
"Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 확인합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{data ? (
|
||||
<Badge variant={statusBadgeVariant(data.status)}>
|
||||
{statusLabel(data.status)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-8 text-sm text-muted-foreground">
|
||||
{t("ui.admin.integrity.loading", "불러오는 중")}
|
||||
</div>
|
||||
) : (
|
||||
<dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t("ui.admin.integrity.summary.total_checks", "검사 항목")}
|
||||
</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">
|
||||
{t("ui.admin.integrity.summary.passed", "정상")}
|
||||
</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">
|
||||
{t("ui.admin.integrity.summary.failures", "실패 건수")}
|
||||
</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">
|
||||
{t("ui.admin.integrity.summary.checked_at", "검사 시각")}
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm">
|
||||
{formatDateTime(data?.checkedAt)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
<div className="space-y-4 pb-6">
|
||||
{isError ? (
|
||||
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
{(error as Error)?.message ||
|
||||
t(
|
||||
"msg.admin.integrity.report.load_error",
|
||||
"정합성 리포트를 불러오지 못했습니다.",
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<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">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||
{integritySectionLabel(section.key, section.label)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{integritySectionDescription(section.key)}
|
||||
</p>
|
||||
</div>
|
||||
<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">
|
||||
{integrityCheckLabel(check.key, check.label)}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{integrityCheckDescription(
|
||||
check.key,
|
||||
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>
|
||||
))}
|
||||
<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>
|
||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||
{t(
|
||||
"ui.admin.integrity.read_model.title",
|
||||
"Read model integrity",
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.integrity.read_model.description",
|
||||
"Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 확인합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{data ? (
|
||||
<Badge variant={statusBadgeVariant(data.status)}>
|
||||
{statusLabel(data.status)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</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-lg font-bold flex items-center gap-2">
|
||||
{t(
|
||||
"ui.admin.integrity.orphan_login_ids.title",
|
||||
"유령 로그인 ID 정리",
|
||||
)}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.integrity.orphan_login_ids.description",
|
||||
"삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={
|
||||
selectedOrphanIds.length === 0 || deleteMutation.isPending
|
||||
}
|
||||
>
|
||||
{t("ui.admin.integrity.orphan_login_ids.delete", "선택 삭제")}
|
||||
</Button>
|
||||
{isLoading ? (
|
||||
<div className="py-8 text-sm text-muted-foreground">
|
||||
{t("ui.admin.integrity.loading", "불러오는 중")}
|
||||
</div>
|
||||
{orphanLoginIDsQuery.isError ? (
|
||||
<div className="mb-3 rounded border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{t(
|
||||
"msg.admin.integrity.orphan_login_ids.load_error",
|
||||
"유령 로그인 ID 대상을 불러오지 못했습니다.",
|
||||
)}
|
||||
) : (
|
||||
<dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t("ui.admin.integrity.summary.total_checks", "검사 항목")}
|
||||
</dt>
|
||||
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
||||
{data?.summary.totalChecks ?? 0}
|
||||
</dd>
|
||||
</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">
|
||||
{t(
|
||||
"msg.admin.integrity.orphan_login_ids.delete_success",
|
||||
"{{count}}개의 유령 로그인 ID를 삭제했습니다.",
|
||||
{ count: deleteMutation.data.deletedCount },
|
||||
)}
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t("ui.admin.integrity.summary.passed", "정상")}
|
||||
</dt>
|
||||
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
||||
{data?.summary.passed ?? 0}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
<OrphanLoginIDTable
|
||||
items={orphanItems}
|
||||
selectedIds={selectedOrphanIds}
|
||||
onToggle={toggleOrphanID}
|
||||
/>
|
||||
</section>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t("ui.admin.integrity.summary.failures", "실패 건수")}
|
||||
</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">
|
||||
{t("ui.admin.integrity.summary.checked_at", "검사 시각")}
|
||||
</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">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||
{integritySectionLabel(section.key, section.label)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{integritySectionDescription(section.key)}
|
||||
</p>
|
||||
</div>
|
||||
<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">
|
||||
{integrityCheckLabel(check.key, check.label)}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{integrityCheckDescription(
|
||||
check.key,
|
||||
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>
|
||||
) : (
|
||||
<div className="animate-in fade-in duration-500">
|
||||
<UserProjectionContent embedded />
|
||||
</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-lg font-bold flex items-center gap-2">
|
||||
{t(
|
||||
"ui.admin.integrity.orphan_login_ids.title",
|
||||
"유령 로그인 ID 정리",
|
||||
)}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.integrity.orphan_login_ids.description",
|
||||
"삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={
|
||||
selectedOrphanIds.length === 0 || deleteMutation.isPending
|
||||
}
|
||||
>
|
||||
{t("ui.admin.integrity.orphan_login_ids.delete", "선택 삭제")}
|
||||
</Button>
|
||||
</div>
|
||||
{orphanLoginIDsQuery.isError ? (
|
||||
<div className="mb-3 rounded border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{t(
|
||||
"msg.admin.integrity.orphan_login_ids.load_error",
|
||||
"유령 로그인 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">
|
||||
{t(
|
||||
"msg.admin.integrity.orphan_login_ids.delete_success",
|
||||
"{{count}}개의 유령 로그인 ID를 삭제했습니다.",
|
||||
{ count: deleteMutation.data.deletedCount },
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<OrphanLoginIDTable
|
||||
items={orphanItems}
|
||||
selectedIds={selectedOrphanIds}
|
||||
onToggle={toggleOrphanID}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
fetchMe,
|
||||
fetchOrySSOTSystemStatus,
|
||||
flushIdentityCache,
|
||||
} from "../../lib/adminApi";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import UserProjectionPage from "./UserProjectionPage";
|
||||
import OrySSOTPage from "./OrySSOTPage";
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
@@ -19,9 +20,9 @@ vi.mock("../../lib/adminApi", () => ({
|
||||
status: "ready",
|
||||
redisReady: true,
|
||||
observedCount: 151,
|
||||
keyCount: 153,
|
||||
lastRefreshedAt: "2026-05-11T03:00:00Z",
|
||||
updatedAt: "2026-05-11T03:00:10Z",
|
||||
keyCount: 153,
|
||||
},
|
||||
})),
|
||||
flushIdentityCache: vi.fn(async () => ({
|
||||
@@ -41,12 +42,12 @@ function renderPage() {
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<UserProjectionPage />
|
||||
<OrySSOTPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("UserProjectionPage", () => {
|
||||
describe("OrySSOTPage", () => {
|
||||
beforeEach(() => {
|
||||
currentRole = "super_admin";
|
||||
vi.clearAllMocks();
|
||||
@@ -54,37 +55,22 @@ describe("UserProjectionPage", () => {
|
||||
window.localStorage.setItem("locale", "ko");
|
||||
});
|
||||
|
||||
it("renders Ory SSOT and Redis identity cache status for super_admin", async () => {
|
||||
it("renders identity cache status and flushes cache", async () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("Ory SSOT 시스템")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Kratos 원장과 Redis identity cache 상태를 분리해서 확인합니다.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
(await screen.findAllByText("Ory SSOT 시스템")).length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(await screen.findByText("Redis identity cache")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("준비됨").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("관측 identity")).toBeInTheDocument();
|
||||
expect(screen.getByText("151")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Local users")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Backend 사용자 read model")).not.toBeInTheDocument();
|
||||
expect(fetchOrySSOTSystemStatus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("flushes only the Redis identity cache for super_admin", async () => {
|
||||
renderPage();
|
||||
|
||||
await screen.findByText("Ory SSOT 시스템");
|
||||
expect(screen.queryByRole("button", { name: /재동기화/ })).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /초기화 후 재구축/ }),
|
||||
).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: /Redis cache flush/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(flushIdentityCache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(fetchOrySSOTSystemStatus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks non-super admins", async () => {
|
||||
@@ -93,21 +79,7 @@ describe("UserProjectionPage", () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("접근 권한이 없습니다")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Ory SSOT 시스템")).not.toBeInTheDocument();
|
||||
expect(fetchMe).toHaveBeenCalled();
|
||||
expect(fetchOrySSOTSystemStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders localized labels in English", async () => {
|
||||
window.localStorage.setItem("locale", "en");
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("Ory SSOT System")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Review Kratos source-of-truth and Redis identity cache status separately.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Redis cache flush")).toBeInTheDocument();
|
||||
expect((await screen.findAllByText("ready")).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -42,11 +42,7 @@ function StatusBadge({ ready, status }: { ready: boolean; status: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function UserProjectionContent({
|
||||
embedded = false,
|
||||
}: {
|
||||
embedded?: boolean;
|
||||
}) {
|
||||
function OrySSOTContent() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ["ory-ssot-system-status"],
|
||||
@@ -74,47 +70,39 @@ export function UserProjectionContent({
|
||||
|
||||
const identityCache = data?.identityCache;
|
||||
|
||||
const header = (
|
||||
<header
|
||||
className={
|
||||
embedded
|
||||
? "flex flex-shrink-0 flex-wrap items-start justify-between gap-4"
|
||||
: "flex flex-shrink-0 flex-wrap items-start justify-between gap-4 sticky top-[-2.5rem] z-20 -mt-4 bg-background/95 pb-2 pt-4 backdrop-blur"
|
||||
}
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
|
||||
<Database size={20} />
|
||||
return (
|
||||
<main className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<header className="flex flex-shrink-0 flex-wrap items-start justify-between gap-4 sticky top-[-2.5rem] z-20 -mt-4 bg-background/95 pb-2 pt-4 backdrop-blur">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
|
||||
<Database size={20} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-3xl font-semibold">
|
||||
{t("ui.admin.ory_ssot.title", "Ory SSOT System")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.ory_ssot.subtitle",
|
||||
"Review Kratos source-of-truth and Redis identity cache status separately.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-3xl font-semibold">
|
||||
{t("ui.admin.ory_ssot.title", "Ory SSOT System")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.ory_ssot.subtitle",
|
||||
"Review Kratos source-of-truth and Redis identity cache status separately.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleFlush}
|
||||
disabled={flushMutation.isPending}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{t(
|
||||
"ui.admin.ory_ssot.actions.flush_identity_cache",
|
||||
"Redis cache flush",
|
||||
)}
|
||||
</Button>
|
||||
</header>
|
||||
);
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleFlush}
|
||||
disabled={flushMutation.isPending}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{t(
|
||||
"ui.admin.ory_ssot.actions.flush_identity_cache",
|
||||
"Redis cache flush",
|
||||
)}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
const body = (
|
||||
<>
|
||||
{isError ? (
|
||||
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
{(error as Error)?.message ||
|
||||
@@ -220,27 +208,11 @@ export function UserProjectionContent({
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
||||
if (embedded) {
|
||||
return (
|
||||
<div className="space-y-4 pb-6">
|
||||
{header}
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
{header}
|
||||
{body}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UserProjectionPage() {
|
||||
export default function OrySSOTPage() {
|
||||
return (
|
||||
<RoleGuard
|
||||
roles={["super_admin"]}
|
||||
@@ -260,7 +232,7 @@ export default function UserProjectionPage() {
|
||||
</main>
|
||||
}
|
||||
>
|
||||
<UserProjectionContent />
|
||||
<OrySSOTContent />
|
||||
</RoleGuard>
|
||||
);
|
||||
}
|
||||
@@ -506,7 +506,7 @@ function GlobalOverviewPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-in fade-in duration-500">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
|
||||
|
||||
@@ -46,8 +46,10 @@ describe("ParentTenantSelector picker", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /테넌트 선택/ }));
|
||||
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
const pickerSrc = screen.getByTitle("테넌트 선택").getAttribute("src");
|
||||
expect(pickerSrc).toContain("/login");
|
||||
const pickerSrc = screen
|
||||
.getByTestId("parent-tenant-picker-frame")
|
||||
.getAttribute("src");
|
||||
expect(pickerSrc).toContain("http://localhost:5175/login");
|
||||
expect(decodeURIComponent(pickerSrc ?? "")).toContain("/embed/picker");
|
||||
|
||||
fireEvent(
|
||||
@@ -71,6 +73,30 @@ describe("ParentTenantSelector picker", () => {
|
||||
await waitFor(() => expect(onChange).toHaveBeenCalledWith("company-1"));
|
||||
});
|
||||
|
||||
it("scopes the org-chart picker to the requested tenant root", () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(
|
||||
<ParentTenantSelector
|
||||
id="parentId"
|
||||
label="상위 테넌트"
|
||||
value=""
|
||||
onChange={onChange}
|
||||
tenants={tenants}
|
||||
noneLabel="없음"
|
||||
orgChartTenantId="group-1"
|
||||
orgChartPickerLabel="한맥가족에서 선택"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "한맥가족에서 선택" }));
|
||||
|
||||
const pickerSrc = screen
|
||||
.getByTestId("parent-tenant-picker-frame")
|
||||
.getAttribute("src");
|
||||
expect(decodeURIComponent(pickerSrc ?? "")).toContain("tenantId=group-1");
|
||||
});
|
||||
|
||||
it("keeps the current tenant out of picker message selections", async () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ type ParentTenantSelectorProps = {
|
||||
labelAction?: ReactNode;
|
||||
contextLabel?: string;
|
||||
orgChartPickerLabel?: string;
|
||||
orgChartTenantId?: string;
|
||||
localPickerLabel?: string;
|
||||
localTenantFilter?: (tenant: TenantSummary) => boolean;
|
||||
compact?: boolean;
|
||||
@@ -49,6 +50,7 @@ export function ParentTenantSelector({
|
||||
labelAction,
|
||||
contextLabel,
|
||||
orgChartPickerLabel,
|
||||
orgChartTenantId,
|
||||
localPickerLabel,
|
||||
localTenantFilter,
|
||||
compact = false,
|
||||
@@ -66,6 +68,9 @@ export function ParentTenantSelector({
|
||||
);
|
||||
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
|
||||
import.meta.env.ORGFRONT_URL,
|
||||
{
|
||||
tenantId: orgChartTenantId,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -135,6 +140,7 @@ export function ParentTenantSelector({
|
||||
title={t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
|
||||
src={pickerUrl}
|
||||
className="h-[600px] w-full rounded-md border"
|
||||
data-testid="parent-tenant-picker-frame"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -61,6 +61,13 @@ function TenantCreatePage() {
|
||||
});
|
||||
const tenants = parentQuery.data?.items ?? [];
|
||||
const selectedParentTenant = tenants.find((tenant) => tenant.id === parentId);
|
||||
const hanmacFamilyTenantId = useMemo(
|
||||
() =>
|
||||
tenants.find(
|
||||
(tenant) => tenant.slug.trim().toLowerCase() === "hanmac-family",
|
||||
)?.id ?? "",
|
||||
[tenants],
|
||||
);
|
||||
const canConfigureHanmacOrg = useMemo(() => {
|
||||
if (!selectedParentTenant) return false;
|
||||
if (selectedParentTenant.slug.toLowerCase() === "hanmac-family") {
|
||||
@@ -206,6 +213,7 @@ function TenantCreatePage() {
|
||||
"ui.admin.tenants.create.form.pick_hanmac_parent",
|
||||
"한맥가족에서 선택",
|
||||
)}
|
||||
orgChartTenantId={hanmacFamilyTenantId}
|
||||
localPickerLabel={t(
|
||||
"ui.admin.tenants.create.form.pick_other_parent",
|
||||
"다른 테넌트 선택",
|
||||
|
||||
@@ -125,7 +125,7 @@ function TenantDetailPage() {
|
||||
</div>
|
||||
|
||||
{/* Outlet for nested routes */}
|
||||
<div className="animate-in fade-in duration-500">
|
||||
<div>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildWorksmobilePasswordManageUrl,
|
||||
@@ -28,6 +30,18 @@ import {
|
||||
} from "./worksmobileComparison";
|
||||
|
||||
describe("TenantWorksmobilePage comparison helpers", () => {
|
||||
it("does not apply page-level enter animations to Worksmobile tab panels", () => {
|
||||
const source = readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
"src/features/tenants/routes/TenantWorksmobilePage.tsx",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(source).not.toContain("space-y-4 animate-in fade-in duration-500");
|
||||
});
|
||||
|
||||
it("summarizes comparison rows by status", () => {
|
||||
const summary = summarizeWorksmobileComparison([
|
||||
{ resourceType: "USER", status: "matched" },
|
||||
@@ -594,6 +608,41 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("formats backend update reasons when value diff details are not directly visible", () => {
|
||||
expect(
|
||||
formatWorksmobileUpdateDetails({
|
||||
resourceType: "USER",
|
||||
status: "needs_update",
|
||||
baronId: "user-1",
|
||||
baronName: "신현우",
|
||||
worksmobileName: "신현우",
|
||||
baronEmail: "hwshin2@hanmaceng.co.kr",
|
||||
worksmobileEmail: "hwshin2@hanmaceng.co.kr",
|
||||
externalKey: "user-1",
|
||||
updateReasons: ["organization"],
|
||||
}),
|
||||
).toEqual(["조직: Baron 소속 정보를 WORKS에 반영해야 합니다."]);
|
||||
});
|
||||
|
||||
it("does not format phone update details for spaced Korean country code formatting only", () => {
|
||||
expect(
|
||||
formatWorksmobileUpdateDetails({
|
||||
resourceType: "USER",
|
||||
status: "needs_update",
|
||||
baronId: "user-1",
|
||||
baronName: "강명진",
|
||||
worksmobileName: "강명진",
|
||||
baronEmail: "mjkang4@hanmaceng.co.kr",
|
||||
worksmobileEmail: "mjkang4@hanmaceng.co.kr",
|
||||
externalKey: "user-1",
|
||||
baronPhone: "+821041585840",
|
||||
worksmobilePhone: "+82 1041585840",
|
||||
baronEmployeeNumber: "mjkang4",
|
||||
worksmobileEmployeeNumber: "M17205",
|
||||
}),
|
||||
).toEqual(["사번: M17205 -> mjkang4"]);
|
||||
});
|
||||
|
||||
it("formats WORKS account name with level on one line", () => {
|
||||
expect(
|
||||
formatWorksmobilePersonName({
|
||||
|
||||
@@ -520,7 +520,7 @@ export function TenantWorksmobilePage() {
|
||||
</div>
|
||||
|
||||
{activeTab === "history" ? (
|
||||
<div className="space-y-4 animate-in fade-in duration-500">
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-3">
|
||||
<div>
|
||||
@@ -627,7 +627,7 @@ export function TenantWorksmobilePage() {
|
||||
) : null}
|
||||
|
||||
{activeTab === "users" ? (
|
||||
<div className="space-y-4 animate-in fade-in duration-500">
|
||||
<div className="space-y-4">
|
||||
<ComparisonSummary
|
||||
title={t(
|
||||
"ui.admin.tenants.worksmobile.compare",
|
||||
@@ -715,7 +715,7 @@ export function TenantWorksmobilePage() {
|
||||
) : null}
|
||||
|
||||
{activeTab === "groups" ? (
|
||||
<div className="space-y-4 animate-in fade-in duration-500">
|
||||
<div className="space-y-4">
|
||||
<ComparisonSummary
|
||||
title={t(
|
||||
"ui.admin.tenants.worksmobile.compare_groups",
|
||||
|
||||
@@ -374,28 +374,39 @@ export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
|
||||
}
|
||||
|
||||
const details: string[] = [];
|
||||
const renderedReasons = new Set<string>();
|
||||
const addDetail = (reason: string, detail: string) => {
|
||||
details.push(detail);
|
||||
renderedReasons.add(reason);
|
||||
};
|
||||
const baronName = row.baronName?.trim();
|
||||
const worksmobileName = row.worksmobileName?.trim();
|
||||
if (baronName && worksmobileName && baronName !== worksmobileName) {
|
||||
details.push(`이름: ${worksmobileName} -> ${baronName}`);
|
||||
addDetail("name", `이름: ${worksmobileName} -> ${baronName}`);
|
||||
}
|
||||
if (row.resourceType === "USER") {
|
||||
const expectedExternalKey = row.baronId?.trim() ?? "";
|
||||
const actualExternalKey = row.externalKey?.trim() ?? "";
|
||||
if (expectedExternalKey && expectedExternalKey !== actualExternalKey) {
|
||||
details.push(
|
||||
addDetail(
|
||||
"external_key",
|
||||
`external_key: ${actualExternalKey || "없음"} -> ${expectedExternalKey}`,
|
||||
);
|
||||
}
|
||||
const expectedEmail = row.baronEmail?.trim().toLowerCase() ?? "";
|
||||
const actualEmail = row.worksmobileEmail?.trim().toLowerCase() ?? "";
|
||||
if (expectedEmail && actualEmail && expectedEmail !== actualEmail) {
|
||||
details.push(`이메일: ${actualEmail} -> ${expectedEmail}`);
|
||||
addDetail("email", `이메일: ${actualEmail} -> ${expectedEmail}`);
|
||||
}
|
||||
const expectedPhone = row.baronPhone?.trim() ?? "";
|
||||
const actualPhone = row.worksmobilePhone?.trim() ?? "";
|
||||
if (expectedPhone && actualPhone && expectedPhone !== actualPhone) {
|
||||
details.push(`전화번호: ${actualPhone} -> ${expectedPhone}`);
|
||||
if (
|
||||
expectedPhone &&
|
||||
actualPhone &&
|
||||
normalizeWorksmobilePhoneForCompare(expectedPhone) !==
|
||||
normalizeWorksmobilePhoneForCompare(actualPhone)
|
||||
) {
|
||||
addDetail("phone", `전화번호: ${actualPhone} -> ${expectedPhone}`);
|
||||
}
|
||||
const expectedEmployeeNumber = row.baronEmployeeNumber?.trim() ?? "";
|
||||
const actualEmployeeNumber = row.worksmobileEmployeeNumber?.trim() ?? "";
|
||||
@@ -404,10 +415,12 @@ export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
|
||||
actualEmployeeNumber &&
|
||||
expectedEmployeeNumber !== actualEmployeeNumber
|
||||
) {
|
||||
details.push(
|
||||
addDetail(
|
||||
"employee_number",
|
||||
`사번: ${actualEmployeeNumber} -> ${expectedEmployeeNumber}`,
|
||||
);
|
||||
}
|
||||
appendWorksmobileUpdateReasonFallbacks(details, row, renderedReasons);
|
||||
return details;
|
||||
}
|
||||
|
||||
@@ -427,14 +440,86 @@ export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
|
||||
const actualParentKey =
|
||||
row.worksmobileParentId ?? row.worksmobileParentExternalKey ?? "";
|
||||
if (expectedParentKey !== actualParentKey) {
|
||||
details.push(
|
||||
addDetail(
|
||||
"organization",
|
||||
`상위: ${actualParent || "없음"} -> ${expectedParent || "없음"}`,
|
||||
);
|
||||
}
|
||||
|
||||
appendWorksmobileUpdateReasonFallbacks(details, row, renderedReasons);
|
||||
return details;
|
||||
}
|
||||
|
||||
function appendWorksmobileUpdateReasonFallbacks(
|
||||
details: string[],
|
||||
row: WorksmobileComparisonItem,
|
||||
renderedReasons: Set<string>,
|
||||
) {
|
||||
for (const reason of row.updateReasons ?? []) {
|
||||
const normalizedReason = reason.trim();
|
||||
if (!normalizedReason || renderedReasons.has(normalizedReason)) {
|
||||
continue;
|
||||
}
|
||||
const detail = formatWorksmobileUpdateReasonFallback(normalizedReason, row);
|
||||
if (!detail) {
|
||||
continue;
|
||||
}
|
||||
details.push(detail);
|
||||
renderedReasons.add(normalizedReason);
|
||||
}
|
||||
}
|
||||
|
||||
function formatWorksmobileUpdateReasonFallback(
|
||||
reason: string,
|
||||
row: WorksmobileComparisonItem,
|
||||
) {
|
||||
switch (reason) {
|
||||
case "name":
|
||||
return "이름: Baron 사용자명을 WORKS에 반영해야 합니다.";
|
||||
case "external_key":
|
||||
return "external_key: Baron 사용자 ID를 WORKS 외부 키로 반영해야 합니다.";
|
||||
case "email":
|
||||
return "이메일: Baron 이메일을 WORKS에 반영해야 합니다.";
|
||||
case "phone":
|
||||
return "전화번호: Baron 전화번호를 WORKS에 반영해야 합니다.";
|
||||
case "employee_number":
|
||||
return "사번: Baron 사번을 WORKS에 반영해야 합니다.";
|
||||
case "organization":
|
||||
return row.resourceType === "GROUP"
|
||||
? "조직: Baron 조직 정보를 WORKS에 반영해야 합니다."
|
||||
: "조직: Baron 소속 정보를 WORKS에 반영해야 합니다.";
|
||||
case "manager":
|
||||
return "조직장: Baron 조직장 설정을 WORKS에 반영해야 합니다.";
|
||||
default:
|
||||
return `업데이트 사유: ${reason}`;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWorksmobilePhoneForCompare(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
const digits = trimmed.replace(/\D/g, "");
|
||||
if (!digits) {
|
||||
return "";
|
||||
}
|
||||
if (digits.startsWith("010")) {
|
||||
return `+82${digits.slice(1)}`;
|
||||
}
|
||||
if (digits.startsWith("82")) {
|
||||
let rest = digits.slice(2);
|
||||
while (rest.startsWith("82")) {
|
||||
rest = rest.slice(2);
|
||||
}
|
||||
if (rest.startsWith("0")) {
|
||||
rest = rest.slice(1);
|
||||
}
|
||||
return `+82${rest}`;
|
||||
}
|
||||
return `+${digits}`;
|
||||
}
|
||||
|
||||
export function buildWorksmobilePasswordManageUrl({
|
||||
tenantId,
|
||||
domainId,
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
type OrgChartTenantSelection,
|
||||
parseOrgChartTenantSelection,
|
||||
} from "./orgChartPicker";
|
||||
import { formatUserPolicyMessage } from "./userPolicyMessages";
|
||||
import type { UserSchemaField } from "./userSchemaFields";
|
||||
import { resolvePersonalTenant } from "./utils/personalTenant";
|
||||
|
||||
@@ -399,7 +400,7 @@ function UserCreatePage() {
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>) => {
|
||||
setError(
|
||||
err.response?.data?.error ||
|
||||
formatUserPolicyMessage(err.response?.data?.error) ||
|
||||
t("msg.admin.users.create.error", "사용자 생성에 실패했습니다."),
|
||||
);
|
||||
},
|
||||
@@ -943,8 +944,12 @@ function UserCreatePage() {
|
||||
data-testid={`appointment-tenant-picker-${index}`}
|
||||
>
|
||||
<Building2 className="mr-2 h-4 w-4 shrink-0" />
|
||||
<span className="truncate">
|
||||
{appointment.tenantName || "테넌트 선택"}
|
||||
<span className="pointer-events-none truncate">
|
||||
{appointment.tenantName ||
|
||||
t(
|
||||
"ui.admin.users.create.form.pick_from_hanmac_family",
|
||||
"한맥가족에서 선택",
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
{appointment.tenantSlug && (
|
||||
|
||||
@@ -93,6 +93,7 @@ import {
|
||||
type OrgChartTenantSelection,
|
||||
parseOrgChartTenantSelection,
|
||||
} from "./orgChartPicker";
|
||||
import { formatUserPolicyMessage } from "./userPolicyMessages";
|
||||
import type { UserSchemaField } from "./userSchemaFields";
|
||||
import {
|
||||
normalizeUserStatusValue,
|
||||
@@ -1012,7 +1013,7 @@ function UserDetailPage() {
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>) => {
|
||||
toast.error(
|
||||
err.response?.data?.error ||
|
||||
formatUserPolicyMessage(err.response?.data?.error) ||
|
||||
t("err.common.unknown", "오류가 발생했습니다."),
|
||||
);
|
||||
},
|
||||
@@ -1078,6 +1079,7 @@ function UserDetailPage() {
|
||||
try {
|
||||
const tenant = await ensurePersonalTenant();
|
||||
payload.tenantSlug = tenant.slug;
|
||||
payload.isPrimaryTenant = true;
|
||||
payload.department = undefined;
|
||||
payload.grade = undefined;
|
||||
payload.position = undefined;
|
||||
@@ -1111,6 +1113,7 @@ function UserDetailPage() {
|
||||
const primary = appointments.find((a) => a.isPrimary);
|
||||
if (primary) {
|
||||
payload.tenantSlug = primary.tenantSlug;
|
||||
payload.isPrimaryTenant = true;
|
||||
payload.primaryTenantId = primary.tenantId;
|
||||
payload.primaryTenantName = primary.tenantName;
|
||||
metadata.primaryTenantId = primary.tenantId;
|
||||
@@ -1133,6 +1136,7 @@ function UserDetailPage() {
|
||||
primaryTenantSlug: primary?.tenantSlug,
|
||||
};
|
||||
payload.tenantSlug = primary?.tenantSlug;
|
||||
payload.isPrimaryTenant = primary ? true : undefined;
|
||||
payload.primaryTenantId = primary?.tenantId;
|
||||
payload.primaryTenantName = primary?.tenantName;
|
||||
}
|
||||
|
||||
@@ -139,6 +139,19 @@ describe("UserListPage search rendering", () => {
|
||||
expect(selectRenderCounter.count).toBe(renderCountBeforeTyping);
|
||||
});
|
||||
|
||||
it("describes the user list as identity mirror backed, not local DB backed", async () => {
|
||||
renderUserListPage();
|
||||
|
||||
await screen.findByText("User 0");
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Kratos identity mirror 기준으로 시스템 사용자를 조회하고 관리합니다.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Local DB/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps rendered row controls below the full 200-user result set", async () => {
|
||||
renderUserListPage();
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ import {
|
||||
downloadUserTemplate,
|
||||
UserBulkUploadModal,
|
||||
} from "./components/UserBulkUploadModal";
|
||||
import { formatUserPolicyMessage } from "./userPolicyMessages";
|
||||
import {
|
||||
normalizeUserStatusValue,
|
||||
type UserStatusValue,
|
||||
@@ -652,7 +653,21 @@ function UserListPage() {
|
||||
|
||||
const bulkUpdateMutation = useMutation({
|
||||
mutationFn: bulkUpdateUsers,
|
||||
onSuccess: () => {
|
||||
onSuccess: (data) => {
|
||||
const failed = data.results?.filter((result) => !result.success) ?? [];
|
||||
if (failed.length > 0) {
|
||||
toast.error(
|
||||
t(
|
||||
"msg.admin.users.bulk.update_partial_error",
|
||||
"{{count}}명의 사용자 정보 수정에 실패했습니다.",
|
||||
{ count: failed.length },
|
||||
),
|
||||
{
|
||||
description: formatUserPolicyMessage(failed[0]?.message),
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
query.refetch();
|
||||
setSelectedUserIds([]);
|
||||
setSelectedBulkStatus("");
|
||||
@@ -725,7 +740,7 @@ function UserListPage() {
|
||||
}
|
||||
description={t(
|
||||
"msg.admin.users.list.subtitle",
|
||||
"시스템 사용자를 조회하고 관리합니다.",
|
||||
"Kratos identity mirror 기준으로 시스템 사용자를 조회하고 관리합니다.",
|
||||
)}
|
||||
actions={
|
||||
<>
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
type TenantImportPreviewRow,
|
||||
} from "../../tenants/utils/tenantCsvImport";
|
||||
import { isHanmacFamilyTenant, isHanmacFamilyUser } from "../orgChartPicker";
|
||||
import { formatUserPolicyMessage } from "../userPolicyMessages";
|
||||
import { parseUserCSV } from "../utils/csvParser";
|
||||
import { applyGeneralPlanningOfficePriority } from "../utils/generalPlanningOfficePriority";
|
||||
import {
|
||||
@@ -768,7 +769,7 @@ export function UserBulkUploadModal({
|
||||
)}
|
||||
{!r.success && (
|
||||
<div className="text-xs text-destructive">
|
||||
{r.message}
|
||||
{formatUserPolicyMessage(r.message)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -51,6 +51,16 @@ describe("orgChartPicker", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the orgfront development origin for authenticated picker URLs", () => {
|
||||
expect(
|
||||
buildAuthenticatedOrgChartTenantPickerUrl(undefined, {
|
||||
tenantId: "hanmac-family-id",
|
||||
}),
|
||||
).toBe(
|
||||
"http://localhost:5175/login?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dsingle%26select%3Dtenant%26width%3D400%26height%3D600%26tenantId%3Dhanmac-family-id%26includeInternal%3Dtrue",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds an authenticated multi picker URL for tenant member selection", () => {
|
||||
expect(
|
||||
buildAuthenticatedOrgChartUserMultiPickerUrl(
|
||||
|
||||
@@ -52,6 +52,8 @@ type OrgChartLoginOptions = {
|
||||
returnTo?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_ORGFRONT_BASE_URL = "http://localhost:5175";
|
||||
|
||||
export const GPDTDC_GRADE_OPTIONS = [
|
||||
"연구원",
|
||||
"선임",
|
||||
@@ -348,7 +350,8 @@ export function buildAuthenticatedOrgChartUrl(
|
||||
baseUrl?: string,
|
||||
options: OrgChartLoginOptions = { includeInternal: true },
|
||||
) {
|
||||
const normalizedBase = (baseUrl ?? "").replace(/\/+$/, "");
|
||||
const normalizedBase =
|
||||
baseUrl?.trim().replace(/\/+$/, "") || DEFAULT_ORGFRONT_BASE_URL;
|
||||
let returnTo = options.returnTo?.trim() || "/chart";
|
||||
if (options.includeInternal && returnTo.startsWith("/chart")) {
|
||||
const [path, query = ""] = returnTo.split("?", 2);
|
||||
|
||||
20
adminfront/src/features/users/userPolicyMessages.ts
Normal file
20
adminfront/src/features/users/userPolicyMessages.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
const INTERNAL_DOMAIN_PERSONAL_POLICY_PATTERNS = [
|
||||
"internal email domain cannot be assigned to personal tenant",
|
||||
"내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다",
|
||||
];
|
||||
|
||||
export function formatUserPolicyMessage(message?: string | null) {
|
||||
const raw = String(message ?? "").trim();
|
||||
if (!raw) {
|
||||
return "";
|
||||
}
|
||||
const normalized = raw.toLowerCase();
|
||||
if (
|
||||
INTERNAL_DOMAIN_PERSONAL_POLICY_PATTERNS.some((pattern) =>
|
||||
normalized.includes(pattern.toLowerCase()),
|
||||
)
|
||||
) {
|
||||
return "내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다. 대표소속을 회사 또는 조직 소속으로 지정해 주세요.";
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
@@ -36,11 +36,17 @@ describe("adminApi user tenant payloads", () => {
|
||||
const { updateUser } = await import("./adminApi");
|
||||
apiClient.put.mockResolvedValue({ data: {} });
|
||||
|
||||
await updateUser("user-id", { tenantSlug: "new-tenant" });
|
||||
await updateUser("user-id", {
|
||||
tenantSlug: "new-tenant",
|
||||
isPrimaryTenant: true,
|
||||
});
|
||||
|
||||
expect(apiClient.put).toHaveBeenCalledWith(
|
||||
"/v1/admin/users/user-id",
|
||||
expect.objectContaining({ tenantSlug: "new-tenant" }),
|
||||
expect.objectContaining({
|
||||
tenantSlug: "new-tenant",
|
||||
isPrimaryTenant: true,
|
||||
}),
|
||||
);
|
||||
expect(apiClient.put.mock.calls[0][1]).not.toHaveProperty("companyCode");
|
||||
});
|
||||
@@ -61,6 +67,7 @@ describe("adminApi user tenant payloads", () => {
|
||||
await bulkUpdateUsers({
|
||||
userIds: ["user-id"],
|
||||
tenantSlug: "new-tenant",
|
||||
isPrimaryTenant: true,
|
||||
});
|
||||
|
||||
expect(apiClient.post.mock.calls[0][1].users[0]).toMatchObject({
|
||||
@@ -71,6 +78,7 @@ describe("adminApi user tenant payloads", () => {
|
||||
);
|
||||
expect(apiClient.put.mock.calls[0][1]).toMatchObject({
|
||||
tenantSlug: "new-tenant",
|
||||
isPrimaryTenant: true,
|
||||
});
|
||||
expect(apiClient.put.mock.calls[0][1]).not.toHaveProperty("companyCode");
|
||||
});
|
||||
|
||||
@@ -146,16 +146,6 @@ export type AdminOverviewStats = {
|
||||
auditEvents24h: number;
|
||||
};
|
||||
|
||||
export type UserProjectionStatus = {
|
||||
name: string;
|
||||
status: "ready" | "failed" | "syncing" | string;
|
||||
ready: boolean;
|
||||
lastSyncedAt?: string;
|
||||
lastError?: string;
|
||||
updatedAt?: string;
|
||||
projectedUsers: number;
|
||||
};
|
||||
|
||||
export type IdentityCacheStatus = {
|
||||
status: string;
|
||||
redisReady: boolean;
|
||||
@@ -270,13 +260,6 @@ export async function deleteOrphanUserLoginIDs(ids: string[]) {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchUserProjectionStatus() {
|
||||
const { data } = await apiClient.get<UserProjectionStatus>(
|
||||
"/v1/admin/projections/users",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchOrySSOTSystemStatus() {
|
||||
const { data } =
|
||||
await apiClient.get<OrySSOTSystemStatus>("/v1/admin/ory/ssot");
|
||||
@@ -718,6 +701,7 @@ export type UserUpdateRequest = {
|
||||
role?: string;
|
||||
status?: string;
|
||||
tenantSlug?: string;
|
||||
isPrimaryTenant?: boolean;
|
||||
isAddTenant?: boolean;
|
||||
isRemoveTenant?: boolean;
|
||||
department?: string;
|
||||
@@ -920,6 +904,7 @@ export type WorksmobileComparisonItem = {
|
||||
worksmobileJobRetryCount?: number;
|
||||
worksmobileLastError?: string;
|
||||
worksmobileLastAttemptAt?: string;
|
||||
updateReasons?: string[];
|
||||
status: string;
|
||||
};
|
||||
|
||||
@@ -1144,13 +1129,17 @@ export async function bulkUpdateUsers(payload: {
|
||||
status?: string;
|
||||
role?: string;
|
||||
tenantSlug?: string;
|
||||
isPrimaryTenant?: boolean;
|
||||
isAddTenant?: boolean;
|
||||
department?: string;
|
||||
position?: string;
|
||||
grade?: string;
|
||||
jobTitle?: string;
|
||||
}) {
|
||||
const { data } = await apiClient.put("/v1/admin/users/bulk", payload);
|
||||
const { data } = await apiClient.put<BulkUserResponse>(
|
||||
"/v1/admin/users/bulk",
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
buildCommonOidcRuntimeConfig,
|
||||
buildCommonUserManagerSettings,
|
||||
} from "../../../common/core/auth";
|
||||
import { resolveAdminPublicOrigin } from "./authConfig";
|
||||
import {
|
||||
resolveAdminOidcAuthority,
|
||||
resolveAdminPublicOrigin,
|
||||
} from "./authConfig";
|
||||
|
||||
const adminPublicOrigin = resolveAdminPublicOrigin(
|
||||
import.meta.env.VITE_ADMIN_PUBLIC_URL,
|
||||
@@ -12,7 +15,10 @@ const adminPublicOrigin = resolveAdminPublicOrigin(
|
||||
);
|
||||
|
||||
export const oidcConfig: AuthProviderProps = buildCommonOidcRuntimeConfig({
|
||||
authority: import.meta.env.VITE_OIDC_AUTHORITY || "https://sso.hmac.kr/oidc",
|
||||
authority: resolveAdminOidcAuthority(
|
||||
import.meta.env.VITE_OIDC_AUTHORITY,
|
||||
window.location.origin,
|
||||
),
|
||||
clientId: import.meta.env.VITE_OIDC_CLIENT_ID || "adminfront",
|
||||
origin: adminPublicOrigin,
|
||||
userStore: new WebStorageStateStore({ store: window.localStorage }),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildAdminAuthRedirectUris,
|
||||
canStartBrowserPkceLogin,
|
||||
resolveAdminOidcAuthority,
|
||||
resolveAdminPublicOrigin,
|
||||
} from "./authConfig";
|
||||
|
||||
@@ -26,6 +27,12 @@ describe("admin auth config", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the local OIDC authority for localhost when no explicit authority is set", () => {
|
||||
expect(resolveAdminOidcAuthority(undefined, "http://localhost:5173")).toBe(
|
||||
"http://localhost:5000/oidc",
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks browser PKCE login when WebCrypto is unavailable", () => {
|
||||
expect(
|
||||
canStartBrowserPkceLogin({
|
||||
|
||||
@@ -5,6 +5,8 @@ export interface AdminAuthRedirectUris {
|
||||
}
|
||||
|
||||
export const ADMIN_AUTH_CALLBACK_PATH = "/auth/callback";
|
||||
const ADMIN_DEFAULT_PRODUCTION_OIDC_AUTHORITY = "https://sso.hmac.kr/oidc";
|
||||
const ADMIN_LOCAL_OIDC_PORT = "5000";
|
||||
|
||||
export function resolveAdminPublicOrigin(
|
||||
configuredOrigin: string | undefined,
|
||||
@@ -71,6 +73,27 @@ function isDevTrustedPkceOrigin(origin: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAdminOidcAuthority(
|
||||
configuredAuthority: string | undefined,
|
||||
browserOrigin: string,
|
||||
) {
|
||||
const trimmed = configuredAuthority?.trim();
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
try {
|
||||
const originUrl = new URL(browserOrigin);
|
||||
if (isDevTrustedPkceOrigin(originUrl.origin)) {
|
||||
return `${originUrl.protocol}//${originUrl.hostname}:${ADMIN_LOCAL_OIDC_PORT}/oidc`;
|
||||
}
|
||||
} catch {
|
||||
return ADMIN_DEFAULT_PRODUCTION_OIDC_AUTHORITY;
|
||||
}
|
||||
|
||||
return ADMIN_DEFAULT_PRODUCTION_OIDC_AUTHORITY;
|
||||
}
|
||||
|
||||
export function canStartBrowserPkceLogin({
|
||||
isSecureContext = window.isSecureContext,
|
||||
origin = window.location.origin,
|
||||
|
||||
@@ -313,6 +313,7 @@ move_description = "Bulk move selected users to another tenant."
|
||||
move_error = "Error moving users."
|
||||
move_success = "{{count}} users moved successfully."
|
||||
parsed_count = "Parsed {{count}} rows."
|
||||
update_partial_error = "Failed to update {{count}} users."
|
||||
update_success = "User info updated successfully."
|
||||
|
||||
[msg.admin.users.create]
|
||||
|
||||
@@ -317,6 +317,7 @@ move_description = "선택한 사용자를 다른 테넌트로 일괄 이동합
|
||||
move_error = "사용자 이동 중 오류가 발생했습니다."
|
||||
move_success = "{{count}}명의 사용자가 성공적으로 이동되었습니다."
|
||||
parsed_count = "{{count}}행의 데이터가 파싱되었습니다."
|
||||
update_partial_error = "{{count}}명의 사용자 정보 수정에 실패했습니다."
|
||||
update_success = "사용자 정보가 일괄 업데이트되었습니다."
|
||||
|
||||
[msg.admin.users.create]
|
||||
@@ -378,7 +379,7 @@ self_password_reset_blocked = "본인 계정의 비밀번호는 사용자 포털
|
||||
delete_confirm = "사용자 \"{{name}}\"을(를) 정말 삭제하시겠습니까?"
|
||||
empty = "검색 결과가 없습니다."
|
||||
fetch_error = "사용자 목록 조회에 실패했습니다."
|
||||
subtitle = "시스템 사용자를 조회하고 관리합니다. (Local DB)"
|
||||
subtitle = "Kratos identity mirror 기준으로 시스템 사용자를 조회하고 관리합니다."
|
||||
|
||||
[msg.admin.users.list.columns]
|
||||
description = "테이블에 표시할 컬럼을 선택합니다."
|
||||
|
||||
@@ -335,6 +335,7 @@ move_description = ""
|
||||
move_error = ""
|
||||
move_success = ""
|
||||
parsed_count = ""
|
||||
update_partial_error = ""
|
||||
update_success = ""
|
||||
|
||||
[msg.admin.users.create]
|
||||
|
||||
@@ -77,7 +77,8 @@ const translations: Record<"ko" | "en", Record<string, string>> = {
|
||||
"Ory SSOT 시스템 상태를 불러오지 못했습니다.",
|
||||
"msg.admin.ory_ssot.subtitle":
|
||||
"Kratos 원장과 Redis identity cache 상태를 분리해서 확인합니다.",
|
||||
"msg.admin.users.list.subtitle": "시스템 사용자를 조회하고 관리합니다.",
|
||||
"msg.admin.users.list.subtitle":
|
||||
"Kratos identity mirror 기준으로 시스템 사용자를 조회하고 관리합니다.",
|
||||
"msg.admin.users.list.registry.count":
|
||||
"총 {{count}}명의 사용자가 등록되어 있습니다.",
|
||||
"msg.admin.integrity.check.duplicate_tenant_slugs.description":
|
||||
@@ -168,7 +169,7 @@ const translations: Record<"ko" | "en", Record<string, string>> = {
|
||||
"msg.admin.ory_ssot.subtitle":
|
||||
"Review Kratos source-of-truth and Redis identity cache status separately.",
|
||||
"msg.admin.users.list.subtitle":
|
||||
"Search and manage users registered in the current tenant.",
|
||||
"Search and manage users from the Kratos identity mirror.",
|
||||
"msg.admin.users.list.registry.count": "{{count}} users loaded.",
|
||||
"msg.admin.integrity.check.duplicate_tenant_slugs.description":
|
||||
"Checks duplicate active tenant slugs using LOWER(TRIM(slug)).",
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { installAdminFrontStaticRoutes } from "./helpers/static-adminfront";
|
||||
|
||||
test.describe("Bulk Actions and Tree Search", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installAdminFrontStaticRoutes(page);
|
||||
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("locale", "ko");
|
||||
window.localStorage.setItem("admin_session", "fake-token");
|
||||
@@ -196,6 +199,55 @@ test.describe("Bulk Actions and Tree Search", () => {
|
||||
await expect(selectionBar).not.toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test("should show a failure toast when bulk update returns blocked rows", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route("**/api/v1/admin/users/bulk", async (route) => {
|
||||
if (route.request().method() === "PUT") {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
results: [
|
||||
{
|
||||
id: "u-1",
|
||||
success: false,
|
||||
message:
|
||||
"internal email domain cannot be assigned to personal tenant: u1@brsw.kr",
|
||||
},
|
||||
],
|
||||
},
|
||||
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();
|
||||
const selectionBar = page.getByTestId("bulk-action-bar");
|
||||
await expect(selectionBar).toBeVisible({ timeout: 15000 });
|
||||
|
||||
await page.getByTestId("bulk-status-select").click();
|
||||
await page.getByRole("option", { name: /입사대기|Preboarding/i }).click();
|
||||
await page.getByTestId("bulk-apply-btn").click();
|
||||
|
||||
await expect(
|
||||
page.getByText(/1명의 사용자 정보 수정에 실패했습니다/),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
/내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다/,
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(/선택한 사용자들의 정보가 수정되었습니다/),
|
||||
).not.toBeVisible();
|
||||
await expect(selectionBar).toBeVisible();
|
||||
});
|
||||
|
||||
test("should let super admins apply selected admin permission to selected users", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -925,6 +925,17 @@ test.describe("Tenants Management", () => {
|
||||
await expect(
|
||||
page.getByRole("button", { name: "다른 테넌트 선택" }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "한맥가족에서 선택" }).click();
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
const hanmacPickerSrc = await page
|
||||
.getByTestId("parent-tenant-picker-frame")
|
||||
.getAttribute("src");
|
||||
expect(hanmacPickerSrc).toContain("http://localhost:5175/login");
|
||||
expect(decodeURIComponent(hanmacPickerSrc ?? "")).toContain(
|
||||
"tenantId=family-1",
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
const parentLabelTop = await page
|
||||
.getByText(/상위 테넌트/)
|
||||
.first()
|
||||
@@ -1945,6 +1956,22 @@ test.describe("Tenants Management", () => {
|
||||
expect(topColumns.split(" ").length).toBe(3);
|
||||
expect(configColumns.split(" ").length).toBe(4);
|
||||
|
||||
await page
|
||||
.getByTestId("tenant-parent-picker-slot")
|
||||
.getByRole("button")
|
||||
.first()
|
||||
.click();
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
const detailPickerSrc = await page
|
||||
.getByTestId("parent-tenant-picker-frame")
|
||||
.getAttribute("src");
|
||||
expect(detailPickerSrc).toContain("http://localhost:5175/login");
|
||||
expect(decodeURIComponent(detailPickerSrc ?? "")).toContain(
|
||||
"/embed/picker",
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
|
||||
const nameTop = await page
|
||||
.getByTestId("tenant-name-slot")
|
||||
.evaluate((element) => element.getBoundingClientRect().top);
|
||||
|
||||
@@ -689,6 +689,37 @@ test.describe("User Management", () => {
|
||||
await expect(page).toHaveURL(/.*\/users$/, { timeout: 10000 });
|
||||
});
|
||||
|
||||
test("should show a Korean policy message when an internal domain user is created as personal", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route(/\/admin\/users$/, async (route) => {
|
||||
if (route.request().method() !== "POST") {
|
||||
return route.fallback();
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 400,
|
||||
json: {
|
||||
error:
|
||||
"internal email domain cannot be assigned to personal tenant: user@hanmaceng.co.kr",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/users/new");
|
||||
await expect(page.getByText(/사용자 추가/i).first()).toBeVisible();
|
||||
|
||||
await page.getByRole("tab", { name: /개인 회원/i }).click();
|
||||
await page.locator('input[name="name"]').fill("Internal User");
|
||||
await page.locator('input[name="email"]').fill("user@hanmaceng.co.kr");
|
||||
await page.getByRole("button", { name: /생성/i }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
/내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다/,
|
||||
),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("should export users through the authenticated API client", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -1032,6 +1063,43 @@ test.describe("User Management", () => {
|
||||
expect(createPayload).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should open Hanmac family tenant picker without submitting the user create form", async ({
|
||||
page,
|
||||
}) => {
|
||||
let createRequests = 0;
|
||||
|
||||
await page.route(/\/admin\/users(\?.*)?$/, async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
createRequests += 1;
|
||||
return route.fulfill({
|
||||
status: 201,
|
||||
json: {
|
||||
id: "new-user-id",
|
||||
name: "Family User",
|
||||
email: "family@test.com",
|
||||
},
|
||||
});
|
||||
}
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
await page.goto("/users/new");
|
||||
await page.getByTestId("add-appointment-btn").click();
|
||||
await expect(page.getByTestId("appointment-row-0")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "한맥가족에서 선택" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/users\/new$/);
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
const pickerSrc = await page
|
||||
.getByTestId("appointment-tenant-picker-frame")
|
||||
.getAttribute("src");
|
||||
expect(decodeURIComponent(pickerSrc ?? "")).toContain(
|
||||
"tenantId=hanmac-family-id",
|
||||
);
|
||||
expect(createRequests).toBe(0);
|
||||
});
|
||||
|
||||
test("should hide Hanmac family subtree and system tenants when creating a non-family user", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { installAdminFrontStaticRoutes } from "./helpers/static-adminfront";
|
||||
|
||||
test.describe("Users Bulk Upload", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installAdminFrontStaticRoutes(page);
|
||||
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("locale", "ko");
|
||||
window.localStorage.setItem("admin_session", "fake-token");
|
||||
@@ -117,6 +120,56 @@ test.describe("Users Bulk Upload", () => {
|
||||
await expect(uploadBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
test("should show Korean policy message for internal domain personal failures", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route("**/api/v1/admin/users/bulk", async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
results: [
|
||||
{
|
||||
email: "user@pre-cast.co.kr",
|
||||
success: false,
|
||||
message:
|
||||
"internal email domain cannot be assigned to personal tenant: user@pre-cast.co.kr",
|
||||
},
|
||||
],
|
||||
},
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.goto("/users");
|
||||
await expect(page.getByTestId("page-title")).toContainText(
|
||||
/사용자|Users/i,
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
|
||||
await page.getByTestId("user-data-mgmt-btn").click();
|
||||
await page
|
||||
.getByRole("menuitem", { name: /일괄 임포트|일괄 등록|Bulk Import/i })
|
||||
.click();
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles({
|
||||
name: "users.csv",
|
||||
mimeType: "text/csv",
|
||||
buffer: Buffer.from("email,name\nuser@pre-cast.co.kr,Internal User\n"),
|
||||
});
|
||||
|
||||
await page.getByTestId("bulk-start-btn").click();
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
/내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다/,
|
||||
),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("should create missing tenant before user bulk import", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -2,15 +2,18 @@ package main
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/bootstrap"
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/idp"
|
||||
"baron-sso-backend/internal/logger"
|
||||
"baron-sso-backend/internal/repository"
|
||||
"baron-sso-backend/internal/service"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -32,6 +35,16 @@ type clearOrphanUserTenantMembershipsConfig struct {
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
type repairDeletedTenantIdentitiesConfig struct {
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
type repairUserTenantConfig struct {
|
||||
UserID string
|
||||
TenantSlug string
|
||||
RemoveTenantSlug string
|
||||
}
|
||||
|
||||
func main() {
|
||||
loadEnv()
|
||||
logger.Init(logger.Config{
|
||||
@@ -56,6 +69,16 @@ func main() {
|
||||
slog.Error("clear-orphan-user-tenant-memberships failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "repair-deleted-tenant-identities":
|
||||
if err := runRepairDeletedTenantIdentities(os.Args[2:]); err != nil {
|
||||
slog.Error("repair-deleted-tenant-identities failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "repair-user-tenant":
|
||||
if err := runRepairUserTenant(os.Args[2:]); err != nil {
|
||||
slog.Error("repair-user-tenant failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "worksmobile-sync":
|
||||
if err := runWorksmobileSync(os.Args[2:]); err != nil {
|
||||
slog.Error("worksmobile-sync failed", "error", err)
|
||||
@@ -121,6 +144,69 @@ func runCreateSuperAdmin(args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func runRepairUserTenant(args []string) error {
|
||||
config, err := resolveRepairUserTenantConfig(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
db, err := openDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var tenant domain.Tenant
|
||||
if err := db.WithContext(ctx).First(&tenant, "slug = ?", config.TenantSlug).Error; err != nil {
|
||||
return fmt.Errorf("target tenant not found slug=%s: %w", config.TenantSlug, err)
|
||||
}
|
||||
|
||||
var removeTenant *domain.Tenant
|
||||
if config.RemoveTenantSlug != "" {
|
||||
var found domain.Tenant
|
||||
if err := db.WithContext(ctx).First(&found, "slug = ?", config.RemoveTenantSlug).Error; err != nil {
|
||||
return fmt.Errorf("remove tenant not found slug=%s: %w", config.RemoveTenantSlug, err)
|
||||
}
|
||||
removeTenant = &found
|
||||
}
|
||||
|
||||
kratos := service.NewKratosAdminService()
|
||||
identity, err := kratos.GetIdentity(ctx, config.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if identity == nil {
|
||||
return fmt.Errorf("identity not found: %s", config.UserID)
|
||||
}
|
||||
traits := adminctlCloneIdentityTraits(identity.Traits)
|
||||
adminctlSetPrimaryTenantTraits(traits, tenant, removeTenant)
|
||||
updated, err := kratos.UpdateIdentity(ctx, config.UserID, traits, identity.State)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if updated == nil {
|
||||
return fmt.Errorf("kratos update returned empty identity")
|
||||
}
|
||||
|
||||
if err := db.WithContext(ctx).
|
||||
Model(&domain.User{}).
|
||||
Where("id = ?", config.UserID).
|
||||
Updates(map[string]any{
|
||||
"tenant_id": tenant.ID,
|
||||
"metadata": domain.JSONMap(updated.Traits),
|
||||
"updated_at": time.Now(),
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if redisService, err := service.NewRedisService(); err == nil {
|
||||
_, _ = redisService.FlushIdentityCache(ctx)
|
||||
} else {
|
||||
slog.Warn("identity mirror flush skipped", "error", err)
|
||||
}
|
||||
fmt.Printf("user tenant repaired: user=%s tenant=%s<%s> removed=%s\n", config.UserID, tenant.Name, tenant.Slug, config.RemoveTenantSlug)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runClearOrphanUserTenantMemberships(args []string) error {
|
||||
config, err := resolveClearOrphanUserTenantMembershipsConfig(args)
|
||||
if err != nil {
|
||||
@@ -152,6 +238,92 @@ func runClearOrphanUserTenantMemberships(args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func runRepairDeletedTenantIdentities(args []string) error {
|
||||
config, err := resolveRepairDeletedTenantIdentitiesConfig(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := openDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
var tenants []domain.Tenant
|
||||
if err := db.WithContext(ctx).Unscoped().Find(&tenants).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
tenantByID, deletedBySlug := adminctlTenantIndexes(tenants)
|
||||
kratos := service.NewKratosAdminService()
|
||||
identities, err := kratos.ListIdentities(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
scanned := 0
|
||||
candidates := 0
|
||||
updated := 0
|
||||
localUpdated := int64(0)
|
||||
for _, identity := range identities {
|
||||
scanned++
|
||||
deletedTenant, targetTenant, ok := adminctlDeletedTenantPromotion(identity.Traits, tenantByID, deletedBySlug)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
candidates++
|
||||
nextTraits, changed := adminctlPromoteIdentityTraits(identity.Traits, deletedTenant, targetTenant)
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
fmt.Printf("repair candidate: user=%s email=%s deleted=%s<%s> target=%s<%s>\n",
|
||||
identity.ID,
|
||||
adminctlTraitString(identity.Traits["email"]),
|
||||
deletedTenant.Name,
|
||||
adminctlLegacyTenantSlug(deletedTenant),
|
||||
targetTenant.Name,
|
||||
targetTenant.Slug,
|
||||
)
|
||||
if config.DryRun {
|
||||
continue
|
||||
}
|
||||
if _, err := kratos.UpdateIdentity(ctx, identity.ID, nextTraits, identity.State); err != nil {
|
||||
return fmt.Errorf("update kratos identity user=%s: %w", identity.ID, err)
|
||||
}
|
||||
result := db.WithContext(ctx).
|
||||
Model(&domain.User{}).
|
||||
Where("id = ?", identity.ID).
|
||||
Updates(map[string]any{"tenant_id": targetTenant.ID, "updated_at": time.Now()})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
localUpdated += result.RowsAffected
|
||||
updated++
|
||||
}
|
||||
|
||||
orphanUpdated := int64(0)
|
||||
if !config.DryRun {
|
||||
affected, err := repository.ClearOrphanUserTenantMemberships(ctx, db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
orphanUpdated = affected
|
||||
if redisService, err := service.NewRedisService(); err == nil {
|
||||
if _, err := redisService.FlushIdentityCache(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
slog.Warn("identity mirror flush skipped", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("deleted tenant identity repair: scanned=%d candidates=%d kratos_updated=%d local_users_updated=%d orphan_memberships_updated=%d dry_run=%t\n",
|
||||
scanned, candidates, updated, localUpdated, orphanUpdated, config.DryRun)
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveCreateSuperAdminConfig(args []string) (createSuperAdminConfig, error) {
|
||||
fs := flag.NewFlagSet("create-super-admin", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
@@ -193,6 +365,294 @@ func resolveClearOrphanUserTenantMembershipsConfig(args []string) (clearOrphanUs
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func resolveRepairDeletedTenantIdentitiesConfig(args []string) (repairDeletedTenantIdentitiesConfig, error) {
|
||||
fs := flag.NewFlagSet("repair-deleted-tenant-identities", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
|
||||
config := repairDeletedTenantIdentitiesConfig{}
|
||||
fs.BoolVar(&config.DryRun, "dry-run", false, "print identities that reference deleted tenants without updating Kratos or local DB")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return config, err
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func resolveRepairUserTenantConfig(args []string) (repairUserTenantConfig, error) {
|
||||
fs := flag.NewFlagSet("repair-user-tenant", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
|
||||
config := repairUserTenantConfig{}
|
||||
fs.StringVar(&config.UserID, "user-id", "", "identity/user id to repair")
|
||||
fs.StringVar(&config.TenantSlug, "tenant-slug", "", "target representative tenant slug")
|
||||
fs.StringVar(&config.RemoveTenantSlug, "remove-tenant-slug", "", "appointment tenant slug to remove")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return config, err
|
||||
}
|
||||
config.UserID = strings.TrimSpace(config.UserID)
|
||||
config.TenantSlug = strings.TrimSpace(config.TenantSlug)
|
||||
config.RemoveTenantSlug = strings.TrimSpace(config.RemoveTenantSlug)
|
||||
if config.UserID == "" {
|
||||
return config, fmt.Errorf("--user-id is required")
|
||||
}
|
||||
if config.TenantSlug == "" {
|
||||
return config, fmt.Errorf("--tenant-slug is required")
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func adminctlSetPrimaryTenantTraits(traits map[string]any, target domain.Tenant, removeTenant *domain.Tenant) {
|
||||
traits["tenant_id"] = target.ID
|
||||
traits["primaryTenantId"] = target.ID
|
||||
traits["primaryTenantSlug"] = target.Slug
|
||||
traits["primaryTenantName"] = target.Name
|
||||
delete(traits, "companyCode")
|
||||
delete(traits, "companyCodes")
|
||||
|
||||
rawAppointments, _ := adminctlPromoteIdentityAppointments(traits["additionalAppointments"], target, target)
|
||||
if rawAppointments == nil {
|
||||
rawAppointments = []any{}
|
||||
}
|
||||
next := make([]any, 0, len(rawAppointments)+1)
|
||||
targetSeen := false
|
||||
for _, raw := range rawAppointments {
|
||||
appointment, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
next = append(next, raw)
|
||||
continue
|
||||
}
|
||||
if removeTenant != nil && adminctlAppointmentMatchesTenant(appointment, *removeTenant) {
|
||||
continue
|
||||
}
|
||||
copied := maps.Clone(appointment)
|
||||
if adminctlAppointmentMatchesTenant(copied, target) {
|
||||
copied["tenantId"] = target.ID
|
||||
copied["tenantSlug"] = target.Slug
|
||||
copied["tenantName"] = target.Name
|
||||
copied["isPrimary"] = true
|
||||
targetSeen = true
|
||||
} else {
|
||||
copied["isPrimary"] = false
|
||||
}
|
||||
next = append(next, copied)
|
||||
}
|
||||
if !targetSeen {
|
||||
next = append(next, map[string]any{
|
||||
"tenantId": target.ID,
|
||||
"tenantSlug": target.Slug,
|
||||
"tenantName": target.Name,
|
||||
"isPrimary": true,
|
||||
})
|
||||
}
|
||||
traits["additionalAppointments"] = next
|
||||
}
|
||||
|
||||
func adminctlAppointmentMatchesTenant(appointment map[string]any, tenant domain.Tenant) bool {
|
||||
return adminctlTraitMatchesTenant(appointment["tenantId"], tenant) ||
|
||||
adminctlTraitMatchesTenant(appointment["tenantSlug"], tenant)
|
||||
}
|
||||
|
||||
func adminctlTenantIndexes(tenants []domain.Tenant) (map[string]domain.Tenant, map[string]domain.Tenant) {
|
||||
tenantByID := make(map[string]domain.Tenant, len(tenants))
|
||||
deletedBySlug := map[string]domain.Tenant{}
|
||||
for _, tenant := range tenants {
|
||||
tenantByID[tenant.ID] = tenant
|
||||
if tenant.DeletedAt.Valid {
|
||||
if slug := strings.ToLower(strings.TrimSpace(tenant.Slug)); slug != "" {
|
||||
deletedBySlug[slug] = tenant
|
||||
}
|
||||
if legacy := adminctlLegacyTenantSlug(tenant); legacy != "" {
|
||||
deletedBySlug[strings.ToLower(legacy)] = tenant
|
||||
}
|
||||
}
|
||||
}
|
||||
return tenantByID, deletedBySlug
|
||||
}
|
||||
|
||||
func adminctlDeletedTenantPromotion(traits map[string]any, tenantByID map[string]domain.Tenant, deletedBySlug map[string]domain.Tenant) (domain.Tenant, domain.Tenant, bool) {
|
||||
deleted, ok := adminctlFindDeletedTenantInTraits(traits, tenantByID, deletedBySlug)
|
||||
if !ok {
|
||||
return domain.Tenant{}, domain.Tenant{}, false
|
||||
}
|
||||
target, ok := adminctlNearestActiveAncestor(deleted, tenantByID)
|
||||
return deleted, target, ok
|
||||
}
|
||||
|
||||
func adminctlFindDeletedTenantInTraits(traits map[string]any, tenantByID map[string]domain.Tenant, deletedBySlug map[string]domain.Tenant) (domain.Tenant, bool) {
|
||||
for _, key := range []string{"tenant_id", "primaryTenantId", "primaryTenantSlug", "companyCode", "company_code"} {
|
||||
if tenant, ok := adminctlDeletedTenantFromValue(traits[key], tenantByID, deletedBySlug); ok {
|
||||
return tenant, true
|
||||
}
|
||||
}
|
||||
switch appointments := traits["additionalAppointments"].(type) {
|
||||
case []any:
|
||||
for _, raw := range appointments {
|
||||
appointment, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, key := range []string{"tenantId", "tenantSlug"} {
|
||||
if tenant, ok := adminctlDeletedTenantFromValue(appointment[key], tenantByID, deletedBySlug); ok {
|
||||
return tenant, true
|
||||
}
|
||||
}
|
||||
}
|
||||
case []map[string]any:
|
||||
for _, appointment := range appointments {
|
||||
for _, key := range []string{"tenantId", "tenantSlug"} {
|
||||
if tenant, ok := adminctlDeletedTenantFromValue(appointment[key], tenantByID, deletedBySlug); ok {
|
||||
return tenant, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return domain.Tenant{}, false
|
||||
}
|
||||
|
||||
func adminctlDeletedTenantFromValue(value any, tenantByID map[string]domain.Tenant, deletedBySlug map[string]domain.Tenant) (domain.Tenant, bool) {
|
||||
raw := strings.TrimSpace(fmt.Sprint(value))
|
||||
if raw == "" || raw == "<nil>" {
|
||||
return domain.Tenant{}, false
|
||||
}
|
||||
if tenant, ok := tenantByID[raw]; ok && tenant.DeletedAt.Valid {
|
||||
return tenant, true
|
||||
}
|
||||
tenant, ok := deletedBySlug[strings.ToLower(raw)]
|
||||
return tenant, ok
|
||||
}
|
||||
|
||||
func adminctlNearestActiveAncestor(deleted domain.Tenant, tenantByID map[string]domain.Tenant) (domain.Tenant, bool) {
|
||||
seen := map[string]bool{}
|
||||
parentID := deleted.ParentID
|
||||
for parentID != nil {
|
||||
id := strings.TrimSpace(*parentID)
|
||||
if id == "" || seen[id] {
|
||||
return domain.Tenant{}, false
|
||||
}
|
||||
seen[id] = true
|
||||
parent, ok := tenantByID[id]
|
||||
if !ok {
|
||||
return domain.Tenant{}, false
|
||||
}
|
||||
if !parent.DeletedAt.Valid {
|
||||
return parent, true
|
||||
}
|
||||
parentID = parent.ParentID
|
||||
}
|
||||
return domain.Tenant{}, false
|
||||
}
|
||||
|
||||
func adminctlPromoteIdentityTraits(traits map[string]any, deletedTenant domain.Tenant, targetTenant domain.Tenant) (map[string]any, bool) {
|
||||
next := adminctlCloneIdentityTraits(traits)
|
||||
changed := false
|
||||
if adminctlTraitMatchesTenant(next["tenant_id"], deletedTenant) || strings.TrimSpace(adminctlTraitString(next["tenant_id"])) == "" {
|
||||
next["tenant_id"] = targetTenant.ID
|
||||
changed = true
|
||||
}
|
||||
if adminctlTraitMatchesTenant(next["primaryTenantId"], deletedTenant) || adminctlTraitMatchesTenant(next["primaryTenantSlug"], deletedTenant) {
|
||||
next["primaryTenantId"] = targetTenant.ID
|
||||
next["primaryTenantSlug"] = targetTenant.Slug
|
||||
next["primaryTenantName"] = targetTenant.Name
|
||||
changed = true
|
||||
}
|
||||
if adminctlTraitMatchesTenant(next["companyCode"], deletedTenant) {
|
||||
next["companyCode"] = targetTenant.Slug
|
||||
changed = true
|
||||
}
|
||||
if adminctlTraitMatchesTenant(next["company_code"], deletedTenant) {
|
||||
next["company_code"] = targetTenant.Slug
|
||||
changed = true
|
||||
}
|
||||
if appointments, appointmentsChanged := adminctlPromoteIdentityAppointments(next["additionalAppointments"], deletedTenant, targetTenant); appointmentsChanged {
|
||||
next["additionalAppointments"] = appointments
|
||||
changed = true
|
||||
}
|
||||
return next, changed
|
||||
}
|
||||
|
||||
func adminctlPromoteIdentityAppointments(raw any, deletedTenant domain.Tenant, targetTenant domain.Tenant) ([]any, bool) {
|
||||
switch appointments := raw.(type) {
|
||||
case []any:
|
||||
next := make([]any, 0, len(appointments))
|
||||
changed := false
|
||||
for _, rawAppointment := range appointments {
|
||||
appointment, ok := rawAppointment.(map[string]any)
|
||||
if !ok {
|
||||
next = append(next, rawAppointment)
|
||||
continue
|
||||
}
|
||||
copied := maps.Clone(appointment)
|
||||
if adminctlTraitMatchesTenant(copied["tenantId"], deletedTenant) || adminctlTraitMatchesTenant(copied["tenantSlug"], deletedTenant) {
|
||||
copied["tenantId"] = targetTenant.ID
|
||||
copied["tenantSlug"] = targetTenant.Slug
|
||||
copied["tenantName"] = targetTenant.Name
|
||||
changed = true
|
||||
}
|
||||
next = append(next, copied)
|
||||
}
|
||||
return next, changed
|
||||
case []map[string]any:
|
||||
next := make([]any, 0, len(appointments))
|
||||
changed := false
|
||||
for _, appointment := range appointments {
|
||||
copied := maps.Clone(appointment)
|
||||
if adminctlTraitMatchesTenant(copied["tenantId"], deletedTenant) || adminctlTraitMatchesTenant(copied["tenantSlug"], deletedTenant) {
|
||||
copied["tenantId"] = targetTenant.ID
|
||||
copied["tenantSlug"] = targetTenant.Slug
|
||||
copied["tenantName"] = targetTenant.Name
|
||||
changed = true
|
||||
}
|
||||
next = append(next, copied)
|
||||
}
|
||||
return next, changed
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func adminctlTraitMatchesTenant(value any, tenant domain.Tenant) bool {
|
||||
raw := strings.TrimSpace(adminctlTraitString(value))
|
||||
if raw == "" {
|
||||
return false
|
||||
}
|
||||
if strings.EqualFold(raw, tenant.ID) || strings.EqualFold(raw, tenant.Slug) {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(raw, adminctlLegacyTenantSlug(tenant))
|
||||
}
|
||||
|
||||
func adminctlLegacyTenantSlug(tenant domain.Tenant) string {
|
||||
slug := strings.TrimSpace(tenant.Slug)
|
||||
idx := strings.LastIndex(slug, "-deleted-")
|
||||
if idx <= 0 {
|
||||
return slug
|
||||
}
|
||||
return slug[:idx]
|
||||
}
|
||||
|
||||
func adminctlTraitString(value any) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(value))
|
||||
}
|
||||
|
||||
func adminctlCloneIdentityTraits(traits map[string]any) map[string]any {
|
||||
if traits == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
raw, err := json.Marshal(traits)
|
||||
if err != nil {
|
||||
return maps.Clone(traits)
|
||||
}
|
||||
var next map[string]any
|
||||
if err := json.Unmarshal(raw, &next); err != nil {
|
||||
return maps.Clone(traits)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func openDB() (*gorm.DB, error) {
|
||||
dsn := fmt.Sprintf(
|
||||
"host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Seoul",
|
||||
@@ -232,5 +692,7 @@ func printUsage() {
|
||||
fmt.Fprintln(os.Stderr, "usage:")
|
||||
fmt.Fprintln(os.Stderr, " adminctl create-super-admin [--email EMAIL] [--password PASSWORD] [--name NAME] [--update-password]")
|
||||
fmt.Fprintln(os.Stderr, " adminctl clear-orphan-user-tenant-memberships [--dry-run]")
|
||||
fmt.Fprintln(os.Stderr, " adminctl repair-deleted-tenant-identities [--dry-run]")
|
||||
fmt.Fprintln(os.Stderr, " adminctl repair-user-tenant --user-id ID --tenant-slug SLUG [--remove-tenant-slug SLUG]")
|
||||
fmt.Fprintln(os.Stderr, " adminctl worksmobile-sync [--orgunits] [--users-csv PATH] [--credential-batch-id ID] [--process] [--serialize-orgunits] [--serialize-users-batch ID] [--batch-size N] [--delay DURATION]")
|
||||
}
|
||||
|
||||
@@ -160,7 +160,6 @@ func TestRecreatePendingWorksmobileUsersFromSnapshotCreatesOnlyMatchedUsers(t *t
|
||||
writer,
|
||||
client,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("recreatePendingWorksmobileUsersFromSnapshot returned error: %v", err)
|
||||
}
|
||||
@@ -224,7 +223,6 @@ func TestRecreatePendingWorksmobileUsersFromSnapshotRollsBackWhenCreateFails(t *
|
||||
writer,
|
||||
client,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("recreatePendingWorksmobileUsersFromSnapshot returned error: %v", err)
|
||||
}
|
||||
@@ -283,7 +281,6 @@ func TestImportHanmacWorksmobileUsersFromRowsSkipsExistingRemoteLocalPart(t *tes
|
||||
writer,
|
||||
client,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("importHanmacWorksmobileUsersFromRows returned error: %v", err)
|
||||
}
|
||||
@@ -341,7 +338,6 @@ func TestImportHanmacWorksmobileUsersFromRowsSavesBaronUserAndCreatesWorksmobile
|
||||
writer,
|
||||
client,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("importHanmacWorksmobileUsersFromRows returned error: %v", err)
|
||||
}
|
||||
@@ -409,7 +405,6 @@ func TestImportHanmacWorksmobileUsersFromRowsKeepsExternalSubEmailOutOfWorksmobi
|
||||
writer,
|
||||
client,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("importHanmacWorksmobileUsersFromRows returned error: %v", err)
|
||||
}
|
||||
@@ -441,7 +436,6 @@ func TestBuildAdminctlWorksmobileOrgUnitPayloadClearsDomainRootParent(t *testing
|
||||
companyID: company,
|
||||
orgID: org,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("buildAdminctlWorksmobileOrgUnitPayload returned error: %v", err)
|
||||
}
|
||||
@@ -512,6 +506,10 @@ func (f *fakeWorksmobilePendingRecreateClient) UpsertUser(ctx context.Context, p
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobilePendingRecreateClient) UpdateUserOnly(ctx context.Context, payload service.WorksmobileUserPayload) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobilePendingRecreateClient) AddUserAliasEmail(ctx context.Context, userID string, email string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -301,7 +301,6 @@ func main() {
|
||||
tenantRepo := repository.NewTenantRepository(db)
|
||||
userGroupRepo := repository.NewUserGroupRepository(db)
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
userProjectionRepo := repository.NewUserProjectionRepository(db)
|
||||
ketoOutboxRepo := repository.NewKetoOutboxRepository(db) // Reuse or re-init
|
||||
rpUsageOutboxRepo := repository.NewRPUsageOutboxRepository(db)
|
||||
worksmobileOutboxRepo := repository.NewWorksmobileOutboxRepository(db)
|
||||
@@ -309,13 +308,6 @@ func main() {
|
||||
kratosAdminService := service.NewKratosAdminService()
|
||||
oryAdminProvider := service.NewOryProvider()
|
||||
|
||||
userProjectionSyncer := service.NewUserProjectionSyncService(kratosAdminService, userProjectionRepo)
|
||||
if synced, err := userProjectionSyncer.Reconcile(context.Background()); err != nil {
|
||||
slog.Error("❌ Kratos user projection sync failed", "error", err)
|
||||
} else {
|
||||
slog.Info("✅ Kratos user projection synced", "users", synced)
|
||||
}
|
||||
|
||||
tenantService := service.NewTenantService(tenantRepo, userRepo, userGroupRepo, ketoOutboxRepo)
|
||||
worksmobilePrivateKey, err := getEnvFileOrValue("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE", "WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY", "")
|
||||
if err != nil {
|
||||
@@ -336,6 +328,7 @@ func main() {
|
||||
)
|
||||
configureWorksmobileClientFromEnv(worksmobileClient)
|
||||
worksmobileService := service.NewWorksmobileSyncService(tenantService, userRepo, worksmobileOutboxRepo, worksmobileClient)
|
||||
worksmobileService.SetIdentityMirror(redisService)
|
||||
worksmobileRelayClient := *worksmobileClient
|
||||
worksmobileRelayClient.RateLimiter = service.NewWorksmobileAPIRateLimiter(240, time.Minute)
|
||||
worksmobileRelayWorker := service.NewWorksmobileRelayWorker(worksmobileOutboxRepo, &worksmobileRelayClient)
|
||||
@@ -371,7 +364,6 @@ func main() {
|
||||
auditHandler := handler.NewAuditHandler(auditRepo)
|
||||
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService)
|
||||
authHandler.HeadlessJWKS = headlessJWKSCache
|
||||
authHandler.UserProjectionRepo = userProjectionRepo
|
||||
authHandler.RPUserMetadataRepo = rpUserMetadataRepo
|
||||
authHandler.RPUsageSink = rpUsageEmitter
|
||||
adminHandler := handler.NewAdminHandler(ketoService, ketoOutboxRepo)
|
||||
@@ -380,7 +372,6 @@ func main() {
|
||||
adminHandler.TenantRepo = tenantRepo
|
||||
adminHandler.Hydra = hydraService
|
||||
adminHandler.AuditRepo = auditRepo
|
||||
adminHandler.UserProjectionRepo = userProjectionRepo
|
||||
adminHandler.IdentityCache = redisService
|
||||
adminHandler.IntegrityChecker = repository.NewDataIntegrityChecker(db)
|
||||
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, ketoOutboxRepo, tenantService, developerService, authHandler)
|
||||
@@ -389,12 +380,20 @@ func main() {
|
||||
devHandler.IdentityWriter = service.NewIdentityWriteService(kratosAdminService, redisService)
|
||||
devHandler.RPUserMetadataRepo = rpUserMetadataRepo
|
||||
devHandler.RPUsageQueries = rpUsageQueryRepo
|
||||
tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, userProjectionRepo, ketoService, ketoOutboxRepo, kratosAdminService, sharedLinkService, hydraService, consentRepo)
|
||||
tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, ketoService, ketoOutboxRepo, kratosAdminService, sharedLinkService, hydraService, consentRepo)
|
||||
tenantHandler.OrgChartCache = redisService
|
||||
tenantHandler.IdentityCache = redisService
|
||||
go func() {
|
||||
startedAt := time.Now()
|
||||
if err := tenantHandler.WarmOrgChartSnapshotCache(context.Background()); err != nil {
|
||||
slog.Warn("Orgfront orgchart snapshot cache warmup failed", "error", err, "latency", time.Since(startedAt).String())
|
||||
return
|
||||
}
|
||||
slog.Info("Orgfront orgchart snapshot cache warmup completed", "latency", time.Since(startedAt).String())
|
||||
}()
|
||||
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
|
||||
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
|
||||
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo, userGroupRepo, auditRepo)
|
||||
userHandler.UserProjectionRepo = userProjectionRepo
|
||||
userHandler.IdentityCache = redisService
|
||||
go func() {
|
||||
startedAt := time.Now()
|
||||
@@ -735,7 +734,6 @@ func main() {
|
||||
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("/ory/ssot", requireSuperAdmin, adminHandler.GetOrySSOTSystemStatus)
|
||||
admin.Post("/ory/ssot/identity-cache/flush", requireSuperAdmin, adminHandler.FlushIdentityCache)
|
||||
admin.Get("/rp-usage/daily", requireAdmin, adminHandler.GetRPUsageDaily)
|
||||
|
||||
@@ -29,6 +29,9 @@ func Run(db *gorm.DB) error {
|
||||
if err := SanitizeLegacyUserMetadata(db); err != nil {
|
||||
return fmt.Errorf("legacy user metadata sanitize failed: %w", err)
|
||||
}
|
||||
if err := CanonicalizeUserAppointmentTenants(db); err != nil {
|
||||
return fmt.Errorf("user appointment tenant canonicalization failed: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("[Bootstrap] User seed skipped (Kratos is SoT)")
|
||||
slog.Info("[Bootstrap] Bootstrap completed successfully.")
|
||||
@@ -50,7 +53,6 @@ func migrateSchemas(db *gorm.DB) error {
|
||||
&domain.TenantDomain{},
|
||||
&domain.User{},
|
||||
&domain.UserLoginID{},
|
||||
&domain.UserProjectionState{},
|
||||
&domain.UserGroup{},
|
||||
&domain.ApiKey{},
|
||||
&domain.IdentityProviderConfig{},
|
||||
|
||||
@@ -15,6 +15,42 @@ where metadata ? 'hanmacFamily'
|
||||
or metadata ? 'userType'
|
||||
`
|
||||
|
||||
const canonicalizeUserAppointmentTenantsSQL = `
|
||||
with normalized as (
|
||||
select
|
||||
u.id,
|
||||
jsonb_agg(
|
||||
case
|
||||
when jsonb_typeof(item.value) = 'object' and t.id is not null then
|
||||
item.value || jsonb_build_object(
|
||||
'tenantId', t.id::text,
|
||||
'tenantSlug', t.slug,
|
||||
'tenantName', t.name
|
||||
)
|
||||
else item.value
|
||||
end
|
||||
order by item.ordinality
|
||||
) as appointments
|
||||
from users u
|
||||
cross join lateral jsonb_array_elements(u.metadata -> 'additionalAppointments') with ordinality as item(value, ordinality)
|
||||
left join tenants t on t.id::text = item.value ->> 'tenantId'
|
||||
and t.deleted_at is null
|
||||
where jsonb_typeof(u.metadata -> 'additionalAppointments') = 'array'
|
||||
group by u.id
|
||||
),
|
||||
changed as (
|
||||
select u.id, normalized.appointments
|
||||
from users u
|
||||
join normalized on normalized.id = u.id
|
||||
where u.metadata -> 'additionalAppointments' is distinct from normalized.appointments
|
||||
)
|
||||
update users u
|
||||
set metadata = jsonb_set(u.metadata, '{additionalAppointments}', changed.appointments, true),
|
||||
updated_at = now()
|
||||
from changed
|
||||
where changed.id = u.id
|
||||
`
|
||||
|
||||
// SanitizeLegacyUserMetadata removes legacy UI classification flags from Baron user metadata.
|
||||
func SanitizeLegacyUserMetadata(db *gorm.DB) error {
|
||||
if db == nil {
|
||||
@@ -32,3 +68,21 @@ func SanitizeLegacyUserMetadata(db *gorm.DB) error {
|
||||
slog.Info("[Bootstrap] Legacy user metadata sanitized", "rowsAffected", result.RowsAffected)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CanonicalizeUserAppointmentTenants rewrites appointment display fields from the tenant UUID source.
|
||||
func CanonicalizeUserAppointmentTenants(db *gorm.DB) error {
|
||||
if db == nil {
|
||||
return fmt.Errorf("database is not configured")
|
||||
}
|
||||
if !db.Migrator().HasTable("users") || !db.Migrator().HasTable("tenants") {
|
||||
slog.Info("[Bootstrap] User appointment tenant canonicalization skipped because required tables do not exist")
|
||||
return nil
|
||||
}
|
||||
|
||||
result := db.Exec(canonicalizeUserAppointmentTenantsSQL)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("canonicalize user appointment tenants: %w", result.Error)
|
||||
}
|
||||
slog.Info("[Bootstrap] User appointment tenant metadata canonicalized", "rowsAffected", result.RowsAffected)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -114,6 +114,89 @@ func TestCanonicalizeLegacyUserStatuses(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalizeUserAppointmentTenantsUsesTenantUUID(t *testing.T) {
|
||||
db := openBootstrapPostgresTestDB(t)
|
||||
if err := db.AutoMigrate(&domain.Tenant{}, &domain.User{}); err != nil {
|
||||
t.Fatalf("failed to migrate users and tenants tables: %v", err)
|
||||
}
|
||||
|
||||
tenant := domain.Tenant{
|
||||
ID: "30000000-0000-0000-0000-000000000101",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
Name: "통합시스템",
|
||||
Slug: "intigrated-system",
|
||||
Status: domain.TenantStatusActive,
|
||||
}
|
||||
if err := db.Create(&tenant).Error; err != nil {
|
||||
t.Fatalf("failed to create tenant: %v", err)
|
||||
}
|
||||
|
||||
user := domain.User{
|
||||
ID: "30000000-0000-0000-0000-000000000201",
|
||||
Email: "appointment@example.com",
|
||||
Name: "Appointment User",
|
||||
Role: domain.RoleUser,
|
||||
Status: domain.UserStatusActive,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": tenant.ID,
|
||||
"tenantSlug": "tech-planning",
|
||||
"tenantName": "기술기획",
|
||||
"grade": "연구원",
|
||||
},
|
||||
map[string]any{
|
||||
"tenantId": "30000000-0000-0000-0000-000000000999",
|
||||
"tenantSlug": "unknown-old",
|
||||
"tenantName": "Unknown Old",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
|
||||
if err := CanonicalizeUserAppointmentTenants(db); err != nil {
|
||||
t.Fatalf("CanonicalizeUserAppointmentTenants returned error: %v", err)
|
||||
}
|
||||
if err := CanonicalizeUserAppointmentTenants(db); err != nil {
|
||||
t.Fatalf("CanonicalizeUserAppointmentTenants must be idempotent: %v", err)
|
||||
}
|
||||
|
||||
var got domain.User
|
||||
if err := db.First(&got, "id = ?", user.ID).Error; err != nil {
|
||||
t.Fatalf("failed to load canonicalized user: %v", err)
|
||||
}
|
||||
appointments, ok := got.Metadata["additionalAppointments"].([]any)
|
||||
if !ok || len(appointments) != 2 {
|
||||
t.Fatalf("additionalAppointments = %#v, want two appointments", got.Metadata["additionalAppointments"])
|
||||
}
|
||||
first, ok := appointments[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("first appointment = %#v, want object", appointments[0])
|
||||
}
|
||||
if first["tenantId"] != tenant.ID {
|
||||
t.Fatalf("tenantId = %#v, want %s", first["tenantId"], tenant.ID)
|
||||
}
|
||||
if first["tenantSlug"] != tenant.Slug {
|
||||
t.Fatalf("tenantSlug = %#v, want %s", first["tenantSlug"], tenant.Slug)
|
||||
}
|
||||
if first["tenantName"] != tenant.Name {
|
||||
t.Fatalf("tenantName = %#v, want %s", first["tenantName"], tenant.Name)
|
||||
}
|
||||
if first["grade"] != "연구원" {
|
||||
t.Fatalf("grade = %#v, want preserved value", first["grade"])
|
||||
}
|
||||
second, ok := appointments[1].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("second appointment = %#v, want object", appointments[1])
|
||||
}
|
||||
if second["tenantSlug"] != "unknown-old" || second["tenantName"] != "Unknown Old" {
|
||||
t.Fatalf("unknown tenant appointment must be preserved: %#v", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSanitizesLegacyUserMetadata(t *testing.T) {
|
||||
db := openBootstrapPostgresTestDB(t)
|
||||
if err := db.AutoMigrate(&domain.User{}); err != nil {
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
UserProjectionNameKratos = "kratos_users"
|
||||
|
||||
UserProjectionStatusSyncing = "syncing"
|
||||
UserProjectionStatusReady = "ready"
|
||||
UserProjectionStatusFailed = "failed"
|
||||
)
|
||||
|
||||
type UserProjectionState struct {
|
||||
Name string `gorm:"primaryKey;column:name" json:"name"`
|
||||
Status string `gorm:"column:status;not null" json:"status"`
|
||||
LastSyncedAt *time.Time `gorm:"column:last_synced_at" json:"lastSyncedAt,omitempty"`
|
||||
LastError string `gorm:"column:last_error;type:text" json:"lastError,omitempty"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
type UserProjectionStatus struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Ready bool `json:"ready"`
|
||||
LastSyncedAt *time.Time `json:"lastSyncedAt,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||
ProjectedUsers int64 `json:"projectedUsers"`
|
||||
}
|
||||
@@ -24,16 +24,15 @@ type identityCacheAdmin interface {
|
||||
}
|
||||
|
||||
type AdminHandler struct {
|
||||
DB *gorm.DB
|
||||
Keto service.KetoService
|
||||
KetoOutbox repository.KetoOutboxRepository
|
||||
RPUsageQueries domain.RPUsageQueryRepository
|
||||
TenantRepo repository.TenantRepository
|
||||
Hydra adminHydraClientLister
|
||||
AuditRepo domain.AuditRepository
|
||||
UserProjectionRepo repository.UserProjectionRepository
|
||||
IdentityCache identityCacheAdmin
|
||||
IntegrityChecker repository.DataIntegrityChecker
|
||||
DB *gorm.DB
|
||||
Keto service.KetoService
|
||||
KetoOutbox repository.KetoOutboxRepository
|
||||
RPUsageQueries domain.RPUsageQueryRepository
|
||||
TenantRepo repository.TenantRepository
|
||||
Hydra adminHydraClientLister
|
||||
AuditRepo domain.AuditRepository
|
||||
IdentityCache identityCacheAdmin
|
||||
IntegrityChecker repository.DataIntegrityChecker
|
||||
}
|
||||
|
||||
const globalCustomClaimsSettingKey = "global_custom_claim_definitions"
|
||||
@@ -289,20 +288,6 @@ func requireSuperAdminProfile(c *fiber.Ctx) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetUserProjectionStatus(c *fiber.Ctx) error {
|
||||
if !requireSuperAdminProfile(c) {
|
||||
return nil
|
||||
}
|
||||
if h == nil || h.UserProjectionRepo == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "user projection service unavailable"})
|
||||
}
|
||||
status, err := h.UserProjectionRepo.GetStatus(c.Context())
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(status)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetOrySSOTSystemStatus(c *fiber.Ctx) error {
|
||||
if !requireSuperAdminProfile(c) {
|
||||
return nil
|
||||
@@ -428,14 +413,14 @@ func (h *AdminHandler) countTenants(ctx context.Context) int64 {
|
||||
}
|
||||
|
||||
func (h *AdminHandler) countUsers(ctx context.Context) int64 {
|
||||
if h == nil || h.UserProjectionRepo == nil {
|
||||
if h == nil || h.DB == nil {
|
||||
return 0
|
||||
}
|
||||
status, err := h.UserProjectionRepo.GetStatus(ctx)
|
||||
if err != nil {
|
||||
var total int64
|
||||
if err := h.DB.WithContext(ctx).Model(&domain.User{}).Count(&total).Error; err != nil {
|
||||
return 0
|
||||
}
|
||||
return status.ProjectedUsers
|
||||
return total
|
||||
}
|
||||
|
||||
func (h *AdminHandler) countOIDCClients(ctx context.Context) int64 {
|
||||
|
||||
@@ -65,34 +65,6 @@ func (f *fakeOverviewAuditRepo) CountEventsSince(ctx context.Context, since time
|
||||
return f.count, nil
|
||||
}
|
||||
|
||||
type fakeAdminUserProjectionRepo struct {
|
||||
status domain.UserProjectionStatus
|
||||
}
|
||||
|
||||
func (f *fakeAdminUserProjectionRepo) IsReady(ctx context.Context) (bool, error) {
|
||||
return f.status.Ready, nil
|
||||
}
|
||||
|
||||
func (f *fakeAdminUserProjectionRepo) CountTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeAdminUserProjectionRepo) CountTenantMembersRecursive(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeAdminUserProjectionRepo) ReplaceAllFromKratos(ctx context.Context, users []domain.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeAdminUserProjectionRepo) MarkFailed(ctx context.Context, syncErr error) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeAdminUserProjectionRepo) GetStatus(ctx context.Context) (domain.UserProjectionStatus, error) {
|
||||
return f.status, nil
|
||||
}
|
||||
|
||||
type fakeIdentityCacheAdmin struct {
|
||||
status domain.IdentityCacheStatus
|
||||
flush domain.IdentityCacheFlushResult
|
||||
@@ -157,58 +129,6 @@ func TestAdminHandler_GetRPUsageDaily(t *testing.T) {
|
||||
require.Equal(t, uint64(12), body.Items[0].LoginRequests)
|
||||
}
|
||||
|
||||
func TestAdminHandler_UserProjectionStatusRequiresSuperAdmin(t *testing.T) {
|
||||
h := &AdminHandler{
|
||||
UserProjectionRepo: &fakeAdminUserProjectionRepo{
|
||||
status: domain.UserProjectionStatus{Name: domain.UserProjectionNameKratos, Status: domain.UserProjectionStatusReady, Ready: true},
|
||||
},
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "tenant-admin", Role: "tenant_admin"})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/admin/projections/users", h.GetUserProjectionStatus)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/projections/users", nil)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestAdminHandler_UserProjectionStatusReturnsProjectionStateForSuperAdmin(t *testing.T) {
|
||||
syncedAt := time.Date(2026, 5, 11, 3, 0, 0, 0, time.UTC)
|
||||
h := &AdminHandler{
|
||||
UserProjectionRepo: &fakeAdminUserProjectionRepo{
|
||||
status: domain.UserProjectionStatus{
|
||||
Name: domain.UserProjectionNameKratos,
|
||||
Status: domain.UserProjectionStatusReady,
|
||||
Ready: true,
|
||||
LastSyncedAt: &syncedAt,
|
||||
ProjectedUsers: 152,
|
||||
},
|
||||
},
|
||||
}
|
||||
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/projections/users", h.GetUserProjectionStatus)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/projections/users", nil)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body domain.UserProjectionStatus
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
require.Equal(t, domain.UserProjectionNameKratos, body.Name)
|
||||
require.Equal(t, domain.UserProjectionStatusReady, body.Status)
|
||||
require.True(t, body.Ready)
|
||||
require.Equal(t, int64(152), body.ProjectedUsers)
|
||||
}
|
||||
|
||||
func TestAdminHandler_GetOrySSOTSystemStatusReturnsIdentityCacheOnly(t *testing.T) {
|
||||
syncedAt := time.Date(2026, 5, 11, 3, 0, 0, 0, time.UTC)
|
||||
cache := &fakeIdentityCacheAdmin{
|
||||
@@ -237,11 +157,9 @@ func TestAdminHandler_GetOrySSOTSystemStatusReturnsIdentityCacheOnly(t *testing.
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body struct {
|
||||
UserProjection *domain.UserProjectionStatus `json:"userProjection,omitempty"`
|
||||
IdentityCache domain.IdentityCacheStatus `json:"identityCache"`
|
||||
IdentityCache domain.IdentityCacheStatus `json:"identityCache"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
require.Nil(t, body.UserProjection)
|
||||
require.True(t, body.IdentityCache.RedisReady)
|
||||
require.Equal(t, int64(151), body.IdentityCache.ObservedCount)
|
||||
require.Equal(t, int64(153), body.IdentityCache.KeyCount)
|
||||
@@ -305,14 +223,6 @@ func TestAdminHandler_GetSystemStatsIncludesOverviewMetrics(t *testing.T) {
|
||||
auditRepo := &fakeOverviewAuditRepo{count: 22}
|
||||
h := &AdminHandler{
|
||||
AuditRepo: auditRepo,
|
||||
UserProjectionRepo: &fakeAdminUserProjectionRepo{
|
||||
status: domain.UserProjectionStatus{
|
||||
Name: domain.UserProjectionNameKratos,
|
||||
Status: domain.UserProjectionStatusReady,
|
||||
Ready: true,
|
||||
ProjectedUsers: 152,
|
||||
},
|
||||
},
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/admin/stats", h.GetSystemStats)
|
||||
@@ -328,7 +238,7 @@ func TestAdminHandler_GetSystemStatsIncludesOverviewMetrics(t *testing.T) {
|
||||
require.Contains(t, body, "totalUsers")
|
||||
require.Contains(t, body, "oidcClients")
|
||||
require.Contains(t, body, "auditEvents24h")
|
||||
require.Equal(t, float64(152), body["totalUsers"])
|
||||
require.Equal(t, float64(0), body["totalUsers"])
|
||||
require.Equal(t, float64(22), body["auditEvents24h"])
|
||||
require.Equal(t, time.UTC, auditRepo.since.Location())
|
||||
}
|
||||
|
||||
@@ -103,7 +103,6 @@ type AuthHandler struct {
|
||||
KetoService service.KetoService
|
||||
KetoOutboxRepo repository.KetoOutboxRepository
|
||||
UserRepo repository.UserRepository
|
||||
UserProjectionRepo repository.UserProjectionRepository
|
||||
ConsentRepo repository.ClientConsentRepository
|
||||
RPUserMetadataRepo repository.RPUserMetadataRepository
|
||||
RPUsageSink domain.RPUsageEventSink
|
||||
@@ -860,7 +859,6 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
||||
|
||||
if err := h.UserRepo.Update(ctx, u); err != nil {
|
||||
slog.Error("[Signup] Failed to sync user to Read-Model (Local DB)", "email", u.Email, "error", err)
|
||||
markUserProjectionFailed(ctx, h.UserProjectionRepo, err)
|
||||
} else {
|
||||
slog.Debug("[Signup] Synced user to Read-Model", "email", u.Email)
|
||||
|
||||
@@ -870,7 +868,6 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
||||
}
|
||||
if err := h.UserRepo.UpdateUserLoginIDs(ctx, u.ID, ids); err != nil {
|
||||
slog.Error("[Signup] Failed to update user login IDs", "userID", u.ID, "error", err)
|
||||
markUserProjectionFailed(ctx, h.UserProjectionRepo, err)
|
||||
}
|
||||
|
||||
// [Keto] Sync user-tenant relationship via Outbox
|
||||
@@ -8120,7 +8117,6 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error {
|
||||
ctx := context.Background()
|
||||
if err := h.syncUpdatedKratosUserReadModel(ctx, identityID, traits); err != nil {
|
||||
slog.Error("[UpdateMe] Failed to sync local user read-model", "userID", identityID, "error", err)
|
||||
markUserProjectionFailed(ctx, h.UserProjectionRepo, err)
|
||||
}
|
||||
if err := h.UserRepo.UpdateUserLoginIDs(ctx, identityID, loginIDRecords); err != nil {
|
||||
slog.Error("[UpdateMe] Failed to update user login IDs", "userID", identityID, "error", err)
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -28,7 +30,13 @@ type hanmacEmailEvaluation struct {
|
||||
LocalPart string
|
||||
}
|
||||
|
||||
func (h *UserHandler) evaluateHanmacImportEmail(ctx context.Context, item bulkUserItem, scope *hanmacEmailScope, usedLocalParts map[string]bool) hanmacEmailEvaluation {
|
||||
type hanmacLocalPartOwner struct {
|
||||
UserID string
|
||||
Email string
|
||||
Name string
|
||||
}
|
||||
|
||||
func (h *UserHandler) evaluateHanmacImportEmail(ctx context.Context, item bulkUserItem, scope *hanmacEmailScope, usedLocalParts map[string]hanmacLocalPartOwner) hanmacEmailEvaluation {
|
||||
originalEmail := strings.TrimSpace(item.Email)
|
||||
name := strings.TrimSpace(item.Name)
|
||||
evaluation := hanmacEmailEvaluation{
|
||||
@@ -68,9 +76,9 @@ func (h *UserHandler) evaluateHanmacImportEmail(ctx context.Context, item bulkUs
|
||||
}
|
||||
|
||||
evaluation.LocalPart = localPart
|
||||
if usedLocalParts[localPart] {
|
||||
if owner, exists := usedLocalParts[localPart]; exists {
|
||||
evaluation.Status = "blockingError"
|
||||
evaluation.Message = "한맥가족 내에서 이미 사용 중인 이메일 ID입니다."
|
||||
evaluation.Message = formatHanmacLocalPartConflictMessage(localPart, owner)
|
||||
evaluation.Blocking = true
|
||||
return evaluation
|
||||
}
|
||||
@@ -88,6 +96,14 @@ func (h *UserHandler) evaluateHanmacImportEmail(ctx context.Context, item bulkUs
|
||||
}
|
||||
|
||||
func (h *UserHandler) ensureHanmacCreateEmailAllowed(ctx context.Context, email string, tenantSlug string, tenantID string) error {
|
||||
return h.ensureHanmacEmailAllowedWithLog(ctx, email, tenantSlug, tenantID, "", "hanmac create email local-part conflict")
|
||||
}
|
||||
|
||||
func (h *UserHandler) ensureHanmacEmailAllowed(ctx context.Context, email string, tenantSlug string, tenantID string, currentUserID string) error {
|
||||
return h.ensureHanmacEmailAllowedWithLog(ctx, email, tenantSlug, tenantID, currentUserID, "hanmac email local-part conflict")
|
||||
}
|
||||
|
||||
func (h *UserHandler) ensureHanmacEmailAllowedWithLog(ctx context.Context, email string, tenantSlug string, tenantID string, currentUserID string, logMessage string) error {
|
||||
scope, err := h.resolveHanmacEmailScope(ctx)
|
||||
if err != nil || scope == nil || !scope.ContainsTenant(tenantID, tenantSlug) {
|
||||
return nil
|
||||
@@ -102,8 +118,22 @@ func (h *UserHandler) ensureHanmacCreateEmailAllowed(ctx context.Context, email
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if usedLocalParts[localPart] {
|
||||
return fmt.Errorf("한맥가족 내에서 이미 사용 중인 이메일 ID입니다.")
|
||||
if owner, exists := usedLocalParts[localPart]; exists {
|
||||
ownerUserID := strings.TrimSpace(owner.UserID)
|
||||
if currentUserID != "" && ownerUserID != "" && ownerUserID == strings.TrimSpace(currentUserID) {
|
||||
return nil
|
||||
}
|
||||
slog.Warn(
|
||||
logMessage,
|
||||
"requestedEmail", email,
|
||||
"localPart", localPart,
|
||||
"ownerUserID", owner.UserID,
|
||||
"ownerEmail", owner.Email,
|
||||
"ownerName", owner.Name,
|
||||
"tenantID", tenantID,
|
||||
"tenantSlug", tenantSlug,
|
||||
)
|
||||
return fmt.Errorf("%s", formatHanmacLocalPartConflictMessage(localPart, owner))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -149,8 +179,8 @@ func (h *UserHandler) resolveHanmacEmailScope(ctx context.Context) (*hanmacEmail
|
||||
return scope, nil
|
||||
}
|
||||
|
||||
func (h *UserHandler) loadHanmacLocalParts(ctx context.Context, scope *hanmacEmailScope) (map[string]bool, error) {
|
||||
used := make(map[string]bool)
|
||||
func (h *UserHandler) loadHanmacLocalParts(ctx context.Context, scope *hanmacEmailScope) (map[string]hanmacLocalPartOwner, error) {
|
||||
used := make(map[string]hanmacLocalPartOwner)
|
||||
if h.UserRepo == nil || scope == nil {
|
||||
return used, nil
|
||||
}
|
||||
@@ -160,7 +190,7 @@ func (h *UserHandler) loadHanmacLocalParts(ctx context.Context, scope *hanmacEma
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addUserEmailLocalParts(used, users)
|
||||
addUserEmailLocalPartOwners(used, users)
|
||||
}
|
||||
|
||||
if len(scope.SlugList) > 0 {
|
||||
@@ -168,7 +198,7 @@ func (h *UserHandler) loadHanmacLocalParts(ctx context.Context, scope *hanmacEma
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addUserEmailLocalParts(used, users)
|
||||
addUserEmailLocalPartOwners(used, users)
|
||||
}
|
||||
|
||||
return used, nil
|
||||
@@ -210,31 +240,79 @@ func isTenantDescendantOf(tenant domain.Tenant, rootID string, tenantByID map[st
|
||||
return false
|
||||
}
|
||||
|
||||
func addUserEmailLocalParts(target map[string]bool, users []domain.User) {
|
||||
func addUserEmailLocalPartOwners(target map[string]hanmacLocalPartOwner, users []domain.User) {
|
||||
for _, user := range users {
|
||||
localPart, err := domain.ExtractNormalizedEmailLocalPart(user.Email)
|
||||
if err == nil && localPart != "" {
|
||||
target[localPart] = true
|
||||
if err != nil || localPart == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := target[localPart]; exists {
|
||||
continue
|
||||
}
|
||||
target[localPart] = hanmacLocalPartOwner{
|
||||
UserID: strings.TrimSpace(user.ID),
|
||||
Email: strings.TrimSpace(user.Email),
|
||||
Name: strings.TrimSpace(user.Name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func nextAvailableHanmacLocalPart(base string, usedLocalParts map[string]bool) string {
|
||||
func formatHanmacLocalPartConflictMessage(localPart string, owner hanmacLocalPartOwner) string {
|
||||
message := fmt.Sprintf("한맥가족 내에서 이미 사용 중인 이메일 ID입니다. local-part=%s", strings.TrimSpace(localPart))
|
||||
if owner.Email != "" {
|
||||
message += ", 사용 계정=" + owner.Email
|
||||
}
|
||||
if owner.Name != "" {
|
||||
message += ", 사용자=" + owner.Name
|
||||
}
|
||||
if owner.UserID != "" {
|
||||
message += ", 사용자 ID=" + owner.UserID
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
func nextAvailableHanmacLocalPart(base string, usedLocalParts map[string]hanmacLocalPartOwner) string {
|
||||
base = strings.ToLower(strings.TrimSpace(base))
|
||||
if base == "" {
|
||||
return ""
|
||||
}
|
||||
if !usedLocalParts[base] {
|
||||
if _, exists := usedLocalParts[base]; !exists {
|
||||
return base
|
||||
}
|
||||
for index := 1; ; index++ {
|
||||
candidate := fmt.Sprintf("%s%d", base, index)
|
||||
if !usedLocalParts[candidate] {
|
||||
|
||||
stem, nextIndex := splitTrailingNumericSuffix(base)
|
||||
if stem == "" {
|
||||
stem = base
|
||||
}
|
||||
for index := nextIndex; ; index++ {
|
||||
candidate := fmt.Sprintf("%s%d", stem, index)
|
||||
if _, exists := usedLocalParts[candidate]; !exists {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func splitTrailingNumericSuffix(value string) (string, int) {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
return "", 1
|
||||
}
|
||||
index := len(value)
|
||||
for index > 0 && value[index-1] >= '0' && value[index-1] <= '9' {
|
||||
index--
|
||||
}
|
||||
if index == len(value) {
|
||||
return value, 1
|
||||
}
|
||||
stem := value[:index]
|
||||
suffix := value[index:]
|
||||
number, err := strconv.Atoi(suffix)
|
||||
if err != nil {
|
||||
return value, 1
|
||||
}
|
||||
return stem, number + 1
|
||||
}
|
||||
|
||||
func appendUniqueString(values []string, value string) []string {
|
||||
if slices.Contains(values, value) {
|
||||
return values
|
||||
|
||||
66
backend/internal/handler/internal_domain_personal_policy.go
Normal file
66
backend/internal/handler/internal_domain_personal_policy.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var internalEmailDomainsDisallowedForPersonal = map[string]bool{
|
||||
"brsw.kr": true,
|
||||
"hanmaceng.co.kr": true,
|
||||
"samaneng.com": true,
|
||||
"hallasanup.com": true,
|
||||
"jangheon.co.kr": true,
|
||||
"jangheon.com": true,
|
||||
"pre-cast.co.kr": true,
|
||||
}
|
||||
|
||||
func internalDomainPersonalPolicyMessage(email string) string {
|
||||
return fmt.Sprintf("내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다: %s", strings.ToLower(strings.TrimSpace(email)))
|
||||
}
|
||||
|
||||
func emailUsesInternalPersonalRestrictedDomain(email string) bool {
|
||||
_, domainPart, err := domain.SplitEmailDomain(email)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return internalEmailDomainsDisallowedForPersonal[strings.ToLower(strings.TrimSpace(domainPart))]
|
||||
}
|
||||
|
||||
func isPersonalTenantForInternalDomainPolicy(tenant *domain.Tenant) bool {
|
||||
if tenant == nil {
|
||||
return false
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(tenant.Type), domain.TenantTypePersonal) {
|
||||
return true
|
||||
}
|
||||
slug := strings.ToLower(strings.TrimSpace(tenant.Slug))
|
||||
return slug == "personal" || strings.HasPrefix(slug, "personal-")
|
||||
}
|
||||
|
||||
func (h *UserHandler) ensureInternalDomainNotAssignedToPersonal(ctx context.Context, email string, tenantID string, tenantSlug string, resolvedTenant *domain.Tenant) error {
|
||||
if !emailUsesInternalPersonalRestrictedDomain(email) {
|
||||
return nil
|
||||
}
|
||||
tenant := resolvedTenant
|
||||
if tenant == nil && h.TenantService != nil {
|
||||
if id := strings.TrimSpace(tenantID); id != "" {
|
||||
if found, err := h.TenantService.GetTenant(ctx, id); err == nil && found != nil {
|
||||
tenant = found
|
||||
}
|
||||
}
|
||||
if tenant == nil {
|
||||
if slug := strings.TrimSpace(tenantSlug); slug != "" {
|
||||
if found, err := h.TenantService.GetTenantBySlug(ctx, slug); err == nil && found != nil {
|
||||
tenant = found
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if isPersonalTenantForInternalDomainPolicy(tenant) {
|
||||
return fmt.Errorf("%s", internalDomainPersonalPolicyMessage(email))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -16,10 +16,8 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"os"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -29,25 +27,28 @@ import (
|
||||
)
|
||||
|
||||
type TenantHandler struct {
|
||||
DB *gorm.DB
|
||||
Service service.TenantService
|
||||
UserRepo repository.UserRepository
|
||||
UserProjectionRepo repository.UserProjectionRepository
|
||||
OrgChartCache orgChartCacheStore
|
||||
Keto service.KetoService
|
||||
KetoOutbox repository.KetoOutboxRepository
|
||||
KratosAdmin service.KratosAdminService
|
||||
SharedLink service.SharedLinkService
|
||||
Worksmobile service.WorksmobileSyncer
|
||||
Hydra *service.HydraAdminService
|
||||
ConsentRepo repository.ClientConsentRepository
|
||||
DB *gorm.DB
|
||||
Service service.TenantService
|
||||
UserRepo repository.UserRepository
|
||||
OrgChartCache orgChartCacheStore
|
||||
IdentityCache domain.RedisRepository
|
||||
Keto service.KetoService
|
||||
KetoOutbox repository.KetoOutboxRepository
|
||||
KratosAdmin service.KratosAdminService
|
||||
SharedLink service.SharedLinkService
|
||||
Worksmobile service.WorksmobileSyncer
|
||||
Hydra *service.HydraAdminService
|
||||
ConsentRepo repository.ClientConsentRepository
|
||||
}
|
||||
|
||||
type orgChartCacheStore interface {
|
||||
Get(key string) (string, error)
|
||||
Set(key string, value string, expiration time.Duration) error
|
||||
DeleteByPrefix(ctx context.Context, prefix string) (int64, error)
|
||||
}
|
||||
|
||||
const orgChartSnapshotCacheKeyPrefix = "orgchart:snapshot:v1:"
|
||||
|
||||
func seedTenantDeleteError(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusConflict, "seed tenants cannot be deleted")
|
||||
}
|
||||
@@ -65,18 +66,17 @@ func seedTenantSlugsForDeleteGuard() []string {
|
||||
return result
|
||||
}
|
||||
|
||||
func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repository.UserRepository, userProjectionRepo repository.UserProjectionRepository, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService, sharedLink service.SharedLinkService, hydra *service.HydraAdminService, consentRepo repository.ClientConsentRepository) *TenantHandler {
|
||||
func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repository.UserRepository, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService, sharedLink service.SharedLinkService, hydra *service.HydraAdminService, consentRepo repository.ClientConsentRepository) *TenantHandler {
|
||||
return &TenantHandler{
|
||||
DB: db,
|
||||
Service: svc,
|
||||
UserRepo: userRepo,
|
||||
UserProjectionRepo: userProjectionRepo,
|
||||
Keto: keto,
|
||||
KetoOutbox: outbox,
|
||||
KratosAdmin: kratos,
|
||||
SharedLink: sharedLink,
|
||||
Hydra: hydra,
|
||||
ConsentRepo: consentRepo,
|
||||
DB: db,
|
||||
Service: svc,
|
||||
UserRepo: userRepo,
|
||||
Keto: keto,
|
||||
KetoOutbox: outbox,
|
||||
KratosAdmin: kratos,
|
||||
SharedLink: sharedLink,
|
||||
Hydra: hydra,
|
||||
ConsentRepo: consentRepo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,6 +263,7 @@ func (h *TenantHandler) ApproveTenant(c *fiber.Ctx) error {
|
||||
if err := h.Service.ApproveTenant(c.Context(), tenantID); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
h.refreshOrgChartSnapshotCacheAfterTenantChange(c.Context(), "tenant_approved")
|
||||
|
||||
return c.JSON(fiber.Map{"message": "Tenant approved successfully"})
|
||||
}
|
||||
@@ -311,12 +312,25 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
parentMap := make(map[string]string)
|
||||
tenantIDs := make(map[string]bool, len(allTenants))
|
||||
for _, t := range allTenants {
|
||||
tenantIDs[t.ID] = true
|
||||
if t.ParentID != nil {
|
||||
parentMap[t.ID] = *t.ParentID
|
||||
}
|
||||
}
|
||||
|
||||
hasValidBaseTenant := false
|
||||
for _, id := range baseTenantIDs {
|
||||
if tenantIDs[strings.TrimSpace(id)] {
|
||||
hasValidBaseTenant = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(baseTenantIDs) > 0 && !hasValidBaseTenant {
|
||||
return errorJSON(c, fiber.StatusConflict, "tenant scope is not available")
|
||||
}
|
||||
|
||||
roots := make(map[string]bool)
|
||||
for _, id := range baseTenantIDs {
|
||||
roots[findTenantRootID(parentMap, id)] = true
|
||||
@@ -372,7 +386,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
memberCounts, totalMemberCounts, err := h.countTenantMembersFromProjection(c.Context(), tenants)
|
||||
memberCounts, totalMemberCounts, err := h.countTenantMembers(c.Context(), tenants)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
|
||||
}
|
||||
@@ -688,6 +702,9 @@ func (h *TenantHandler) ImportTenantsCSV(c *fiber.Ctx) error {
|
||||
}
|
||||
result.Details = append(result.Details, detail)
|
||||
}
|
||||
if result.Created > 0 || result.Updated > 0 {
|
||||
h.refreshOrgChartSnapshotCacheAfterTenantChange(c.Context(), "tenants_imported")
|
||||
}
|
||||
|
||||
return c.JSON(result)
|
||||
}
|
||||
@@ -1247,6 +1264,9 @@ func (h *TenantHandler) canViewPrivateTenant(ctx context.Context, profile *domai
|
||||
for _, relation := range []string{"view_private", "view_private_descendants", "view", "manage"} {
|
||||
allowed, err := h.Keto.CheckPermission(ctx, subject, "Tenant", privateRootID, relation)
|
||||
if err != nil {
|
||||
if isMissingKetoRelationError(err) {
|
||||
continue
|
||||
}
|
||||
return false, fmt.Errorf("private tenant permission check failed: %w", err)
|
||||
}
|
||||
if allowed {
|
||||
@@ -1257,6 +1277,9 @@ func (h *TenantHandler) canViewPrivateTenant(ctx context.Context, profile *domai
|
||||
for _, ancestorID := range tenantAncestorIDs(privateRootID, tenants) {
|
||||
allowed, err := h.Keto.CheckPermission(ctx, subject, "Tenant", ancestorID, "view_private_descendants")
|
||||
if err != nil {
|
||||
if isMissingKetoRelationError(err) {
|
||||
continue
|
||||
}
|
||||
return false, fmt.Errorf("private tenant descendant permission check failed: %w", err)
|
||||
}
|
||||
if allowed {
|
||||
@@ -1266,6 +1289,14 @@ func (h *TenantHandler) canViewPrivateTenant(ctx context.Context, profile *domai
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func isMissingKetoRelationError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
message := strings.ToLower(err.Error())
|
||||
return strings.Contains(message, "relation ") && strings.Contains(message, "does not exist")
|
||||
}
|
||||
|
||||
func profileCanManageTenantOrAncestor(profile *domain.UserProfileResponse, tenantID string, tenants []domain.Tenant) bool {
|
||||
manageableIDs := make(map[string]bool, len(profile.ManageableTenants))
|
||||
for _, tenant := range profile.ManageableTenants {
|
||||
@@ -1669,7 +1700,7 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
memberCounts, totalMemberCounts, err := h.countTenantMembersFromProjection(c.Context(), []domain.Tenant{tenant})
|
||||
memberCounts, totalMemberCounts, err := h.countTenantMembers(c.Context(), []domain.Tenant{tenant})
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
|
||||
}
|
||||
@@ -1795,6 +1826,7 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
h.refreshOrgChartSnapshotCacheAfterTenantChange(c.Context(), "tenant_created")
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(summary)
|
||||
}
|
||||
@@ -1955,6 +1987,7 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
||||
fmt.Printf("[TenantHandler] failed to enqueue Worksmobile tenant update sync: %v\n", err)
|
||||
}
|
||||
}
|
||||
h.refreshOrgChartSnapshotCacheAfterTenantChange(c.Context(), "tenant_updated")
|
||||
|
||||
return c.JSON(mapTenantSummary(tenant))
|
||||
}
|
||||
@@ -1980,6 +2013,10 @@ func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error {
|
||||
return seedTenantDeleteError(c)
|
||||
}
|
||||
|
||||
if err := h.reassignUserMembershipsBeforeTenantDelete(c.Context(), []string{tenantID}); err != nil {
|
||||
return errorJSON(c, fiber.StatusConflict, err.Error())
|
||||
}
|
||||
|
||||
if err := cleanupDeletedTenantReferences(c.Context(), h.Hydra, h.ConsentRepo, h.KetoOutbox, []string{tenantID}); err != nil {
|
||||
logTenantCleanupFailure(err, []string{tenantID})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
@@ -1999,6 +2036,7 @@ func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error {
|
||||
fmt.Printf("[TenantHandler] failed to enqueue Worksmobile tenant delete sync: %v\n", err)
|
||||
}
|
||||
}
|
||||
h.refreshOrgChartSnapshotCacheAfterTenantChange(c.Context(), "tenant_deleted")
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
@@ -2287,6 +2325,10 @@ func (h *TenantHandler) DeleteTenantsBulk(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
|
||||
if len(req.IDs) == 0 {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "no IDs provided")
|
||||
}
|
||||
req.IDs = uniqueNonEmptyStrings(req.IDs)
|
||||
if len(req.IDs) == 0 {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "no IDs provided")
|
||||
}
|
||||
@@ -2313,6 +2355,10 @@ func (h *TenantHandler) DeleteTenantsBulk(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.reassignUserMembershipsBeforeTenantDelete(c.Context(), req.IDs); err != nil {
|
||||
return errorJSON(c, fiber.StatusConflict, err.Error())
|
||||
}
|
||||
|
||||
if err := cleanupDeletedTenantReferences(c.Context(), h.Hydra, h.ConsentRepo, h.KetoOutbox, req.IDs); err != nil {
|
||||
logTenantCleanupFailure(err, req.IDs)
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
@@ -2321,6 +2367,7 @@ func (h *TenantHandler) DeleteTenantsBulk(c *fiber.Ctx) error {
|
||||
if err := h.Service.DeleteTenantsBulk(c.Context(), req.IDs); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
h.refreshOrgChartSnapshotCacheAfterTenantChange(c.Context(), "tenants_bulk_deleted")
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
||||
"message": "Tenants deleted successfully",
|
||||
@@ -2328,6 +2375,362 @@ func (h *TenantHandler) DeleteTenantsBulk(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
func uniqueNonEmptyStrings(values []string) []string {
|
||||
seen := make(map[string]bool, len(values))
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" || seen[trimmed] {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = true
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (h *TenantHandler) reassignUserMembershipsBeforeTenantDelete(ctx context.Context, tenantIDs []string) error {
|
||||
if h == nil || h.DB == nil {
|
||||
return nil
|
||||
}
|
||||
deletedIDs := uniqueNonEmptyStrings(tenantIDs)
|
||||
if len(deletedIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
affectedIDs, err := h.deletedTenantIDsReferencedByUsers(ctx, deletedIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(affectedIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var tenants []domain.Tenant
|
||||
if err := h.DB.WithContext(ctx).Unscoped().Find(&tenants).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
targets, err := resolveTenantDeletionPromotionTargets(tenants, deletedIDs, affectedIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var affectedUsers []domain.User
|
||||
if err := h.DB.WithContext(ctx).
|
||||
Where("tenant_id IN ?", affectedIDs).
|
||||
Find(&affectedUsers).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
tenantByID := make(map[string]domain.Tenant, len(tenants))
|
||||
for _, tenant := range tenants {
|
||||
tenantByID[tenant.ID] = tenant
|
||||
}
|
||||
if err := h.promoteKratosUserMembershipsForTenantDelete(ctx, affectedUsers, tenantByID, targets); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return h.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
for deletedID, targetID := range targets {
|
||||
if err := tx.Model(&domain.User{}).
|
||||
Where("tenant_id = ?", deletedID).
|
||||
Updates(map[string]any{"tenant_id": targetID, "updated_at": time.Now()}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&domain.UserLoginID{}).
|
||||
Where("tenant_id = ?", deletedID).
|
||||
Update("tenant_id", targetID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (h *TenantHandler) promoteKratosUserMembershipsForTenantDelete(ctx context.Context, users []domain.User, tenantByID map[string]domain.Tenant, targets map[string]string) error {
|
||||
if h == nil || h.KratosAdmin == nil {
|
||||
return nil
|
||||
}
|
||||
for _, user := range users {
|
||||
if user.TenantID == nil || strings.TrimSpace(user.ID) == "" {
|
||||
continue
|
||||
}
|
||||
deletedID := strings.TrimSpace(*user.TenantID)
|
||||
targetID := strings.TrimSpace(targets[deletedID])
|
||||
if deletedID == "" || targetID == "" {
|
||||
continue
|
||||
}
|
||||
deletedTenant, ok := tenantByID[deletedID]
|
||||
if !ok {
|
||||
return fmt.Errorf("deleted tenant not found while promoting user membership: %s", deletedID)
|
||||
}
|
||||
targetTenant, ok := tenantByID[targetID]
|
||||
if !ok {
|
||||
return fmt.Errorf("promotion target tenant not found while promoting user membership: %s", targetID)
|
||||
}
|
||||
identity, err := h.KratosAdmin.GetIdentity(ctx, user.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get kratos identity for tenant promotion user=%s: %w", user.ID, err)
|
||||
}
|
||||
if identity == nil {
|
||||
return fmt.Errorf("kratos identity not found for tenant promotion user=%s", user.ID)
|
||||
}
|
||||
traits, changed := promoteIdentityTraitsFromDeletedTenant(identity.Traits, deletedTenant, targetTenant, true)
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
updated, err := h.KratosAdmin.UpdateIdentity(ctx, user.ID, traits, identity.State)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update kratos identity for tenant promotion user=%s: %w", user.ID, err)
|
||||
}
|
||||
if updated == nil {
|
||||
identity.Traits = traits
|
||||
h.storePromotedIdentityMirror(*identity)
|
||||
continue
|
||||
}
|
||||
h.storePromotedIdentityMirror(*updated)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func promoteIdentityTraitsFromDeletedTenant(traits map[string]any, deletedTenant domain.Tenant, targetTenant domain.Tenant, forcePrimary bool) (map[string]any, bool) {
|
||||
next := cloneIdentityTraits(traits)
|
||||
changed := false
|
||||
primaryChanged := forcePrimary
|
||||
if traitStringEqualTenant(next["tenant_id"], deletedTenant.ID, deletedTenant.Slug) || forcePrimary {
|
||||
next["tenant_id"] = targetTenant.ID
|
||||
changed = true
|
||||
primaryChanged = true
|
||||
}
|
||||
if traitStringEqualTenant(next["primaryTenantId"], deletedTenant.ID, deletedTenant.Slug) || forcePrimary {
|
||||
next["primaryTenantId"] = targetTenant.ID
|
||||
changed = true
|
||||
primaryChanged = true
|
||||
}
|
||||
if traitStringEqualTenant(next["primaryTenantSlug"], deletedTenant.ID, deletedTenant.Slug) || forcePrimary {
|
||||
next["primaryTenantSlug"] = targetTenant.Slug
|
||||
changed = true
|
||||
primaryChanged = true
|
||||
}
|
||||
if traitStringEqualTenant(next["primaryTenantName"], deletedTenant.Name, "") || forcePrimary {
|
||||
next["primaryTenantName"] = targetTenant.Name
|
||||
changed = true
|
||||
primaryChanged = true
|
||||
}
|
||||
if traitStringEqualTenant(next["companyCode"], deletedTenant.ID, deletedTenant.Slug) {
|
||||
next["companyCode"] = targetTenant.Slug
|
||||
changed = true
|
||||
}
|
||||
if traitStringEqualTenant(next["company_code"], deletedTenant.ID, deletedTenant.Slug) {
|
||||
next["company_code"] = targetTenant.Slug
|
||||
changed = true
|
||||
}
|
||||
|
||||
appointments, appointmentsChanged := promoteIdentityAppointmentsFromDeletedTenant(next["additionalAppointments"], deletedTenant, targetTenant)
|
||||
if appointmentsChanged {
|
||||
next["additionalAppointments"] = appointments
|
||||
changed = true
|
||||
}
|
||||
if primaryChanged {
|
||||
next["primaryTenantId"] = targetTenant.ID
|
||||
next["primaryTenantSlug"] = targetTenant.Slug
|
||||
next["primaryTenantName"] = targetTenant.Name
|
||||
}
|
||||
return next, changed
|
||||
}
|
||||
|
||||
func promoteIdentityAppointmentsFromDeletedTenant(raw any, deletedTenant domain.Tenant, targetTenant domain.Tenant) ([]any, bool) {
|
||||
switch appointments := raw.(type) {
|
||||
case []any:
|
||||
next := make([]any, 0, len(appointments))
|
||||
changed := false
|
||||
for _, rawAppointment := range appointments {
|
||||
appointment, ok := rawAppointment.(map[string]any)
|
||||
if !ok {
|
||||
next = append(next, rawAppointment)
|
||||
continue
|
||||
}
|
||||
copied := maps.Clone(appointment)
|
||||
if identityAppointmentMatchesTenant(copied, deletedTenant) {
|
||||
copied["tenantId"] = targetTenant.ID
|
||||
copied["tenantSlug"] = targetTenant.Slug
|
||||
copied["tenantName"] = targetTenant.Name
|
||||
changed = true
|
||||
}
|
||||
next = append(next, copied)
|
||||
}
|
||||
return next, changed
|
||||
case []map[string]any:
|
||||
next := make([]any, 0, len(appointments))
|
||||
changed := false
|
||||
for _, appointment := range appointments {
|
||||
copied := maps.Clone(appointment)
|
||||
if identityAppointmentMatchesTenant(copied, deletedTenant) {
|
||||
copied["tenantId"] = targetTenant.ID
|
||||
copied["tenantSlug"] = targetTenant.Slug
|
||||
copied["tenantName"] = targetTenant.Name
|
||||
changed = true
|
||||
}
|
||||
next = append(next, copied)
|
||||
}
|
||||
return next, changed
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func identityAppointmentMatchesTenant(appointment map[string]any, tenant domain.Tenant) bool {
|
||||
return traitStringEqualTenant(appointment["tenantId"], tenant.ID, tenant.Slug) ||
|
||||
traitStringEqualTenant(appointment["tenantSlug"], tenant.ID, tenant.Slug) ||
|
||||
traitStringEqualTenant(appointment["tenantName"], tenant.Name, "")
|
||||
}
|
||||
|
||||
func traitStringEqualTenant(value any, id string, slug string) bool {
|
||||
raw := strings.TrimSpace(fmt.Sprint(value))
|
||||
if raw == "" || raw == "<nil>" {
|
||||
return false
|
||||
}
|
||||
if strings.EqualFold(raw, strings.TrimSpace(id)) {
|
||||
return true
|
||||
}
|
||||
return strings.TrimSpace(slug) != "" && strings.EqualFold(raw, strings.TrimSpace(slug))
|
||||
}
|
||||
|
||||
func cloneIdentityTraits(traits map[string]any) map[string]any {
|
||||
if traits == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
next := make(map[string]any, len(traits))
|
||||
for key, value := range traits {
|
||||
next[key] = cloneIdentityTraitValue(value)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func cloneIdentityTraitValue(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
next := make(map[string]any, len(typed))
|
||||
for key, nested := range typed {
|
||||
next[key] = cloneIdentityTraitValue(nested)
|
||||
}
|
||||
return next
|
||||
case []any:
|
||||
next := make([]any, len(typed))
|
||||
for i, nested := range typed {
|
||||
next[i] = cloneIdentityTraitValue(nested)
|
||||
}
|
||||
return next
|
||||
case []map[string]any:
|
||||
next := make([]any, len(typed))
|
||||
for i, nested := range typed {
|
||||
next[i] = cloneIdentityTraitValue(nested)
|
||||
}
|
||||
return next
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func (h *TenantHandler) storePromotedIdentityMirror(identity service.KratosIdentity) {
|
||||
if h == nil || h.IdentityCache == nil || strings.TrimSpace(identity.ID) == "" {
|
||||
return
|
||||
}
|
||||
raw, err := json.Marshal(identity)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = h.IdentityCache.Set(identityMirrorKey(identity.ID), string(raw), 0)
|
||||
_ = h.IdentityCache.Delete("identity:mirror:state")
|
||||
}
|
||||
|
||||
func (h *TenantHandler) deletedTenantIDsReferencedByUsers(ctx context.Context, tenantIDs []string) ([]string, error) {
|
||||
referenced := make(map[string]bool)
|
||||
|
||||
var userTenantIDs []string
|
||||
if err := h.DB.WithContext(ctx).
|
||||
Model(&domain.User{}).
|
||||
Where("tenant_id IN ?", tenantIDs).
|
||||
Distinct("tenant_id").
|
||||
Pluck("tenant_id", &userTenantIDs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, tenantID := range userTenantIDs {
|
||||
if tenantID != "" {
|
||||
referenced[tenantID] = true
|
||||
}
|
||||
}
|
||||
|
||||
var loginTenantIDs []string
|
||||
if err := h.DB.WithContext(ctx).
|
||||
Model(&domain.UserLoginID{}).
|
||||
Where("tenant_id IN ?", tenantIDs).
|
||||
Distinct("tenant_id").
|
||||
Pluck("tenant_id", &loginTenantIDs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, tenantID := range loginTenantIDs {
|
||||
if tenantID != "" {
|
||||
referenced[tenantID] = true
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(referenced))
|
||||
for _, tenantID := range tenantIDs {
|
||||
if referenced[tenantID] {
|
||||
result = append(result, tenantID)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func resolveTenantDeletionPromotionTargets(tenants []domain.Tenant, deletedTenantIDs []string, affectedTenantIDs []string) (map[string]string, error) {
|
||||
deleted := make(map[string]bool, len(deletedTenantIDs))
|
||||
for _, tenantID := range deletedTenantIDs {
|
||||
tenantID = strings.TrimSpace(tenantID)
|
||||
if tenantID != "" {
|
||||
deleted[tenantID] = true
|
||||
}
|
||||
}
|
||||
|
||||
tenantByID := make(map[string]domain.Tenant, len(tenants))
|
||||
for _, tenant := range tenants {
|
||||
if strings.TrimSpace(tenant.ID) != "" {
|
||||
tenantByID[tenant.ID] = tenant
|
||||
}
|
||||
}
|
||||
|
||||
targets := make(map[string]string, len(affectedTenantIDs))
|
||||
for _, affectedID := range uniqueNonEmptyStrings(affectedTenantIDs) {
|
||||
tenant, ok := tenantByID[affectedID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("tenant %s not found for membership reassignment", affectedID)
|
||||
}
|
||||
|
||||
visited := map[string]bool{affectedID: true}
|
||||
for {
|
||||
if tenant.ParentID == nil || strings.TrimSpace(*tenant.ParentID) == "" || *tenant.ParentID == tenant.ID {
|
||||
return nil, fmt.Errorf("tenant %s cannot be deleted while referenced by users because it has no remaining parent tenant", affectedID)
|
||||
}
|
||||
parentID := strings.TrimSpace(*tenant.ParentID)
|
||||
if visited[parentID] {
|
||||
return nil, fmt.Errorf("tenant %s cannot be reassigned because its parent chain has a cycle", affectedID)
|
||||
}
|
||||
visited[parentID] = true
|
||||
|
||||
parent, ok := tenantByID[parentID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("tenant %s cannot be reassigned because parent tenant %s was not found", affectedID, parentID)
|
||||
}
|
||||
if !deleted[parentID] && !parent.DeletedAt.Valid {
|
||||
targets[affectedID] = parent.ID
|
||||
break
|
||||
}
|
||||
tenant = parent
|
||||
}
|
||||
}
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
func mapTenantSummary(t domain.Tenant) tenantSummary {
|
||||
domains := make([]string, 0, len(t.Domains))
|
||||
for _, d := range t.Domains {
|
||||
@@ -2673,7 +3076,7 @@ func buildOrgContextTree(rootID string, tenants []domain.Tenant, tenantByID map[
|
||||
return build(rootID)
|
||||
}
|
||||
|
||||
func (h *TenantHandler) countTenantMembersFromProjection(ctx context.Context, tenants []domain.Tenant) (map[string]int64, map[string]int64, error) {
|
||||
func (h *TenantHandler) countTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, map[string]int64, error) {
|
||||
counts := make(map[string]int64, len(tenants))
|
||||
for _, tenant := range tenants {
|
||||
counts[tenant.ID] = 0
|
||||
@@ -2681,27 +3084,78 @@ func (h *TenantHandler) countTenantMembersFromProjection(ctx context.Context, te
|
||||
if len(tenants) == 0 {
|
||||
return counts, counts, nil
|
||||
}
|
||||
if h.UserProjectionRepo == nil {
|
||||
return nil, nil, errors.New("user projection is not configured")
|
||||
if h.UserRepo == nil {
|
||||
return counts, counts, nil
|
||||
}
|
||||
ready, err := h.UserProjectionRepo.IsReady(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("user projection status unavailable: %w", err)
|
||||
|
||||
tenantIDs := make([]string, 0, len(tenants))
|
||||
for _, tenant := range tenants {
|
||||
if strings.TrimSpace(tenant.ID) != "" {
|
||||
tenantIDs = append(tenantIDs, tenant.ID)
|
||||
}
|
||||
}
|
||||
if !ready {
|
||||
return nil, nil, errors.New("user projection is not ready")
|
||||
}
|
||||
directCounts, err := h.UserProjectionRepo.CountTenantMembers(ctx, tenants)
|
||||
|
||||
directCounts, err := h.UserRepo.CountByTenantIDs(ctx, tenantIDs)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
totalCounts, err := h.UserProjectionRepo.CountTenantMembersRecursive(ctx, tenants)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
|
||||
totalCounts := make(map[string]int64, len(tenants))
|
||||
allTenants := tenants
|
||||
if h.Service != nil {
|
||||
if listed, _, listErr := h.Service.ListTenants(ctx, 10000, 0, "", ""); listErr == nil && len(listed) > 0 {
|
||||
allTenants = listed
|
||||
}
|
||||
}
|
||||
childrenByParentID := make(map[string][]domain.Tenant)
|
||||
for _, tenant := range allTenants {
|
||||
if tenant.ParentID == nil || strings.TrimSpace(*tenant.ParentID) == "" {
|
||||
continue
|
||||
}
|
||||
childrenByParentID[*tenant.ParentID] = append(childrenByParentID[*tenant.ParentID], tenant)
|
||||
}
|
||||
for _, tenant := range tenants {
|
||||
descendantIDs := collectTenantSubtreeIDs(tenant.ID, childrenByParentID)
|
||||
if len(descendantIDs) == 0 {
|
||||
totalCounts[tenant.ID] = directCounts[tenant.ID]
|
||||
continue
|
||||
}
|
||||
_, total, _, countErr := h.UserRepo.List(ctx, 0, 1, "", descendantIDs, "")
|
||||
if countErr != nil {
|
||||
return nil, nil, countErr
|
||||
}
|
||||
totalCounts[tenant.ID] = total
|
||||
}
|
||||
return directCounts, totalCounts, nil
|
||||
}
|
||||
|
||||
func collectTenantSubtreeIDs(rootID string, childrenByParentID map[string][]domain.Tenant) []string {
|
||||
rootID = strings.TrimSpace(rootID)
|
||||
if rootID == "" {
|
||||
return nil
|
||||
}
|
||||
ids := []string{rootID}
|
||||
queue := []string{rootID}
|
||||
seen := map[string]struct{}{rootID: {}}
|
||||
for len(queue) > 0 {
|
||||
current := queue[0]
|
||||
queue = queue[1:]
|
||||
for _, child := range childrenByParentID[current] {
|
||||
childID := strings.TrimSpace(child.ID)
|
||||
if childID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[childID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[childID] = struct{}{}
|
||||
ids = append(ids, childID)
|
||||
queue = append(queue, childID)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func normalizeTenantStatus(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
@@ -2763,7 +3217,6 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
|
||||
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||
cacheMode := strings.ToLower(strings.TrimSpace(c.Query("cache")))
|
||||
cacheKey := orgChartSnapshotCacheKey(profile, c.Get("X-Tenant-ID"))
|
||||
ttl := orgChartSnapshotCacheTTL()
|
||||
role, userID, profileTenantID := orgChartProfileLogValues(profile)
|
||||
slog.Info("orgchart snapshot request started",
|
||||
"user_id", userID,
|
||||
@@ -2778,9 +3231,8 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
|
||||
var cached orgChartSnapshotResponse
|
||||
if err := json.Unmarshal([]byte(raw), &cached); err == nil {
|
||||
cached.Cache = orgChartSnapshotCacheInfo{
|
||||
Source: "redis",
|
||||
Hit: true,
|
||||
TTLSeconds: int(ttl.Seconds()),
|
||||
Source: "redis",
|
||||
Hit: true,
|
||||
}
|
||||
c.Set("X-Orgfront-Cache", "HIT")
|
||||
slog.Info("orgchart snapshot cache hit",
|
||||
@@ -2823,14 +3275,13 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
|
||||
}
|
||||
snapshot.Cache = orgChartSnapshotCacheInfo{
|
||||
Source: "database",
|
||||
Hit: false,
|
||||
TTLSeconds: int(ttl.Seconds()),
|
||||
Source: "database",
|
||||
Hit: false,
|
||||
}
|
||||
|
||||
if cacheMode == "redis" && h.OrgChartCache != nil {
|
||||
if raw, err := json.Marshal(snapshot); err == nil {
|
||||
if err := h.OrgChartCache.Set(cacheKey, string(raw), ttl); err != nil {
|
||||
if err := h.OrgChartCache.Set(cacheKey, string(raw), orgChartSnapshotCacheExpiration()); err != nil {
|
||||
slog.Warn("orgchart snapshot cache write failed",
|
||||
"user_id", userID,
|
||||
"role", role,
|
||||
@@ -2858,13 +3309,68 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
|
||||
return c.JSON(snapshot)
|
||||
}
|
||||
|
||||
func (h *TenantHandler) WarmOrgChartSnapshotCache(ctx context.Context) error {
|
||||
if h == nil || h.OrgChartCache == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
profile := &domain.UserProfileResponse{
|
||||
ID: "orgfront-cache-warmup",
|
||||
Role: domain.RoleSuperAdmin,
|
||||
}
|
||||
snapshot, err := h.buildOrgChartSnapshot(ctx, profile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
snapshot.Cache = orgChartSnapshotCacheInfo{
|
||||
Source: "database",
|
||||
Hit: false,
|
||||
}
|
||||
raw, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.OrgChartCache.Set(
|
||||
orgChartSnapshotCacheKey(profile, ""),
|
||||
string(raw),
|
||||
orgChartSnapshotCacheExpiration(),
|
||||
)
|
||||
}
|
||||
|
||||
func (h *TenantHandler) refreshOrgChartSnapshotCacheAfterTenantChange(ctx context.Context, reason string) {
|
||||
if h == nil || h.OrgChartCache == nil {
|
||||
return
|
||||
}
|
||||
deleted, err := h.OrgChartCache.DeleteByPrefix(ctx, orgChartSnapshotCacheKeyPrefix)
|
||||
if err != nil {
|
||||
slog.Warn("Orgfront orgchart snapshot cache invalidation failed after tenant change",
|
||||
"reason", reason,
|
||||
"prefix", orgChartSnapshotCacheKeyPrefix,
|
||||
"error", err,
|
||||
)
|
||||
return
|
||||
}
|
||||
if err := h.WarmOrgChartSnapshotCache(ctx); err != nil {
|
||||
slog.Warn("Orgfront orgchart snapshot cache refresh failed after tenant change",
|
||||
"reason", reason,
|
||||
"error", err,
|
||||
)
|
||||
return
|
||||
}
|
||||
slog.Info("Orgfront orgchart snapshot cache refreshed after tenant change",
|
||||
"reason", reason,
|
||||
"invalidated_keys", deleted,
|
||||
)
|
||||
}
|
||||
|
||||
func (h *TenantHandler) buildOrgChartSnapshot(ctx context.Context, profile *domain.UserProfileResponse) (orgChartSnapshotResponse, error) {
|
||||
tenants, err := h.listOrgChartTenantsForProfile(ctx, profile)
|
||||
if err != nil {
|
||||
return orgChartSnapshotResponse{}, err
|
||||
}
|
||||
|
||||
memberCounts, totalMemberCounts, err := h.countTenantMembersFromProjection(ctx, tenants)
|
||||
memberCounts, totalMemberCounts, err := h.countTenantMembers(ctx, tenants)
|
||||
if err != nil {
|
||||
return orgChartSnapshotResponse{}, err
|
||||
}
|
||||
@@ -3002,7 +3508,10 @@ func orgChartSnapshotCacheKey(profile *domain.UserProfileResponse, tenantHeader
|
||||
if profile != nil {
|
||||
role = domain.NormalizeRole(profile.Role)
|
||||
userID = strings.TrimSpace(profile.ID)
|
||||
if tenantID == "" && profile.TenantID != nil {
|
||||
if role == domain.RoleSuperAdmin {
|
||||
userID = "all"
|
||||
tenantID = "none"
|
||||
} else if tenantID == "" && profile.TenantID != nil {
|
||||
tenantID = strings.TrimSpace(*profile.TenantID)
|
||||
}
|
||||
}
|
||||
@@ -3012,7 +3521,7 @@ func orgChartSnapshotCacheKey(profile *domain.UserProfileResponse, tenantHeader
|
||||
if tenantID == "" {
|
||||
tenantID = "none"
|
||||
}
|
||||
return fmt.Sprintf("orgchart:snapshot:v1:%s:%s:%s", role, userID, tenantID)
|
||||
return fmt.Sprintf("%s%s:%s:%s", orgChartSnapshotCacheKeyPrefix, role, userID, tenantID)
|
||||
}
|
||||
|
||||
func orgChartProfileLogValues(profile *domain.UserProfileResponse) (string, string, string) {
|
||||
@@ -3045,17 +3554,8 @@ func findTenantRootID(parentMap map[string]string, tenantID string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func orgChartSnapshotCacheTTL() time.Duration {
|
||||
const defaultTTL = 5 * time.Minute
|
||||
raw := strings.TrimSpace(os.Getenv("ORGFRONT_ORGCHART_CACHE_TTL_SECONDS"))
|
||||
if raw == "" {
|
||||
return defaultTTL
|
||||
}
|
||||
seconds, err := strconv.Atoi(raw)
|
||||
if err != nil || seconds <= 0 {
|
||||
return defaultTTL
|
||||
}
|
||||
return time.Duration(seconds) * time.Second
|
||||
func orgChartSnapshotCacheExpiration() time.Duration {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
|
||||
|
||||
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"baron-sso-backend/internal/testsupport"
|
||||
"bytes"
|
||||
"context"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
postgres_module "github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
@@ -56,8 +58,8 @@ func newTenantHandlerSeedDeleteDB(t *testing.T) *gorm.DB {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open postgres connection: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&domain.Tenant{}); err != nil {
|
||||
t.Fatalf("failed to migrate tenants: %v", err)
|
||||
if err := db.AutoMigrate(&domain.Tenant{}, &domain.User{}, &domain.UserLoginID{}); err != nil {
|
||||
t.Fatalf("failed to migrate tenant delete models: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
@@ -108,6 +110,137 @@ func TestTenantHandlerDeleteTenantRejectsSeedTenant(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantHandlerDeleteTenantReassignsUserMembershipsToParentTenant(t *testing.T) {
|
||||
db := newTenantHandlerSeedDeleteDB(t)
|
||||
parent := domain.Tenant{
|
||||
ID: "10000000-0000-0000-0000-000000000001",
|
||||
Name: "Parent",
|
||||
Slug: "delete-policy-parent",
|
||||
Type: domain.TenantTypeCompany,
|
||||
Status: domain.TenantStatusActive,
|
||||
}
|
||||
child := domain.Tenant{
|
||||
ID: "10000000-0000-0000-0000-000000000002",
|
||||
Name: "Collaboration",
|
||||
Slug: "delete-policy-collaboration",
|
||||
Type: domain.TenantTypeUserGroup,
|
||||
ParentID: &parent.ID,
|
||||
Status: domain.TenantStatusActive,
|
||||
}
|
||||
user := domain.User{
|
||||
ID: "10000000-0000-0000-0000-000000000101",
|
||||
Email: "delete-policy-user@example.com",
|
||||
Name: "Delete Policy User",
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &child.ID,
|
||||
}
|
||||
loginID := domain.UserLoginID{
|
||||
ID: "10000000-0000-0000-0000-000000000201",
|
||||
UserID: user.ID,
|
||||
TenantID: child.ID,
|
||||
FieldKey: "employee_number",
|
||||
LoginID: "delete-policy-user",
|
||||
}
|
||||
if err := db.Create(&parent).Error; err != nil {
|
||||
t.Fatalf("failed to create parent tenant: %v", err)
|
||||
}
|
||||
if err := db.Create(&child).Error; err != nil {
|
||||
t.Fatalf("failed to create child tenant: %v", err)
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
if err := db.Create(&loginID).Error; err != nil {
|
||||
t.Fatalf("failed to create login id: %v", err)
|
||||
}
|
||||
|
||||
staleIdentity := service.KratosIdentity{
|
||||
ID: user.ID,
|
||||
State: "active",
|
||||
Traits: map[string]any{
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
"tenant_id": child.ID,
|
||||
"primaryTenantId": child.ID,
|
||||
"primaryTenantSlug": child.Slug,
|
||||
"primaryTenantName": child.Name,
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": child.ID,
|
||||
"tenantSlug": child.Slug,
|
||||
"tenantName": child.Name,
|
||||
"isPrimary": true,
|
||||
"grade": "G5",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
updatedIdentity := staleIdentity
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockKratos.On("GetIdentity", mock.Anything, user.ID).Return(&staleIdentity, nil).Once()
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, user.ID, mock.MatchedBy(func(traits map[string]any) bool {
|
||||
if traits["tenant_id"] != parent.ID || traits["primaryTenantId"] != parent.ID {
|
||||
return false
|
||||
}
|
||||
if traits["primaryTenantSlug"] != parent.Slug || traits["primaryTenantName"] != parent.Name {
|
||||
return false
|
||||
}
|
||||
appointments, ok := traits["additionalAppointments"].([]any)
|
||||
if !ok || len(appointments) != 1 {
|
||||
return false
|
||||
}
|
||||
appointment, ok := appointments[0].(map[string]any)
|
||||
return ok &&
|
||||
appointment["tenantId"] == parent.ID &&
|
||||
appointment["tenantSlug"] == parent.Slug &&
|
||||
appointment["tenantName"] == parent.Name &&
|
||||
appointment["grade"] == "G5" &&
|
||||
appointment["isPrimary"] == true
|
||||
}), "active").Run(func(args mock.Arguments) {
|
||||
updatedIdentity.Traits = args.Get(2).(map[string]any)
|
||||
}).Return(&updatedIdentity, nil).Once()
|
||||
redis := &mockRedisRepo{data: map[string]string{}}
|
||||
staleRaw, _ := json.Marshal(staleIdentity)
|
||||
if err := redis.Set(identityMirrorKey(user.ID), string(staleRaw), 0); err != nil {
|
||||
t.Fatalf("failed to seed identity mirror: %v", err)
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Delete("/tenants/:id", (&TenantHandler{DB: db, KratosAdmin: mockKratos, IdentityCache: redis}).DeleteTenant)
|
||||
req := httptest.NewRequest(http.MethodDelete, "/tenants/"+child.ID, nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusNoContent)
|
||||
}
|
||||
|
||||
var foundUser domain.User
|
||||
if err := db.First(&foundUser, "id = ?", user.ID).Error; err != nil {
|
||||
t.Fatalf("failed to reload user: %v", err)
|
||||
}
|
||||
if foundUser.TenantID == nil || *foundUser.TenantID != parent.ID {
|
||||
t.Fatalf("user tenant_id = %v, want %s", foundUser.TenantID, parent.ID)
|
||||
}
|
||||
var foundLogin domain.UserLoginID
|
||||
if err := db.First(&foundLogin, "id = ?", loginID.ID).Error; err != nil {
|
||||
t.Fatalf("failed to reload login id: %v", err)
|
||||
}
|
||||
if foundLogin.TenantID != parent.ID {
|
||||
t.Fatalf("login tenant_id = %s, want %s", foundLogin.TenantID, parent.ID)
|
||||
}
|
||||
mockKratos.AssertExpectations(t)
|
||||
|
||||
var mirrored service.KratosIdentity
|
||||
if err := json.Unmarshal([]byte(redis.data[identityMirrorKey(user.ID)]), &mirrored); err != nil {
|
||||
t.Fatalf("failed to decode mirrored identity: %v", err)
|
||||
}
|
||||
if mirrored.Traits["tenant_id"] != parent.ID || mirrored.Traits["primaryTenantSlug"] != parent.Slug {
|
||||
t.Fatalf("mirrored traits = %#v, want promoted tenant %s/%s", mirrored.Traits, parent.ID, parent.Slug)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantHandlerDeleteTenantsBulkRejectsSeedTenant(t *testing.T) {
|
||||
setSeedTenantCSVForDeleteGuard(t, "protected-root")
|
||||
db := newTenantHandlerSeedDeleteDB(t)
|
||||
@@ -157,3 +290,78 @@ func TestTenantHandlerDeleteTenantsBulkRejectsSeedTenant(t *testing.T) {
|
||||
t.Fatalf("remaining tenant count = %d, want 2", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantHandlerDeleteTenantsBulkReassignsUsersToNearestRemainingAncestor(t *testing.T) {
|
||||
db := newTenantHandlerSeedDeleteDB(t)
|
||||
root := domain.Tenant{
|
||||
ID: "10000000-0000-0000-0000-000000000011",
|
||||
Name: "Root",
|
||||
Slug: "delete-policy-root",
|
||||
Type: domain.TenantTypeCompanyGroup,
|
||||
Status: domain.TenantStatusActive,
|
||||
}
|
||||
parent := domain.Tenant{
|
||||
ID: "10000000-0000-0000-0000-000000000012",
|
||||
Name: "Parent",
|
||||
Slug: "delete-policy-bulk-parent",
|
||||
Type: domain.TenantTypeCompany,
|
||||
ParentID: &root.ID,
|
||||
Status: domain.TenantStatusActive,
|
||||
}
|
||||
child := domain.Tenant{
|
||||
ID: "10000000-0000-0000-0000-000000000013",
|
||||
Name: "Collaboration",
|
||||
Slug: "delete-policy-bulk-collaboration",
|
||||
Type: domain.TenantTypeUserGroup,
|
||||
ParentID: &parent.ID,
|
||||
Status: domain.TenantStatusActive,
|
||||
}
|
||||
user := domain.User{
|
||||
ID: "10000000-0000-0000-0000-000000000111",
|
||||
Email: "bulk-delete-policy-user@example.com",
|
||||
Name: "Bulk Delete Policy User",
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &child.ID,
|
||||
}
|
||||
if err := db.Create(&root).Error; err != nil {
|
||||
t.Fatalf("failed to create root tenant: %v", err)
|
||||
}
|
||||
if err := db.Create(&parent).Error; err != nil {
|
||||
t.Fatalf("failed to create parent tenant: %v", err)
|
||||
}
|
||||
if err := db.Create(&child).Error; err != nil {
|
||||
t.Fatalf("failed to create child tenant: %v", err)
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
|
||||
mockSvc := new(MockTenantService)
|
||||
mockSvc.On("DeleteTenantsBulk", mock.Anything, []string{parent.ID, child.ID}).Return(nil).Once()
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Delete("/tenants/bulk", (&TenantHandler{DB: db, Service: mockSvc}).DeleteTenantsBulk)
|
||||
body, _ := json.Marshal(map[string][]string{"ids": {parent.ID, child.ID}})
|
||||
req := httptest.NewRequest(http.MethodDelete, "/tenants/bulk", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
|
||||
var foundUser domain.User
|
||||
if err := db.First(&foundUser, "id = ?", user.ID).Error; err != nil {
|
||||
t.Fatalf("failed to reload user: %v", err)
|
||||
}
|
||||
if foundUser.TenantID == nil || *foundUser.TenantID != root.ID {
|
||||
t.Fatalf("user tenant_id = %v, want %s", foundUser.TenantID, root.ID)
|
||||
}
|
||||
mockSvc.AssertExpectations(t)
|
||||
}
|
||||
|
||||
@@ -113,7 +113,16 @@ func (m *MockUserRepoForHandler) DB() *gorm.DB {
|
||||
}
|
||||
|
||||
func (m *MockUserRepoForHandler) Create(ctx context.Context, user *domain.User) error { return nil }
|
||||
func (m *MockUserRepoForHandler) Update(ctx context.Context, user *domain.User) error { return nil }
|
||||
func (m *MockUserRepoForHandler) Update(ctx context.Context, user *domain.User) error {
|
||||
for _, call := range m.ExpectedCalls {
|
||||
if call.Method == "Update" {
|
||||
args := m.Called(ctx, user)
|
||||
return args.Error(0)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockUserRepoForHandler) Delete(ctx context.Context, id string) error {
|
||||
m.deletedIDs = append(m.deletedIDs, id)
|
||||
return nil
|
||||
@@ -155,7 +164,20 @@ func (m *MockUserRepoForHandler) FindByTenantIDs(ctx context.Context, tenantIDs
|
||||
}
|
||||
|
||||
func (m *MockUserRepoForHandler) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) {
|
||||
return nil, nil
|
||||
for _, call := range m.ExpectedCalls {
|
||||
if call.Method == "CountByTenantIDs" {
|
||||
args := m.Called(ctx, tenantIDs)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(map[string]int64), args.Error(1)
|
||||
}
|
||||
}
|
||||
counts := make(map[string]int64, len(tenantIDs))
|
||||
for _, tenantID := range tenantIDs {
|
||||
counts[tenantID] = 0
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (m *MockUserRepoForHandler) FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error) {
|
||||
@@ -187,10 +209,6 @@ func (m *MockUserRepoForHandler) FindTenantIDByLoginID(ctx context.Context, logi
|
||||
return "", nil
|
||||
}
|
||||
|
||||
type MockUserProjectionRepoForHandler struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type mockOrgChartCache struct {
|
||||
mock.Mock
|
||||
values map[string]string
|
||||
@@ -210,40 +228,9 @@ func (m *mockOrgChartCache) Set(key string, value string, expiration time.Durati
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockUserProjectionRepoForHandler) IsReady(ctx context.Context) (bool, error) {
|
||||
args := m.Called(ctx)
|
||||
return args.Bool(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockUserProjectionRepoForHandler) GetStatus(ctx context.Context) (domain.UserProjectionStatus, error) {
|
||||
args := m.Called(ctx)
|
||||
return args.Get(0).(domain.UserProjectionStatus), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockUserProjectionRepoForHandler) CountTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
|
||||
args := m.Called(ctx, tenants)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(map[string]int64), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockUserProjectionRepoForHandler) CountTenantMembersRecursive(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
|
||||
args := m.Called(ctx, tenants)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(map[string]int64), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockUserProjectionRepoForHandler) ReplaceAllFromKratos(ctx context.Context, users []domain.User) error {
|
||||
args := m.Called(ctx, users)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockUserProjectionRepoForHandler) MarkFailed(ctx context.Context, syncErr error) error {
|
||||
args := m.Called(ctx, syncErr)
|
||||
return args.Error(0)
|
||||
func (m *mockOrgChartCache) DeleteByPrefix(ctx context.Context, prefix string) (int64, error) {
|
||||
args := m.Called(prefix)
|
||||
return args.Get(0).(int64), args.Error(1)
|
||||
}
|
||||
|
||||
func toJSONString(t *testing.T, value any) string {
|
||||
@@ -281,14 +268,14 @@ func TestTenantHandler_CreateTenant(t *testing.T) {
|
||||
assert.Equal(t, "t1", got["id"])
|
||||
}
|
||||
|
||||
func TestTenantHandler_ListTenantsUsesReadyUserProjectionCountsWithoutKratos(t *testing.T) {
|
||||
func TestTenantHandler_ListTenantsUsesUserRepositoryCounts(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
mockProjection := new(MockUserProjectionRepoForHandler)
|
||||
mockUsers := new(MockUserRepoForHandler)
|
||||
|
||||
h := &TenantHandler{
|
||||
Service: mockSvc,
|
||||
UserProjectionRepo: mockProjection,
|
||||
Service: mockSvc,
|
||||
UserRepo: mockUsers,
|
||||
}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
@@ -303,11 +290,11 @@ func TestTenantHandler_ListTenantsUsesReadyUserProjectionCountsWithoutKratos(t *
|
||||
{ID: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"},
|
||||
}
|
||||
mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").Return(tenants, int64(1), nil).Once()
|
||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
|
||||
mockProjection.On("CountTenantMembers", mock.Anything, tenants).
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil).Once()
|
||||
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"00000000-0000-0000-0000-000000000001"}).
|
||||
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once()
|
||||
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).
|
||||
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 7}, nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"00000000-0000-0000-0000-000000000001"}, "").
|
||||
Return([]domain.User{}, int64(7), "", nil).Once()
|
||||
|
||||
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
|
||||
resp, _ := app.Test(req)
|
||||
@@ -320,7 +307,7 @@ func TestTenantHandler_ListTenantsUsesReadyUserProjectionCountsWithoutKratos(t *
|
||||
require.Len(t, res.Items, 1)
|
||||
assert.Equal(t, int64(2), res.Items[0].MemberCount)
|
||||
assert.Equal(t, int64(7), res.Items[0].TotalMemberCount)
|
||||
mockProjection.AssertExpectations(t)
|
||||
mockUsers.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTenantHandler_GetOrgChartSnapshotReturnsRedisCacheHit(t *testing.T) {
|
||||
@@ -353,7 +340,6 @@ func TestTenantHandler_GetOrgChartSnapshotReturnsRedisCacheHit(t *testing.T) {
|
||||
func TestTenantHandler_GetOrgChartSnapshotCachesMissResult(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
mockProjection := new(MockUserProjectionRepoForHandler)
|
||||
mockUsers := new(MockUserRepoForHandler)
|
||||
cache := &mockOrgChartCache{}
|
||||
now := time.Date(2026, 6, 9, 0, 0, 0, 0, time.UTC)
|
||||
@@ -370,15 +356,15 @@ func TestTenantHandler_GetOrgChartSnapshotCachesMissResult(t *testing.T) {
|
||||
cache.On("Get", mock.Anything).Return("", redis.Nil).Once()
|
||||
cache.On("Set", mock.MatchedBy(func(key string) bool {
|
||||
return strings.HasPrefix(key, "orgchart:snapshot:")
|
||||
}), mock.Anything, mock.AnythingOfType("time.Duration")).Return(nil).Once()
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(2), nil).Once()
|
||||
}), mock.Anything, time.Duration(0)).Return(nil).Once()
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(2), nil).Twice()
|
||||
mockSvc.On("ListJoinedTenants", mock.Anything, "user-1").Return([]domain.Tenant{tenants[1]}, nil).Once()
|
||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
|
||||
mockProjection.On("CountTenantMembers", mock.Anything, tenants).Return(map[string]int64{familyID: 0, samanID: 1}, nil).Once()
|
||||
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).Return(map[string]int64{familyID: 1, samanID: 1}, nil).Once()
|
||||
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID, samanID}).Return(map[string]int64{familyID: 0, samanID: 1}, nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 1, "", []string{familyID, samanID}, "").Return([]domain.User{}, int64(1), "", nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 1, "", []string{samanID}, "").Return([]domain.User{}, int64(1), "", nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 10000, "", []string{}, "").Return(users, int64(1), "", nil).Once()
|
||||
|
||||
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, UserProjectionRepo: mockProjection, OrgChartCache: cache}
|
||||
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache}
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
@@ -401,14 +387,103 @@ func TestTenantHandler_GetOrgChartSnapshotCachesMissResult(t *testing.T) {
|
||||
require.Equal(t, int64(1), body.Tenants[0].TotalMemberCount)
|
||||
cache.AssertExpectations(t)
|
||||
mockSvc.AssertExpectations(t)
|
||||
mockProjection.AssertExpectations(t)
|
||||
mockUsers.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestOrgChartSnapshotCacheKeySharesSuperAdminGlobalSnapshot(t *testing.T) {
|
||||
first := orgChartSnapshotCacheKey(&domain.UserProfileResponse{
|
||||
ID: "super-admin-1",
|
||||
Role: domain.RoleSuperAdmin,
|
||||
}, "")
|
||||
second := orgChartSnapshotCacheKey(&domain.UserProfileResponse{
|
||||
ID: "super-admin-2",
|
||||
Role: domain.RoleSuperAdmin,
|
||||
}, "")
|
||||
|
||||
require.Equal(t, first, second)
|
||||
require.Equal(t, "orgchart:snapshot:v1:super_admin:all:none", first)
|
||||
}
|
||||
|
||||
func TestResolveTenantDeletionPromotionTargetsUsesNearestRemainingAncestor(t *testing.T) {
|
||||
rootID := "root"
|
||||
parentID := "parent"
|
||||
childID := "child"
|
||||
tenants := []domain.Tenant{
|
||||
{ID: rootID, Slug: "root"},
|
||||
{ID: parentID, Slug: "parent", ParentID: &rootID},
|
||||
{ID: childID, Slug: "child", ParentID: &parentID},
|
||||
}
|
||||
|
||||
targets, err := resolveTenantDeletionPromotionTargets(tenants, []string{parentID, childID}, []string{childID})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, map[string]string{
|
||||
childID: rootID,
|
||||
}, targets)
|
||||
}
|
||||
|
||||
func TestTenantHandler_WarmOrgChartSnapshotCacheStoresSuperAdminGlobalSnapshot(t *testing.T) {
|
||||
mockSvc := new(MockTenantService)
|
||||
mockUsers := new(MockUserRepoForHandler)
|
||||
cache := &mockOrgChartCache{}
|
||||
now := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
|
||||
familyID := "family"
|
||||
tenants := []domain.Tenant{
|
||||
{ID: familyID, Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
|
||||
}
|
||||
|
||||
cache.On("Set", "orgchart:snapshot:v1:super_admin:all:none", mock.Anything, time.Duration(0)).Return(nil).Once()
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil).Twice()
|
||||
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID}).Return(map[string]int64{familyID: 0}, nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 1, "", []string{familyID}, "").Return([]domain.User{}, int64(0), "", nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 10000, "", []string{}, "").Return([]domain.User{}, int64(0), "", nil).Once()
|
||||
|
||||
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache}
|
||||
|
||||
require.NoError(t, h.WarmOrgChartSnapshotCache(context.Background()))
|
||||
raw := cache.values["orgchart:snapshot:v1:super_admin:all:none"]
|
||||
require.NotEmpty(t, raw)
|
||||
|
||||
var cached orgChartSnapshotResponse
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &cached))
|
||||
require.Len(t, cached.Tenants, 1)
|
||||
require.Equal(t, "database", cached.Cache.Source)
|
||||
require.False(t, cached.Cache.Hit)
|
||||
|
||||
cache.AssertExpectations(t)
|
||||
mockSvc.AssertExpectations(t)
|
||||
mockUsers.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTenantHandler_RefreshOrgChartSnapshotCacheAfterTenantChangeInvalidatesAllSnapshotKeys(t *testing.T) {
|
||||
mockSvc := new(MockTenantService)
|
||||
mockUsers := new(MockUserRepoForHandler)
|
||||
cache := &mockOrgChartCache{}
|
||||
now := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
|
||||
familyID := "family"
|
||||
tenants := []domain.Tenant{
|
||||
{ID: familyID, Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
|
||||
}
|
||||
|
||||
cache.On("DeleteByPrefix", "orgchart:snapshot:v1:").Return(int64(3), nil).Once()
|
||||
cache.On("Set", "orgchart:snapshot:v1:super_admin:all:none", mock.Anything, time.Duration(0)).Return(nil).Once()
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil).Twice()
|
||||
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID}).Return(map[string]int64{familyID: 0}, nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 1, "", []string{familyID}, "").Return([]domain.User{}, int64(0), "", nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 10000, "", []string{}, "").Return([]domain.User{}, int64(0), "", nil).Once()
|
||||
|
||||
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache}
|
||||
|
||||
h.refreshOrgChartSnapshotCacheAfterTenantChange(context.Background(), "tenant_created")
|
||||
|
||||
cache.AssertExpectations(t)
|
||||
mockSvc.AssertExpectations(t)
|
||||
mockUsers.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTenantHandler_GetOrgChartSnapshotHandlesSelfParentHanmacFamily(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
mockProjection := new(MockUserProjectionRepoForHandler)
|
||||
mockUsers := new(MockUserRepoForHandler)
|
||||
now := time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)
|
||||
parent := func(id string) *string { return &id }
|
||||
@@ -424,7 +499,7 @@ func TestTenantHandler_GetOrgChartSnapshotHandlesSelfParentHanmacFamily(t *testi
|
||||
{ID: "user-1", Email: "user@samaneng.com", Name: "Saman User", Role: domain.RoleUser, Status: domain.UserStatusActive, TenantID: &samanID, Tenant: &tenants[1], CreatedAt: now, UpdatedAt: now},
|
||||
}
|
||||
|
||||
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, UserProjectionRepo: mockProjection}
|
||||
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers}
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
@@ -438,14 +513,11 @@ func TestTenantHandler_GetOrgChartSnapshotHandlesSelfParentHanmacFamily(t *testi
|
||||
})
|
||||
app.Get("/admin/orgchart/snapshot", h.GetOrgChartSnapshot)
|
||||
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Once()
|
||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
|
||||
mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
|
||||
return tenantSlugsMatch(got, "hanmac-family", "saman", "saman-platform")
|
||||
})).Return(map[string]int64{familyID: 0, samanID: 1, teamID: 0}, nil).Once()
|
||||
mockProjection.On("CountTenantMembersRecursive", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
|
||||
return tenantSlugsMatch(got, "hanmac-family", "saman", "saman-platform")
|
||||
})).Return(map[string]int64{familyID: 1, samanID: 1, teamID: 0}, nil).Once()
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Twice()
|
||||
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID, samanID, teamID}).Return(map[string]int64{familyID: 0, samanID: 1, teamID: 0}, nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 1, "", []string{familyID, samanID, teamID}, "").Return([]domain.User{}, int64(1), "", nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 1, "", []string{samanID, teamID}, "").Return([]domain.User{}, int64(1), "", nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 1, "", []string{teamID}, "").Return([]domain.User{}, int64(0), "", nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 10000, "", []string{familyID, samanID, teamID}, "").Return(users, int64(1), "", nil).Once()
|
||||
mockSvc.On("ListJoinedTenants", mock.Anything, "user-1").Return([]domain.Tenant{tenants[1], tenants[2]}, nil).Once()
|
||||
|
||||
@@ -463,18 +535,17 @@ func TestTenantHandler_GetOrgChartSnapshotHandlesSelfParentHanmacFamily(t *testi
|
||||
require.True(t, tenantSummarySlugsMatch(body.Tenants, "hanmac-family", "saman", "saman-platform"))
|
||||
require.Len(t, body.Users, 1)
|
||||
mockSvc.AssertExpectations(t)
|
||||
mockProjection.AssertExpectations(t)
|
||||
mockUsers.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTenantHandler_ListTenantsReturnsTotalMemberCountForDescendants(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
mockProjection := new(MockUserProjectionRepoForHandler)
|
||||
mockUsers := new(MockUserRepoForHandler)
|
||||
|
||||
h := &TenantHandler{
|
||||
Service: mockSvc,
|
||||
UserProjectionRepo: mockProjection,
|
||||
Service: mockSvc,
|
||||
UserRepo: mockUsers,
|
||||
}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
@@ -492,11 +563,13 @@ func TestTenantHandler_ListTenantsReturnsTotalMemberCountForDescendants(t *testi
|
||||
{ID: childID, Name: "Child", Slug: "child", ParentID: &parentID},
|
||||
}
|
||||
mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").Return(tenants, int64(2), nil).Once()
|
||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
|
||||
mockProjection.On("CountTenantMembers", mock.Anything, tenants).
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(2), nil).Once()
|
||||
mockUsers.On("CountByTenantIDs", mock.Anything, []string{parentID, childID}).
|
||||
Return(map[string]int64{parentID: 1, childID: 2}, nil).Once()
|
||||
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).
|
||||
Return(map[string]int64{parentID: 3, childID: 2}, nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 1, "", []string{parentID, childID}, "").
|
||||
Return([]domain.User{}, int64(3), "", nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 1, "", []string{childID}, "").
|
||||
Return([]domain.User{}, int64(2), "", nil).Once()
|
||||
|
||||
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
|
||||
resp, _ := app.Test(req)
|
||||
@@ -510,49 +583,17 @@ func TestTenantHandler_ListTenantsReturnsTotalMemberCountForDescendants(t *testi
|
||||
assert.Equal(t, int64(3), res.Items[0].TotalMemberCount)
|
||||
assert.Equal(t, int64(2), res.Items[1].MemberCount)
|
||||
assert.Equal(t, int64(2), res.Items[1].TotalMemberCount)
|
||||
mockProjection.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTenantHandler_ListTenantsRejectsStatsWhenUserProjectionIsNotReady(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
mockProjection := new(MockUserProjectionRepoForHandler)
|
||||
|
||||
h := &TenantHandler{
|
||||
Service: mockSvc,
|
||||
UserProjectionRepo: mockProjection,
|
||||
}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
Role: "super_admin",
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/tenants", h.ListTenants)
|
||||
|
||||
tenants := []domain.Tenant{
|
||||
{ID: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"},
|
||||
}
|
||||
mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").Return(tenants, int64(1), nil).Once()
|
||||
mockProjection.On("IsReady", mock.Anything).Return(false, nil).Once()
|
||||
|
||||
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
|
||||
resp, _ := app.Test(req)
|
||||
|
||||
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
||||
mockProjection.AssertNotCalled(t, "CountTenantMembers", mock.Anything, mock.Anything)
|
||||
mockProjection.AssertNotCalled(t, "CountTenantMembersRecursive", mock.Anything, mock.Anything)
|
||||
mockUsers.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTenantHandler_ListTenants(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
mockProjection := new(MockUserProjectionRepoForHandler)
|
||||
mockUsers := new(MockUserRepoForHandler)
|
||||
|
||||
h := &TenantHandler{
|
||||
Service: mockSvc,
|
||||
UserProjectionRepo: mockProjection,
|
||||
Service: mockSvc,
|
||||
UserRepo: mockUsers,
|
||||
}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
@@ -569,11 +610,10 @@ func TestTenantHandler_ListTenants(t *testing.T) {
|
||||
|
||||
// Mocking for the new allTenants check in ListTenants
|
||||
mockSvc.On("ListTenants", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tenants, int64(2), nil).Maybe()
|
||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
|
||||
mockProjection.On("CountTenantMembers", mock.Anything, tenants).
|
||||
Return(map[string]int64{"t1": 5, "t2": 10}, nil).Once()
|
||||
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).
|
||||
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"t1", "t2"}).
|
||||
Return(map[string]int64{"t1": 5, "t2": 10}, nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"t1"}, "").Return([]domain.User{}, int64(5), "", nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"t2"}, "").Return([]domain.User{}, int64(10), "", nil).Once()
|
||||
|
||||
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
|
||||
resp, _ := app.Test(req)
|
||||
@@ -599,11 +639,11 @@ func TestTenantHandler_ListTenants(t *testing.T) {
|
||||
func TestTenantHandler_ListTenantsReturnsNextCursorWhenMoreRowsExist(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
mockProjection := new(MockUserProjectionRepoForHandler)
|
||||
mockUsers := new(MockUserRepoForHandler)
|
||||
|
||||
h := &TenantHandler{
|
||||
Service: mockSvc,
|
||||
UserProjectionRepo: mockProjection,
|
||||
Service: mockSvc,
|
||||
UserRepo: mockUsers,
|
||||
}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
@@ -621,9 +661,10 @@ func TestTenantHandler_ListTenantsReturnsNextCursorWhenMoreRowsExist(t *testing.
|
||||
}
|
||||
|
||||
mockSvc.On("ListTenants", mock.Anything, 2, 0, "", "").Return(tenants, int64(3), nil).Once()
|
||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
|
||||
mockProjection.On("CountTenantMembers", mock.Anything, tenants).Return(map[string]int64{}, nil).Once()
|
||||
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).Return(map[string]int64{}, nil).Once()
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(3), nil).Once()
|
||||
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"00000000-0000-0000-0000-000000000002", "00000000-0000-0000-0000-000000000001"}).Return(map[string]int64{}, nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"00000000-0000-0000-0000-000000000002"}, "").Return([]domain.User{}, int64(0), "", nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"00000000-0000-0000-0000-000000000001"}, "").Return([]domain.User{}, int64(0), "", nil).Once()
|
||||
|
||||
req := httptest.NewRequest("GET", "/tenants?limit=2&offset=0", nil)
|
||||
resp, _ := app.Test(req)
|
||||
@@ -662,11 +703,11 @@ func TestPageTenantsByCursorUsesStableCreatedAtAndIDOrder(t *testing.T) {
|
||||
func TestTenantHandler_ListTenantsHidesPrivateSubtreeForUnauthorizedUser(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
mockProjection := new(MockUserProjectionRepoForHandler)
|
||||
mockUsers := new(MockUserRepoForHandler)
|
||||
|
||||
h := &TenantHandler{
|
||||
Service: mockSvc,
|
||||
UserProjectionRepo: mockProjection,
|
||||
Service: mockSvc,
|
||||
UserRepo: mockUsers,
|
||||
}
|
||||
|
||||
parent := func(id string) *string { return &id }
|
||||
@@ -688,14 +729,12 @@ func TestTenantHandler_ListTenantsHidesPrivateSubtreeForUnauthorizedUser(t *test
|
||||
})
|
||||
app.Get("/tenants", h.ListTenants)
|
||||
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Once()
|
||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
|
||||
mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
|
||||
return tenantSlugsMatch(got, "hanmac-family", "hanmac", "public-team")
|
||||
})).Return(map[string]int64{}, nil).Once()
|
||||
mockProjection.On("CountTenantMembersRecursive", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
|
||||
return tenantSlugsMatch(got, "hanmac-family", "hanmac", "public-team")
|
||||
})).Return(map[string]int64{}, nil).Once()
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Twice()
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Twice()
|
||||
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"family", "company", "public-team"}).Return(map[string]int64{}, nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"family", "company", "public-team", "private-team", "private-child"}, "").Return([]domain.User{}, int64(0), "", nil).Maybe()
|
||||
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"company", "public-team", "private-team", "private-child"}, "").Return([]domain.User{}, int64(0), "", nil).Maybe()
|
||||
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"public-team"}, "").Return([]domain.User{}, int64(0), "", nil).Maybe()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/tenants?limit=100&offset=0", nil)
|
||||
resp, err := app.Test(req)
|
||||
@@ -712,11 +751,11 @@ func TestTenantHandler_ListTenantsHidesPrivateSubtreeForUnauthorizedUser(t *test
|
||||
func TestTenantHandler_ListTenantsShowsPrivateSubtreeForManageableTenant(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
mockProjection := new(MockUserProjectionRepoForHandler)
|
||||
mockUsers := new(MockUserRepoForHandler)
|
||||
|
||||
h := &TenantHandler{
|
||||
Service: mockSvc,
|
||||
UserProjectionRepo: mockProjection,
|
||||
Service: mockSvc,
|
||||
UserRepo: mockUsers,
|
||||
}
|
||||
|
||||
parent := func(id string) *string { return &id }
|
||||
@@ -740,14 +779,10 @@ func TestTenantHandler_ListTenantsShowsPrivateSubtreeForManageableTenant(t *test
|
||||
})
|
||||
app.Get("/tenants", h.ListTenants)
|
||||
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Twice()
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Once()
|
||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
|
||||
mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
|
||||
return tenantSlugsMatch(got, "hanmac-family", "hanmac", "private-team", "private-child")
|
||||
})).Return(map[string]int64{}, nil).Once()
|
||||
mockProjection.On("CountTenantMembersRecursive", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
|
||||
return tenantSlugsMatch(got, "hanmac-family", "hanmac", "private-team", "private-child")
|
||||
})).Return(map[string]int64{}, nil).Once()
|
||||
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"family", "company", "private-team", "private-child"}).Return(map[string]int64{}, nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 1, "", mock.Anything, "").Return([]domain.User{}, int64(0), "", nil).Maybe()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/tenants?limit=100&offset=0", nil)
|
||||
resp, err := app.Test(req)
|
||||
@@ -761,6 +796,41 @@ func TestTenantHandler_ListTenantsShowsPrivateSubtreeForManageableTenant(t *test
|
||||
require.Contains(t, toJSONString(t, res), "private-child")
|
||||
}
|
||||
|
||||
func TestTenantHandler_ListTenantsRejectsStaleProfileTenantScope(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
h := &TenantHandler{Service: mockSvc}
|
||||
|
||||
parent := func(id string) *string { return &id }
|
||||
tenants := []domain.Tenant{
|
||||
{ID: "family", Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family"},
|
||||
{ID: "company", Type: domain.TenantTypeCompany, ParentID: parent("family"), Name: "한맥", Slug: "hanmac"},
|
||||
}
|
||||
staleTenantID := "deleted-tenant"
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &staleTenantID,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/tenants", h.ListTenants)
|
||||
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Once()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/tenants?limit=100&offset=0", nil)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusConflict, resp.StatusCode)
|
||||
|
||||
var body map[string]any
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
require.Contains(t, body["error"], "tenant scope is not available")
|
||||
mockSvc.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTenantHandler_FilterPrivateTenantsAllowsExplicitPrivatePermission(t *testing.T) {
|
||||
parent := func(id string) *string { return &id }
|
||||
tenants := []domain.Tenant{
|
||||
@@ -785,6 +855,41 @@ func TestTenantHandler_FilterPrivateTenantsAllowsExplicitPrivatePermission(t *te
|
||||
mockKeto.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTenantHandler_FilterPrivateTenantsTreatsMissingKetoRelationsAsDenied(t *testing.T) {
|
||||
parent := func(id string) *string { return &id }
|
||||
tenants := []domain.Tenant{
|
||||
{ID: "company", Type: domain.TenantTypeCompany, Name: "한맥", Slug: "hanmac"},
|
||||
{ID: "private-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "비공개팀", Slug: "private-team", Config: domain.JSONMap{"visibility": "private"}},
|
||||
{ID: "public-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "공개팀", Slug: "public-team"},
|
||||
}
|
||||
mockKeto := new(devMockKetoService)
|
||||
h := &TenantHandler{Keto: mockKeto}
|
||||
relationErr := errors.New(`keto returned status 400: {"reason":"relation \"view_private\" does not exist"}`)
|
||||
|
||||
for _, relation := range []string{"view_private", "view_private_descendants", "view", "manage"} {
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "private-team", relation).Return(false, relationErr).Once()
|
||||
}
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "company", "view_private_descendants").Return(false, relationErr).Once()
|
||||
|
||||
filtered, err := h.filterPrivateTenantsForProfile(context.Background(), tenants, &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: "tenant_admin",
|
||||
TenantID: parent("company"),
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.ElementsMatch(t, []string{"hanmac", "public-team"}, tenantSlugs(filtered))
|
||||
mockKeto.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func tenantSlugs(tenants []domain.Tenant) []string {
|
||||
slugs := make([]string, 0, len(tenants))
|
||||
for _, tenant := range tenants {
|
||||
slugs = append(slugs, tenant.Slug)
|
||||
}
|
||||
return slugs
|
||||
}
|
||||
|
||||
func tenantSlugsMatch(got []domain.Tenant, want ...string) bool {
|
||||
if len(got) != len(want) {
|
||||
return false
|
||||
@@ -1127,47 +1232,14 @@ func TestTenantHandler_GetOrgContextJSONRequiresApiKey(t *testing.T) {
|
||||
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestTenantHandler_ListTenantsReturnsServiceUnavailableWhenProjectionStatusFails(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
mockProjection := new(MockUserProjectionRepoForHandler)
|
||||
|
||||
h := &TenantHandler{
|
||||
Service: mockSvc,
|
||||
UserProjectionRepo: mockProjection,
|
||||
}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
Role: "super_admin",
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/tenants", h.ListTenants)
|
||||
|
||||
tenants := []domain.Tenant{
|
||||
{ID: "t1", Name: "Tenant A", Slug: "slug-a"},
|
||||
}
|
||||
mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").Return(tenants, int64(1), nil).Once()
|
||||
mockProjection.On("IsReady", mock.Anything).Return(false, errors.New("projection state query failed")).Once()
|
||||
|
||||
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
|
||||
resp, _ := app.Test(req)
|
||||
|
||||
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
||||
mockProjection.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTenantHandler_ListTenantsUsesProjectionCountsWhenAvailable(t *testing.T) {
|
||||
func TestTenantHandler_ListTenantsUsesUserRepositoryCountsWhenAvailable(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
mockUserRepo := new(MockUserRepoForHandler)
|
||||
mockProjection := new(MockUserProjectionRepoForHandler)
|
||||
|
||||
h := &TenantHandler{
|
||||
Service: mockSvc,
|
||||
UserRepo: mockUserRepo,
|
||||
UserProjectionRepo: mockProjection,
|
||||
Service: mockSvc,
|
||||
UserRepo: mockUserRepo,
|
||||
}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
@@ -1183,16 +1255,11 @@ func TestTenantHandler_ListTenantsUsesProjectionCountsWhenAvailable(t *testing.T
|
||||
}
|
||||
|
||||
mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").Return(tenants, int64(1), nil).Once()
|
||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
|
||||
mockProjection.On("CountTenantMembers", mock.Anything, tenants).
|
||||
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once()
|
||||
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).
|
||||
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once()
|
||||
|
||||
mockUserRepo.On("CountByCompanyCodes", mock.Anything, []string{"saman"}).
|
||||
Return(map[string]int64{"saman": 152}, nil).Maybe()
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil).Once()
|
||||
mockUserRepo.On("CountByTenantIDs", mock.Anything, []string{"00000000-0000-0000-0000-000000000001"}).
|
||||
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 152}, nil).Maybe()
|
||||
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 152}, nil).Once()
|
||||
mockUserRepo.On("List", mock.Anything, 0, 1, "", []string{"00000000-0000-0000-0000-000000000001"}, "").
|
||||
Return([]domain.User{}, int64(152), "", nil).Once()
|
||||
|
||||
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
|
||||
resp, _ := app.Test(req)
|
||||
@@ -1203,8 +1270,8 @@ func TestTenantHandler_ListTenantsUsesProjectionCountsWhenAvailable(t *testing.T
|
||||
json.NewDecoder(resp.Body).Decode(&res)
|
||||
|
||||
assert.Len(t, res.Items, 1)
|
||||
assert.Equal(t, int64(2), res.Items[0].MemberCount)
|
||||
mockProjection.AssertExpectations(t)
|
||||
assert.Equal(t, int64(152), res.Items[0].MemberCount)
|
||||
mockUserRepo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTenantHandler_ExportTenantsCSV(t *testing.T) {
|
||||
@@ -1718,6 +1785,46 @@ func TestTenantHandler_ApproveTenant(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestTenantHandler_ApproveTenantRefreshesOrgChartSnapshotCache(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
mockUsers := new(MockUserRepoForHandler)
|
||||
cache := &mockOrgChartCache{}
|
||||
now := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
|
||||
familyID := "family"
|
||||
tenantID := "tenant-1"
|
||||
tenants := []domain.Tenant{
|
||||
{ID: familyID, Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
|
||||
{ID: tenantID, Type: domain.TenantTypeOrganization, Name: "조직", Slug: "team", ParentID: &familyID, Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
|
||||
}
|
||||
|
||||
mockSvc.On("ApproveTenant", mock.Anything, tenantID).Return(nil).Once()
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Twice()
|
||||
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID, tenantID}).Return(map[string]int64{familyID: 0, tenantID: 0}, nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 1, "", []string{familyID, tenantID}, "").Return([]domain.User{}, int64(0), "", nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 1, "", []string{tenantID}, "").Return([]domain.User{}, int64(0), "", nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 10000, "", []string{}, "").Return([]domain.User{}, int64(0), "", nil).Once()
|
||||
cache.On("DeleteByPrefix", "orgchart:snapshot:v1:").Return(int64(1), nil).Once()
|
||||
cache.On("Set", "orgchart:snapshot:v1:super_admin:all:none", mock.Anything, time.Duration(0)).Return(nil).Once()
|
||||
|
||||
h := &TenantHandler{
|
||||
Service: mockSvc,
|
||||
UserRepo: mockUsers,
|
||||
OrgChartCache: cache,
|
||||
}
|
||||
|
||||
app.Post("/tenants/:id/approve", h.ApproveTenant)
|
||||
|
||||
req := httptest.NewRequest("POST", "/tenants/"+tenantID+"/approve", nil)
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
cache.AssertExpectations(t)
|
||||
mockSvc.AssertExpectations(t)
|
||||
mockUsers.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func (m *MockTenantService) DeleteTenantsBulk(ctx context.Context, tenantIDs []string) error {
|
||||
args := m.Called(ctx, tenantIDs)
|
||||
return args.Error(0)
|
||||
|
||||
@@ -34,17 +34,16 @@ type OryProviderAPI interface {
|
||||
}
|
||||
|
||||
type UserHandler struct {
|
||||
KratosAdmin service.KratosAdminService
|
||||
OryProvider OryProviderAPI
|
||||
TenantService service.TenantService
|
||||
KetoService service.KetoService
|
||||
KetoOutboxRepo repository.KetoOutboxRepository
|
||||
UserRepo repository.UserRepository
|
||||
UserProjectionRepo repository.UserProjectionRepository
|
||||
UserGroupRepo repository.UserGroupRepository
|
||||
AuditRepo domain.AuditRepository
|
||||
IdentityCache domain.RedisRepository
|
||||
Worksmobile service.WorksmobileSyncer
|
||||
KratosAdmin service.KratosAdminService
|
||||
OryProvider OryProviderAPI
|
||||
TenantService service.TenantService
|
||||
KetoService service.KetoService
|
||||
KetoOutboxRepo repository.KetoOutboxRepository
|
||||
UserRepo repository.UserRepository
|
||||
UserGroupRepo repository.UserGroupRepository
|
||||
AuditRepo domain.AuditRepository
|
||||
IdentityCache domain.RedisRepository
|
||||
Worksmobile service.WorksmobileSyncer
|
||||
}
|
||||
|
||||
func NewUserHandler(kratosAdmin service.KratosAdminService, oryProvider OryProviderAPI, tenantService service.TenantService, ketoService service.KetoService, ketoOutboxRepo repository.KetoOutboxRepository, userRepo repository.UserRepository, userGroupRepo repository.UserGroupRepository, auditRepo domain.AuditRepository) *UserHandler {
|
||||
@@ -111,6 +110,16 @@ func userAppointmentSliceFromRaw(raw any) []any {
|
||||
appointments = append(appointments, value)
|
||||
}
|
||||
return appointments
|
||||
case []map[string]string:
|
||||
appointments := make([]any, 0, len(values))
|
||||
for _, value := range values {
|
||||
appointment := make(map[string]any, len(value))
|
||||
for key, item := range value {
|
||||
appointment[key] = item
|
||||
}
|
||||
appointments = append(appointments, appointment)
|
||||
}
|
||||
return appointments
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -323,7 +332,7 @@ func sanitizeUserRepresentativeTenants(ctx context.Context, tenantService servic
|
||||
delete(metadata, "primaryTenantIsOwner")
|
||||
cleared = true
|
||||
}
|
||||
if isNonPublicRepresentativeTenant(ctx, tenantService, normalizeMetadataString(metadata["primaryTenantId"]), normalizeMetadataString(metadata["primaryTenantSlug"])) {
|
||||
if isBlockedRepresentativeTenant(ctx, tenantService, normalizeMetadataString(metadata["primaryTenantId"]), normalizeMetadataString(metadata["primaryTenantSlug"])) {
|
||||
clearMetadataPrimary()
|
||||
}
|
||||
|
||||
@@ -336,7 +345,7 @@ func sanitizeUserRepresentativeTenants(ctx context.Context, tenantService servic
|
||||
if tenantSlug == "" {
|
||||
tenantSlug = normalizeMetadataString(appointment["slug"])
|
||||
}
|
||||
if !isNonPublicRepresentativeTenant(ctx, tenantService, tenantID, tenantSlug) {
|
||||
if !isBlockedRepresentativeTenant(ctx, tenantService, tenantID, tenantSlug) {
|
||||
return
|
||||
}
|
||||
appointment["isPrimary"] = false
|
||||
@@ -359,7 +368,7 @@ func sanitizeUserRepresentativeTenants(ctx context.Context, tenantService servic
|
||||
return cleared, nil
|
||||
}
|
||||
|
||||
func isNonPublicRepresentativeTenant(ctx context.Context, tenantService service.TenantService, tenantID string, tenantSlug string) bool {
|
||||
func isBlockedRepresentativeTenant(ctx context.Context, tenantService service.TenantService, tenantID string, tenantSlug string) bool {
|
||||
var tenant *domain.Tenant
|
||||
var err error
|
||||
if strings.TrimSpace(tenantID) != "" {
|
||||
@@ -371,7 +380,7 @@ func isNonPublicRepresentativeTenant(ctx context.Context, tenantService service.
|
||||
return false
|
||||
}
|
||||
visibility := tenantVisibility(tenant.Config)
|
||||
return visibility == "internal" || visibility == "private"
|
||||
return visibility == "private"
|
||||
}
|
||||
|
||||
func primaryTenantIDFromRequest(primaryTenantID string, metadata map[string]any, appointments []map[string]any) string {
|
||||
@@ -544,9 +553,34 @@ func tenantSlugPointerFromRequest(tenantSlug *string, legacyCompanyCode *string)
|
||||
}
|
||||
|
||||
func identityTenantAccessKeys(traits map[string]any) []string {
|
||||
keys := make([]string, 0, 2)
|
||||
if tenantID := strings.ToLower(strings.TrimSpace(extractTraitString(traits, "tenant_id"))); tenantID != "" {
|
||||
keys = append(keys, tenantID)
|
||||
keys := make([]string, 0, 4)
|
||||
seen := make(map[string]bool)
|
||||
appendKey := func(value string) {
|
||||
key := strings.ToLower(strings.TrimSpace(value))
|
||||
if key == "" || seen[key] {
|
||||
return
|
||||
}
|
||||
seen[key] = true
|
||||
keys = append(keys, key)
|
||||
}
|
||||
|
||||
appendKey(extractTraitString(traits, "tenant_id"))
|
||||
appendKey(extractTraitString(traits, "tenantSlug"))
|
||||
|
||||
appointments := userAppointmentSliceFromRaw(traits["additionalAppointments"])
|
||||
if len(appointments) == 0 {
|
||||
if metadata, ok := traits["metadata"].(map[string]any); ok {
|
||||
appointments = userAppointmentSliceFromRaw(metadata["additionalAppointments"])
|
||||
}
|
||||
}
|
||||
for _, raw := range appointments {
|
||||
appointment, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
appendKey(normalizeMetadataString(appointment["tenantId"]))
|
||||
appendKey(normalizeMetadataString(appointment["tenantSlug"]))
|
||||
appendKey(normalizeMetadataString(appointment["slug"]))
|
||||
}
|
||||
return keys
|
||||
}
|
||||
@@ -673,6 +707,7 @@ func kratosIdentityCursorKey(identity service.KratosIdentity) (time.Time, string
|
||||
}
|
||||
|
||||
func identityMatchesSearch(identity service.KratosIdentity, searchLower string) bool {
|
||||
searchLower = strings.TrimSpace(searchLower)
|
||||
if searchLower == "" {
|
||||
return true
|
||||
}
|
||||
@@ -685,6 +720,9 @@ func identityMatchesSearch(identity service.KratosIdentity, searchLower string)
|
||||
if strings.Contains(strings.ToLower(extractTraitString(identity.Traits, "name")), searchLower) {
|
||||
return true
|
||||
}
|
||||
if identityEmailLocalPartMatchesSearch(identity, searchLower) {
|
||||
return true
|
||||
}
|
||||
rawTraits, err := json.Marshal(identity.Traits)
|
||||
if err != nil {
|
||||
return false
|
||||
@@ -692,6 +730,21 @@ func identityMatchesSearch(identity service.KratosIdentity, searchLower string)
|
||||
return strings.Contains(strings.ToLower(string(rawTraits)), searchLower)
|
||||
}
|
||||
|
||||
func identityEmailLocalPartMatchesSearch(identity service.KratosIdentity, searchLower string) bool {
|
||||
if !strings.Contains(searchLower, "@") {
|
||||
return false
|
||||
}
|
||||
searchLocalPart, err := domain.ExtractNormalizedEmailLocalPart(searchLower)
|
||||
if err != nil || searchLocalPart == "" {
|
||||
return false
|
||||
}
|
||||
identityLocalPart, err := domain.ExtractNormalizedEmailLocalPart(extractTraitString(identity.Traits, "email"))
|
||||
if err != nil || identityLocalPart == "" {
|
||||
return false
|
||||
}
|
||||
return searchLocalPart == identityLocalPart
|
||||
}
|
||||
|
||||
func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
// [New] Get requester profile from middleware
|
||||
var requesterRole string
|
||||
@@ -813,11 +866,11 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
searchLower := strings.ToLower(search)
|
||||
|
||||
for _, identity := range identities {
|
||||
tID := strings.ToLower(extractTraitString(identity.Traits, "tenant_id"))
|
||||
tenantAccessKeys := identityTenantAccessKeys(identity.Traits)
|
||||
|
||||
// Tenant Admin & Member filtering
|
||||
if requesterRole != domain.RoleSuperAdmin {
|
||||
hasAccess := manageableSlugs[tID]
|
||||
hasAccess := anyTenantKeyAllowed(tenantAccessKeys, manageableSlugs)
|
||||
if !hasAccess {
|
||||
continue
|
||||
}
|
||||
@@ -825,7 +878,11 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
|
||||
// Dedicated tenantSlug filter
|
||||
if tenantSlug != "" {
|
||||
matches := tID == targetTenantID
|
||||
targetKeys := map[string]bool{
|
||||
targetTenantID: true,
|
||||
strings.ToLower(tenantSlug): true,
|
||||
}
|
||||
matches := anyTenantKeyAllowed(tenantAccessKeys, targetKeys)
|
||||
if !matches {
|
||||
continue
|
||||
}
|
||||
@@ -1195,6 +1252,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
|
||||
// [Resolve TenantID and Custom Login IDs before Kratos creation]
|
||||
var tenantID string
|
||||
var resolvedTenant *domain.Tenant
|
||||
primaryAppointments := req.AdditionalAppointments
|
||||
if representativeCleared {
|
||||
primaryAppointments = nil
|
||||
@@ -1205,18 +1263,26 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
if tenant, err := h.TenantService.GetTenant(c.Context(), requestedPrimaryTenantID); err == nil && tenant != nil {
|
||||
tenantID = tenant.ID
|
||||
req.CompanyCode = tenant.Slug
|
||||
resolvedTenant = tenant
|
||||
}
|
||||
}
|
||||
}
|
||||
if req.CompanyCode != "" && h.TenantService != nil {
|
||||
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode); err == nil && tenant != nil {
|
||||
tenantID = tenant.ID
|
||||
resolvedTenant = tenant
|
||||
}
|
||||
}
|
||||
if err := h.ensureInternalDomainNotAssignedToPersonal(c.Context(), email, tenantID, req.CompanyCode, resolvedTenant); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
if tenantID == "" {
|
||||
if req.CompanyCode != "" || requestedPrimaryTenantID != "" {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid tenant assignment")
|
||||
}
|
||||
if emailUsesInternalPersonalRestrictedDomain(email) {
|
||||
return errorJSON(c, fiber.StatusBadRequest, internalDomainPersonalPolicyMessage(email))
|
||||
}
|
||||
tenant, err := createPersonalTenantForUser(c.Context(), h.TenantService, email)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "failed to create personal tenant")
|
||||
@@ -1304,21 +1370,13 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
// Sync to local DB (Synchronous for immediate consistency)
|
||||
if err := h.UserRepo.Update(c.Context(), localUser); err != nil {
|
||||
slog.Error("[UserHandler] Failed to sync new user to local DB", "email", localUser.Email, "error", err)
|
||||
markUserProjectionFailed(c.Context(), h.UserProjectionRepo, err)
|
||||
}
|
||||
if h.Worksmobile != nil {
|
||||
if err := h.Worksmobile.EnqueueUserUpsertIfInScope(c.Context(), *localUser); err != nil {
|
||||
slog.Warn("[UserHandler] Failed to enqueue Worksmobile user sync", "userID", localUser.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update User Login IDs in local DB
|
||||
for i := range loginIDRecords {
|
||||
loginIDRecords[i].UserID = localUser.ID
|
||||
}
|
||||
if err := h.UserRepo.UpdateUserLoginIDs(c.Context(), localUser.ID, loginIDRecords); err != nil {
|
||||
slog.Error("[UserHandler] Failed to update user login IDs", "userID", localUser.ID, "error", err)
|
||||
markUserProjectionFailed(c.Context(), h.UserProjectionRepo, err)
|
||||
}
|
||||
|
||||
// [Keto] Sync relations via Outbox (Synchronous for accurate counting)
|
||||
@@ -1405,7 +1463,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||
results := make([]bulkUserResult, 0, len(req.Users))
|
||||
var hanmacScope *hanmacEmailScope
|
||||
var hanmacLocalParts map[string]bool
|
||||
var hanmacLocalParts map[string]hanmacLocalPartOwner
|
||||
hanmacScopeLoaded := false
|
||||
bulkEmailErrors := validateBulkUserEmailUniqueness(req.Users)
|
||||
|
||||
@@ -1414,6 +1472,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
ID string
|
||||
Slug string
|
||||
Name string
|
||||
Type string
|
||||
ParentID *string
|
||||
Schema []any
|
||||
Groups []domain.UserGroup
|
||||
@@ -1428,6 +1487,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
ID: tenant.ID,
|
||||
Slug: tenant.Slug,
|
||||
Name: tenant.Name,
|
||||
Type: tenant.Type,
|
||||
ParentID: tenant.ParentID,
|
||||
}
|
||||
if s, ok := tenant.Config["userSchema"].([]any); ok {
|
||||
@@ -1558,6 +1618,10 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
continue
|
||||
}
|
||||
tenantSlug = tItem.Slug
|
||||
if isPersonalTenantForInternalDomainPolicy(&domain.Tenant{ID: tItem.ID, Slug: tItem.Slug, Type: tItem.Type}) && emailUsesInternalPersonalRestrictedDomain(email) {
|
||||
results = append(results, bulkUserResult{Email: email, Success: false, Status: "blockingError", Message: internalDomainPersonalPolicyMessage(email)})
|
||||
continue
|
||||
}
|
||||
} else if tenantSlug != "" {
|
||||
tItem, err = resolveTenantBySlug(tenantSlug)
|
||||
if err != nil {
|
||||
@@ -1565,6 +1629,10 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
continue
|
||||
}
|
||||
tenantSlug = tItem.Slug
|
||||
if isPersonalTenantForInternalDomainPolicy(&domain.Tenant{ID: tItem.ID, Slug: tItem.Slug, Type: tItem.Type}) && emailUsesInternalPersonalRestrictedDomain(email) {
|
||||
results = append(results, bulkUserResult{Email: email, Success: false, Status: "blockingError", Message: internalDomainPersonalPolicyMessage(email)})
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
for _, domainName := range bulkUserEmailDomainCandidates(item.EmailDomain, email) {
|
||||
if domainTenant, ok := resolveTenantByDomain(domainName); ok {
|
||||
@@ -1574,6 +1642,10 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
if tenantSlug == "" {
|
||||
if emailUsesInternalPersonalRestrictedDomain(email) {
|
||||
results = append(results, bulkUserResult{Email: email, Success: false, Status: "blockingError", Message: internalDomainPersonalPolicyMessage(email)})
|
||||
continue
|
||||
}
|
||||
tItem, err = createPersonalTenantItem(email)
|
||||
if err != nil {
|
||||
results = append(results, bulkUserResult{Email: email, Success: false, Message: "failed to create personal tenant"})
|
||||
@@ -1582,6 +1654,10 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
tenantSlug = tItem.Slug
|
||||
}
|
||||
}
|
||||
if isPersonalTenantForInternalDomainPolicy(&domain.Tenant{ID: tItem.ID, Slug: tItem.Slug, Type: tItem.Type}) && emailUsesInternalPersonalRestrictedDomain(email) {
|
||||
results = append(results, bulkUserResult{Email: email, Success: false, Status: "blockingError", Message: internalDomainPersonalPolicyMessage(email)})
|
||||
continue
|
||||
}
|
||||
|
||||
// Role-based access check
|
||||
if requester != nil && requester.Role != domain.RoleSuperAdmin {
|
||||
@@ -1678,7 +1754,10 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
}
|
||||
userEmail = emailEvaluation.Email
|
||||
if emailEvaluation.LocalPart != "" {
|
||||
hanmacLocalParts[emailEvaluation.LocalPart] = true
|
||||
hanmacLocalParts[emailEvaluation.LocalPart] = hanmacLocalPartOwner{
|
||||
Email: userEmail,
|
||||
Name: item.Name,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if _, _, err := domain.SplitEmailDomain(email); err != nil {
|
||||
@@ -1878,14 +1957,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
|
||||
if err := h.UserRepo.Update(c.Context(), localUser); err != nil {
|
||||
slog.Error("Failed to sync bulk user to local DB", "email", email, "error", err)
|
||||
markUserProjectionFailed(c.Context(), h.UserProjectionRepo, err)
|
||||
}
|
||||
if h.Worksmobile != nil {
|
||||
if err := h.Worksmobile.EnqueueUserUpsertIfInScope(c.Context(), *localUser); err != nil {
|
||||
slog.Warn("Failed to enqueue Worksmobile bulk user sync", "userID", localUser.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update User Login IDs in local DB
|
||||
for i := range loginIDRecords {
|
||||
loginIDRecords[i].UserID = localUser.ID
|
||||
@@ -1903,7 +1975,6 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
|
||||
if err := h.UserRepo.UpdateUserLoginIDs(c.Context(), localUser.ID, loginIDRecords); err != nil {
|
||||
slog.Error("Failed to update user login IDs in bulk", "userID", localUser.ID, "error", err)
|
||||
markUserProjectionFailed(c.Context(), h.UserProjectionRepo, err)
|
||||
results = append(results, bulkUserResult{
|
||||
Email: userEmail,
|
||||
OriginalEmail: emailEvaluation.OriginalEmail,
|
||||
@@ -2165,6 +2236,8 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
||||
// Pre-fetch tenant cache if tenantSlug is being changed.
|
||||
type tenantCacheItem struct {
|
||||
ID string
|
||||
Slug string
|
||||
Type string
|
||||
Schema []any
|
||||
}
|
||||
tenantCache := make(map[string]tenantCacheItem)
|
||||
@@ -2223,6 +2296,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
||||
if req.CompanyCode != nil {
|
||||
delete(traits, "companyCode")
|
||||
delete(traits, "companyCodes")
|
||||
var targetTenant *domain.Tenant
|
||||
|
||||
if req.IsAddTenant {
|
||||
if h.TenantService == nil {
|
||||
@@ -2234,6 +2308,10 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
||||
results = append(results, map[string]any{"id": id, "success": false, "message": "invalid tenant assignment"})
|
||||
continue
|
||||
}
|
||||
if err := h.ensureInternalDomainNotAssignedToPersonal(c.Context(), extractTraitString(traits, "email"), tenant.ID, tenant.Slug, tenant); err != nil {
|
||||
results = append(results, map[string]any{"id": id, "success": false, "message": err.Error()})
|
||||
continue
|
||||
}
|
||||
metadata := mergeUserAddTenantAppointment(traits, nil, tenant)
|
||||
if appointments, ok := metadata["additionalAppointments"]; ok {
|
||||
traits["additionalAppointments"] = appointments
|
||||
@@ -2249,12 +2327,22 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
||||
}
|
||||
} else if tItem, exists := tenantCache[*req.CompanyCode]; exists {
|
||||
traits["tenant_id"] = tItem.ID
|
||||
targetTenant = &domain.Tenant{ID: tItem.ID, Slug: tItem.Slug, Type: tItem.Type}
|
||||
} else if h.TenantService != nil {
|
||||
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), *req.CompanyCode)
|
||||
if err == nil && tenant != nil {
|
||||
tItem.ID = tenant.ID
|
||||
tItem.Slug = tenant.Slug
|
||||
tItem.Type = tenant.Type
|
||||
tenantCache[*req.CompanyCode] = tItem
|
||||
traits["tenant_id"] = tenant.ID
|
||||
targetTenant = tenant
|
||||
}
|
||||
}
|
||||
if !req.IsAddTenant {
|
||||
if err := h.ensureInternalDomainNotAssignedToPersonal(c.Context(), extractTraitString(traits, "email"), extractTraitString(traits, "tenant_id"), *req.CompanyCode, targetTenant); err != nil {
|
||||
results = append(results, map[string]any{"id": id, "success": false, "message": err.Error()})
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2306,8 +2394,8 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
||||
localUser.JobTitle = *req.JobTitle
|
||||
}
|
||||
|
||||
// Resolve TenantID if changing tenantSlug.
|
||||
if req.CompanyCode != nil && h.TenantService != nil {
|
||||
// Resolve TenantID only for representative tenant changes.
|
||||
if req.CompanyCode != nil && !req.IsAddTenant && h.TenantService != nil {
|
||||
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), *req.CompanyCode); err == nil && tenant != nil {
|
||||
localUser.TenantID = &tenant.ID
|
||||
}
|
||||
@@ -2315,7 +2403,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
||||
|
||||
_ = h.UserRepo.Update(c.Context(), localUser)
|
||||
if h.Worksmobile != nil {
|
||||
if err := h.Worksmobile.EnqueueUserUpsertIfInScope(c.Context(), *localUser); err != nil {
|
||||
if err := h.Worksmobile.EnqueueUserUpdateIfInScope(c.Context(), *localUser); err != nil {
|
||||
slog.Warn("Failed to enqueue Worksmobile bulk user update sync", "userID", localUser.ID, "error", err)
|
||||
}
|
||||
}
|
||||
@@ -2468,6 +2556,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
Status *string `json:"status"`
|
||||
TenantSlug *string `json:"tenantSlug"`
|
||||
CompanyCode *string `json:"companyCode"`
|
||||
IsPrimaryTenant bool `json:"isPrimaryTenant"`
|
||||
IsAddTenant bool `json:"isAddTenant"`
|
||||
IsRemoveTenant bool `json:"isRemoveTenant"`
|
||||
Department *string `json:"department"`
|
||||
@@ -2498,6 +2587,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
req.PrimaryTenantID = ""
|
||||
req.PrimaryTenantName = ""
|
||||
req.PrimaryTenantIsOwner = nil
|
||||
req.IsPrimaryTenant = false
|
||||
req.CompanyCode = nil
|
||||
}
|
||||
}
|
||||
@@ -2592,6 +2682,17 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
if requester == nil || domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can change user email")
|
||||
}
|
||||
targetTenantID := extractTraitString(traits, "tenant_id")
|
||||
if targetTenantID == "" {
|
||||
targetTenantID = oldTenantID
|
||||
}
|
||||
targetTenantSlug := ""
|
||||
if req.CompanyCode != nil && !req.IsRemoveTenant {
|
||||
targetTenantSlug = strings.TrimSpace(*req.CompanyCode)
|
||||
}
|
||||
if err := h.ensureHanmacEmailAllowed(c.Context(), nextEmail, targetTenantSlug, targetTenantID, userID); err != nil {
|
||||
return errorJSON(c, fiber.StatusConflict, err.Error())
|
||||
}
|
||||
if h.UserRepo != nil {
|
||||
if existing, err := h.UserRepo.FindByEmail(c.Context(), nextEmail); err == nil && existing != nil && existing.ID != userID {
|
||||
return errorJSON(c, fiber.StatusConflict, "email is already used by another user")
|
||||
@@ -2619,6 +2720,8 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
}
|
||||
if req.CompanyCode != nil {
|
||||
code := strings.TrimSpace(*req.CompanyCode)
|
||||
var resolvedTenant *domain.Tenant
|
||||
representativeTenantRequested := req.IsPrimaryTenant || strings.TrimSpace(req.PrimaryTenantID) != ""
|
||||
|
||||
if req.IsRemoveTenant {
|
||||
if h.TenantService != nil && code != "" {
|
||||
@@ -2639,10 +2742,11 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if !req.IsAddTenant {
|
||||
} else if !req.IsAddTenant && representativeTenantRequested {
|
||||
if h.TenantService != nil && code != "" {
|
||||
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code); err == nil && tenant != nil {
|
||||
traits["tenant_id"] = tenant.ID
|
||||
resolvedTenant = tenant
|
||||
} else {
|
||||
traits["tenant_id"] = ""
|
||||
}
|
||||
@@ -2652,6 +2756,9 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
if err != nil || tenant == nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid tenant assignment")
|
||||
}
|
||||
if err := h.ensureInternalDomainNotAssignedToPersonal(c.Context(), extractTraitString(traits, "email"), tenant.ID, tenant.Slug, tenant); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
req.Metadata = mergeUserAddTenantAppointment(traits, req.Metadata, tenant)
|
||||
if h.KetoOutboxRepo != nil {
|
||||
_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
|
||||
@@ -2663,6 +2770,11 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
}
|
||||
if !req.IsRemoveTenant && !req.IsAddTenant && representativeTenantRequested {
|
||||
if err := h.ensureInternalDomainNotAssignedToPersonal(c.Context(), extractTraitString(traits, "email"), extractTraitString(traits, "tenant_id"), code, resolvedTenant); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
delete(traits, "companyCode")
|
||||
delete(traits, "companyCodes")
|
||||
@@ -2719,6 +2831,9 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
traits["secondary_emails"] = subEmails
|
||||
traits["worksmobileAliasEmails"] = subEmails
|
||||
}
|
||||
if err := h.ensureInternalDomainNotAssignedToPersonal(c.Context(), extractTraitString(traits, "email"), extractTraitString(traits, "tenant_id"), "", nil); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
// [LoginID Sync based on Tenant Settings]
|
||||
// Perform sync AFTER metadata merge to ensure traits contains current values
|
||||
@@ -2773,10 +2888,9 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
ctx := context.Background() // Use request context if appropriate, but sync must finish
|
||||
if err := h.UserRepo.Update(ctx, updatedLocalUser); err != nil {
|
||||
slog.Error("[UserHandler] Failed to sync updated user to local DB", "userID", updatedLocalUser.ID, "error", err)
|
||||
markUserProjectionFailed(ctx, h.UserProjectionRepo, err)
|
||||
}
|
||||
if h.Worksmobile != nil {
|
||||
if err := h.Worksmobile.EnqueueUserUpsertIfInScope(ctx, *updatedLocalUser); err != nil {
|
||||
if err := h.Worksmobile.EnqueueUserUpdateIfInScope(ctx, *updatedLocalUser); err != nil {
|
||||
slog.Warn("[UserHandler] Failed to enqueue Worksmobile updated user sync", "userID", updatedLocalUser.ID, "error", err)
|
||||
}
|
||||
}
|
||||
@@ -2784,7 +2898,6 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
// Update User Login IDs in local DB
|
||||
if err := h.UserRepo.UpdateUserLoginIDs(ctx, updatedLocalUser.ID, loginIDRecords); err != nil {
|
||||
slog.Error("[UserHandler] Failed to update user login IDs", "userID", updatedLocalUser.ID, "error", err)
|
||||
markUserProjectionFailed(ctx, h.UserProjectionRepo, err)
|
||||
}
|
||||
|
||||
// [Keto Sync] asynchronously as it's less critical for immediate UI count
|
||||
@@ -2963,7 +3076,6 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
|
||||
if h.UserRepo != nil {
|
||||
if err := h.UserRepo.Delete(context.Background(), userID); err != nil {
|
||||
slog.Error("[UserHandler] Failed to delete local user read-model", "userID", userID, "error", err)
|
||||
markUserProjectionFailed(context.Background(), h.UserProjectionRepo, err)
|
||||
} else {
|
||||
slog.Info("[UserHandler] Successfully deleted local user read-model", "userID", userID)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
@@ -171,6 +172,7 @@ func (m *userHandlerMockKetoOutboxRepository) MarkProcessed(ctx context.Context,
|
||||
|
||||
type fakeUserHandlerWorksmobileSyncer struct {
|
||||
upserts []domain.User
|
||||
updates []domain.User
|
||||
}
|
||||
|
||||
func (f *fakeUserHandlerWorksmobileSyncer) EnqueueTenantUpsertIfInScope(ctx context.Context, tenant domain.Tenant) error {
|
||||
@@ -186,6 +188,11 @@ func (f *fakeUserHandlerWorksmobileSyncer) EnqueueUserUpsertIfInScope(ctx contex
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeUserHandlerWorksmobileSyncer) EnqueueUserUpdateIfInScope(ctx context.Context, user domain.User) error {
|
||||
f.updates = append(f.updates, user)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeUserHandlerWorksmobileSyncer) EnqueueUserDeleteIfInScope(ctx context.Context, user domain.User) error {
|
||||
return nil
|
||||
}
|
||||
@@ -206,6 +213,18 @@ func TestSanitizeUserMetadataRemovesLegacyClassificationFlags(t *testing.T) {
|
||||
assert.Contains(t, metadata, "userType")
|
||||
}
|
||||
|
||||
func TestIdentityMatchesSearchFindsSameEmailLocalPartAcrossHanmacFamilyDomains(t *testing.T) {
|
||||
identity := service.KratosIdentity{
|
||||
ID: "user-han",
|
||||
Traits: map[string]any{
|
||||
"email": "han@samaneng.com",
|
||||
"name": "안헌",
|
||||
},
|
||||
}
|
||||
|
||||
require.True(t, identityMatchesSearch(identity, "han@hanmaceng.co.kr"))
|
||||
}
|
||||
|
||||
func TestSanitizeUserRepresentativeTenantsClearsNonPublicPrimary(t *testing.T) {
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
internalTenantID := "internal-tenant"
|
||||
@@ -249,6 +268,44 @@ func TestSanitizeUserRepresentativeTenantsClearsNonPublicPrimary(t *testing.T) {
|
||||
mockTenant.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestSanitizeUserRepresentativeTenantsAllowsPublicTeamOrgPrimary(t *testing.T) {
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
teamTenantID := "team-tenant"
|
||||
metadata := map[string]any{
|
||||
"primaryTenantId": teamTenantID,
|
||||
"primaryTenantName": "IS3",
|
||||
"primaryTenantSlug": "is-3",
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{"tenantId": teamTenantID, "tenantSlug": "is-3", "isPrimary": true},
|
||||
},
|
||||
}
|
||||
appointments := []map[string]any{
|
||||
{"tenantId": teamTenantID, "tenantSlug": "is-3", "isPrimary": true},
|
||||
}
|
||||
|
||||
mockTenant.On("GetTenant", mock.Anything, teamTenantID).Return(&domain.Tenant{
|
||||
ID: teamTenantID,
|
||||
Slug: "is-3",
|
||||
Config: domain.JSONMap{
|
||||
"visibility": "public",
|
||||
"orgUnitType": "팀",
|
||||
},
|
||||
}, nil)
|
||||
|
||||
cleared, err := sanitizeUserRepresentativeTenants(context.Background(), mockTenant, metadata, appointments)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, cleared)
|
||||
assert.Equal(t, teamTenantID, metadata["primaryTenantId"])
|
||||
assert.Equal(t, "IS3", metadata["primaryTenantName"])
|
||||
assert.Equal(t, "is-3", metadata["primaryTenantSlug"])
|
||||
assert.Equal(t, true, appointments[0]["isPrimary"])
|
||||
metadataAppointments := metadata["additionalAppointments"].([]any)
|
||||
firstAppointment := metadataAppointments[0].(map[string]any)
|
||||
assert.Equal(t, true, firstAppointment["isPrimary"])
|
||||
mockTenant.AssertExpectations(t)
|
||||
}
|
||||
|
||||
type MockTenantServiceForUser struct {
|
||||
mock.Mock
|
||||
service.TenantService
|
||||
@@ -292,11 +349,16 @@ func (m *MockTenantServiceForUser) ListManageableTenants(ctx context.Context, us
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForUser) ListTenants(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) {
|
||||
args := m.Called(ctx, limit, offset, parentID, search)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Get(1).(int64), args.Error(2)
|
||||
for _, call := range m.ExpectedCalls {
|
||||
if call.Method == "ListTenants" {
|
||||
args := m.Called(ctx, limit, offset, parentID, search)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Get(1).(int64), args.Error(2)
|
||||
}
|
||||
return args.Get(0).([]domain.Tenant), args.Get(1).(int64), args.Error(2)
|
||||
}
|
||||
}
|
||||
return args.Get(0).([]domain.Tenant), args.Get(1).(int64), args.Error(2)
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForUser) ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) {
|
||||
@@ -659,6 +721,59 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestUserHandler_BulkCreateUsersDoesNotAutoProvisionWorksmobileUsers(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockOry := new(MockOryProvider)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
mockRepo := new(MockUserRepoForHandler)
|
||||
worksmobile := &fakeUserHandlerWorksmobileSyncer{}
|
||||
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
OryProvider: mockOry,
|
||||
TenantService: mockTenant,
|
||||
UserRepo: mockRepo,
|
||||
Worksmobile: worksmobile,
|
||||
}
|
||||
|
||||
app.Post("/users/bulk", h.BulkCreateUsers)
|
||||
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
|
||||
ID: "t-123",
|
||||
Slug: "test-tenant",
|
||||
Config: domain.JSONMap{},
|
||||
}, nil).Maybe()
|
||||
mockTenant.On("GetTenant", mock.Anything, "t-123").Return(&domain.Tenant{
|
||||
ID: "t-123",
|
||||
Slug: "test-tenant",
|
||||
Config: domain.JSONMap{},
|
||||
}, nil).Maybe()
|
||||
mockTenant.On("ListTenants", mock.Anything, 10000, 0, "", "").Return([]domain.Tenant{}, int64(0), nil).Maybe()
|
||||
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
|
||||
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, mock.Anything).Return("", nil).Maybe()
|
||||
mockOry.On("CreateUser", mock.Anything, mock.Anything).Return("some-id", nil).Once()
|
||||
|
||||
payload := map[string]any{
|
||||
"users": []map[string]any{
|
||||
{
|
||||
"email": "user1@test.com",
|
||||
"name": "User One",
|
||||
"tenantSlug": "test-tenant",
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, _ := app.Test(req)
|
||||
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
assert.Empty(t, worksmobile.upserts)
|
||||
mockOry.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_BulkCreateUsersRejectsRequestedUserID(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
@@ -1135,6 +1250,84 @@ func TestUserHandler_ListUsersWarmsIdentityMirrorFromKratosWhenMirrorEmpty(t *te
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_ListUsersTenantSlugFilterIncludesAdditionalAppointments(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
createdAt := time.Date(2026, 6, 15, 4, 55, 0, 0, time.UTC)
|
||||
primaryTenantID := "primary-tenant-id"
|
||||
targetTenantID := "target-tenant-id"
|
||||
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
TenantService: mockTenant,
|
||||
}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
Role: domain.RoleSuperAdmin,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/users", h.ListUsers)
|
||||
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "target-team").Return(&domain.Tenant{
|
||||
ID: targetTenantID,
|
||||
Slug: "target-team",
|
||||
Name: "Target Team",
|
||||
}, nil).Once()
|
||||
mockTenant.On("GetTenant", mock.Anything, primaryTenantID).Return(&domain.Tenant{
|
||||
ID: primaryTenantID,
|
||||
Slug: "primary-team",
|
||||
Name: "Primary Team",
|
||||
}, nil).Maybe()
|
||||
mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{
|
||||
{
|
||||
ID: "additional-member",
|
||||
State: "active",
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
Traits: map[string]any{
|
||||
"email": "additional@example.com",
|
||||
"name": "Additional Member",
|
||||
"tenant_id": primaryTenantID,
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": targetTenantID,
|
||||
"tenantSlug": "target-team",
|
||||
"tenantName": "Target Team",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "outside-member",
|
||||
State: "active",
|
||||
CreatedAt: createdAt.Add(-time.Minute),
|
||||
UpdatedAt: createdAt,
|
||||
Traits: map[string]any{
|
||||
"email": "outside@example.com",
|
||||
"name": "Outside Member",
|
||||
"tenant_id": primaryTenantID,
|
||||
},
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
req := httptest.NewRequest("GET", "/users?tenantSlug=target-team&limit=20&offset=0", nil)
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var res userListResponse
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
|
||||
require.Equal(t, int64(1), res.Total)
|
||||
require.Len(t, res.Items, 1)
|
||||
require.Equal(t, "additional-member", res.Items[0].ID)
|
||||
require.Equal(t, "additional@example.com", res.Items[0].Email)
|
||||
mockKratos.AssertExpectations(t)
|
||||
mockTenant.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_WarmIdentityMirrorRebuildsRedisFromKratos(t *testing.T) {
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{
|
||||
@@ -1540,6 +1733,91 @@ func TestUserHandler_BulkCreateUsers_HanmacEmailPolicy(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestNextAvailableHanmacLocalPartIncrementsTrailingNumericSuffix(t *testing.T) {
|
||||
used := map[string]hanmacLocalPartOwner{
|
||||
"jhchoi11": {Email: "jhchoi11@hanmaceng.co.kr"},
|
||||
"jhchoi12": {Email: "jhchoi12@hallasanup.com"},
|
||||
"yskim11": {Email: "yskim11@hanmaceng.co.kr"},
|
||||
"yskim12": {Email: "yskim12@hanmaceng.co.kr"},
|
||||
"yskim13": {Email: "yskim13@hanmaceng.co.kr"},
|
||||
}
|
||||
|
||||
assert.Equal(t, "jhchoi13", nextAvailableHanmacLocalPart("jhchoi11", used))
|
||||
assert.Equal(t, "yskim14", nextAvailableHanmacLocalPart("yskim11", used))
|
||||
assert.Equal(t, "mjkim", nextAvailableHanmacLocalPart("mjkim", used))
|
||||
}
|
||||
|
||||
func TestUserHandler_BulkCreateUsersRejectsInternalDomainPersonalTenant(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockOry := new(MockOryProvider)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
OryProvider: mockOry,
|
||||
TenantService: mockTenant,
|
||||
}
|
||||
app.Post("/users/bulk", h.BulkCreateUsers)
|
||||
|
||||
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "personal-team").Return(&domain.Tenant{
|
||||
ID: "personal-tenant-id",
|
||||
Slug: "personal-team",
|
||||
Type: domain.TenantTypePersonal,
|
||||
Status: domain.TenantStatusActive,
|
||||
Config: domain.JSONMap{},
|
||||
}, nil)
|
||||
|
||||
body := `{"users":[{"email":"internal@jangheon.co.kr","name":"Internal User","tenantSlug":"personal-team"}]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/users/bulk", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var payload map[string][]map[string]any
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload))
|
||||
require.Len(t, payload["results"], 1)
|
||||
require.Equal(t, false, payload["results"][0]["success"])
|
||||
require.Contains(t, payload["results"][0]["message"], "내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다")
|
||||
mockOry.AssertNotCalled(t, "CreateUser", mock.Anything, mock.Anything)
|
||||
mockKratos.AssertNotCalled(t, "GetIdentity", mock.Anything, mock.Anything)
|
||||
mockTenant.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_BulkCreateUsersRejectsInternalDomainPersonalAutoTenant(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockOry := new(MockOryProvider)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
OryProvider: mockOry,
|
||||
TenantService: mockTenant,
|
||||
}
|
||||
app.Post("/users/bulk", h.BulkCreateUsers)
|
||||
|
||||
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
|
||||
mockTenant.On("GetTenantByDomain", mock.Anything, "pre-cast.co.kr").Return(nil, nil)
|
||||
|
||||
body := `{"users":[{"email":"internal@pre-cast.co.kr","name":"Internal User"}]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/users/bulk", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var payload map[string][]map[string]any
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload))
|
||||
require.Len(t, payload["results"], 1)
|
||||
require.Equal(t, false, payload["results"][0]["success"])
|
||||
require.Contains(t, payload["results"][0]["message"], "내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다")
|
||||
mockTenant.AssertNotCalled(t, "RegisterTenant", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
mockOry.AssertNotCalled(t, "CreateUser", mock.Anything, mock.Anything)
|
||||
mockKratos.AssertNotCalled(t, "GetIdentity", mock.Anything, mock.Anything)
|
||||
mockTenant.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_CreateUser_HanmacEmailPolicyBlocksDuplicateLocalPart(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
@@ -1570,7 +1848,7 @@ func TestUserHandler_CreateUser_HanmacEmailPolicyBlocksDuplicateLocalPart(t *tes
|
||||
}, nil).Maybe()
|
||||
mockTenant.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Maybe()
|
||||
mockRepo.On("FindByTenantIDs", mock.Anything, []string{rootID, companyID}).Return([]domain.User{
|
||||
{Email: "han@hanmaceng.co.kr", CompanyCode: "hanmac", TenantID: &companyID},
|
||||
{ID: "owner-user", Email: "han@hanmaceng.co.kr", Name: "안헌", CompanyCode: "hanmac", TenantID: &companyID},
|
||||
}, nil).Maybe()
|
||||
mockRepo.On("FindByCompanyCodes", mock.Anything, []string{"hanmac-family", "hanmac"}).Return([]domain.User{}, nil).Maybe()
|
||||
|
||||
@@ -1583,12 +1861,23 @@ func TestUserHandler_CreateUser_HanmacEmailPolicyBlocksDuplicateLocalPart(t *tes
|
||||
req := httptest.NewRequest("POST", "/users", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
var logBuffer bytes.Buffer
|
||||
previousLogger := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&logBuffer, nil)))
|
||||
t.Cleanup(func() { slog.SetDefault(previousLogger) })
|
||||
|
||||
resp, _ := app.Test(req)
|
||||
assert.Equal(t, http.StatusConflict, resp.StatusCode)
|
||||
|
||||
var result map[string]any
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
assert.Contains(t, result["error"].(string), "한맥가족 내에서 이미 사용 중인 이메일 ID입니다.")
|
||||
assert.Contains(t, result["error"].(string), "han")
|
||||
assert.Contains(t, result["error"].(string), "han@hanmaceng.co.kr")
|
||||
assert.Contains(t, result["error"].(string), "안헌")
|
||||
assert.Contains(t, logBuffer.String(), "hanmac create email local-part conflict")
|
||||
assert.Contains(t, logBuffer.String(), "owner-user")
|
||||
assert.Contains(t, logBuffer.String(), "han@hanmaceng.co.kr")
|
||||
mockOry.AssertNotCalled(t, "CreateUser")
|
||||
}
|
||||
|
||||
@@ -1635,9 +1924,9 @@ func TestUserHandler_BulkUpdateUsers(t *testing.T) {
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
results := result["results"].([]any)
|
||||
assert.True(t, results[0].(map[string]any)["success"].(bool))
|
||||
assert.Len(t, worksmobile.upserts, 1)
|
||||
assert.Equal(t, "u-1", worksmobile.upserts[0].ID)
|
||||
assert.Equal(t, domain.UserStatusPreboarding, worksmobile.upserts[0].Status)
|
||||
assert.Len(t, worksmobile.updates, 1)
|
||||
assert.Equal(t, "u-1", worksmobile.updates[0].ID)
|
||||
assert.Equal(t, domain.UserStatusPreboarding, worksmobile.updates[0].Status)
|
||||
})
|
||||
|
||||
t.Run("Success - Super admin assigns legacy roles as user", func(t *testing.T) {
|
||||
@@ -1682,10 +1971,12 @@ func TestUserHandler_BulkUpdateUsersAddTenantMembership(t *testing.T) {
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
mockOutbox := new(userHandlerMockKetoOutboxRepository)
|
||||
mockRepo := new(MockUserRepoForHandler)
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
TenantService: mockTenant,
|
||||
KetoOutboxRepo: mockOutbox,
|
||||
UserRepo: mockRepo,
|
||||
}
|
||||
app.Put("/users/bulk", func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
|
||||
@@ -1735,6 +2026,12 @@ func TestUserHandler_BulkUpdateUsersAddTenantMembership(t *testing.T) {
|
||||
"additionalAppointments": []any{map[string]any{"tenantId": "team-a-id", "tenantSlug": "team-a", "tenantName": "Team A"}},
|
||||
},
|
||||
}, nil).Once()
|
||||
mockRepo.On("Update", mock.Anything, mock.MatchedBy(func(user *domain.User) bool {
|
||||
return user != nil &&
|
||||
user.ID == "u-1" &&
|
||||
user.TenantID != nil &&
|
||||
*user.TenantID == "primary-tenant-id"
|
||||
})).Return(nil).Once()
|
||||
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
|
||||
return entry.Namespace == "Tenant" &&
|
||||
entry.Object == "team-a-id" &&
|
||||
@@ -1753,6 +2050,7 @@ func TestUserHandler_BulkUpdateUsersAddTenantMembership(t *testing.T) {
|
||||
mockKratos.AssertExpectations(t)
|
||||
mockTenant.AssertExpectations(t)
|
||||
mockOutbox.AssertExpectations(t)
|
||||
mockRepo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_BulkDeleteUsers(t *testing.T) {
|
||||
@@ -2258,6 +2556,73 @@ func TestUserHandler_UpdateUser_AllowsSuperAdminEmailChange(t *testing.T) {
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUserRejectsHanmacDuplicateLocalPart(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
mockRepo := new(MockUserRepoForHandler)
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
TenantService: mockTenant,
|
||||
UserRepo: mockRepo,
|
||||
}
|
||||
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)
|
||||
})
|
||||
|
||||
rootID := "hanmac-family-id"
|
||||
targetTenantID := "brsw-id"
|
||||
ownerTenantID := "hanmac-id"
|
||||
tenants := []domain.Tenant{
|
||||
{ID: rootID, Slug: "hanmac-family", Name: "한맥가족"},
|
||||
{ID: targetTenantID, Slug: "brsw", Name: "바론", ParentID: &rootID},
|
||||
{ID: ownerTenantID, Slug: "hanmac", Name: "한맥기술", ParentID: &rootID},
|
||||
}
|
||||
userID := "target-user-id"
|
||||
|
||||
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
|
||||
ID: userID,
|
||||
Traits: map[string]interface{}{
|
||||
"email": "jmhwang11@brsw.kr",
|
||||
"name": "황재민",
|
||||
"role": domain.RoleUser,
|
||||
"tenant_id": targetTenantID,
|
||||
},
|
||||
State: "active",
|
||||
}, nil).Maybe()
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.Anything, mock.Anything).Return(&service.KratosIdentity{
|
||||
ID: userID,
|
||||
State: "active",
|
||||
}, nil).Maybe()
|
||||
mockTenant.On("GetTenant", mock.Anything, targetTenantID).Return(&tenants[1], nil).Maybe()
|
||||
mockTenant.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Maybe()
|
||||
mockRepo.On("FindByTenantIDs", mock.Anything, mock.MatchedBy(func(ids []string) bool {
|
||||
return slices.Contains(ids, targetTenantID) && slices.Contains(ids, ownerTenantID)
|
||||
})).Return([]domain.User{
|
||||
{ID: "owner-user-id", Email: "jmhwang2@hanmaceng.co.kr", Name: "황지만", TenantID: &ownerTenantID, CompanyCode: "hanmac"},
|
||||
}, nil).Maybe()
|
||||
mockRepo.On("FindByCompanyCodes", mock.Anything, mock.MatchedBy(func(slugs []string) bool {
|
||||
return slices.Contains(slugs, "brsw") && slices.Contains(slugs, "hanmac")
|
||||
})).Return([]domain.User{}, nil).Maybe()
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"email": "jmhwang2@brsw.kr"})
|
||||
req := httptest.NewRequest(http.MethodPut, "/users/"+userID, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusConflict, resp.StatusCode)
|
||||
|
||||
var result map[string]any
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
|
||||
message, _ := result["error"].(string)
|
||||
assert.Contains(t, message, "한맥가족 내에서 이미 사용 중인 이메일 ID입니다.")
|
||||
assert.Contains(t, message, "jmhwang2")
|
||||
assert.Contains(t, message, "jmhwang2@hanmaceng.co.kr")
|
||||
mockKratos.AssertNotCalled(t, "UpdateIdentity", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUserClearsWorksmobileAliasMetadataWhenSubEmailIsCleared(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
@@ -2769,9 +3134,7 @@ func TestUserHandler_CreateUser_UsesAdditionalAppointmentAsPrimaryTenant(t *test
|
||||
resp, _ := app.Test(req)
|
||||
|
||||
assert.Equal(t, 201, resp.StatusCode)
|
||||
assert.Len(t, worksmobile.upserts, 1)
|
||||
assert.Equal(t, "some-id", worksmobile.upserts[0].ID)
|
||||
assert.Equal(t, tenantID, *worksmobile.upserts[0].TenantID)
|
||||
assert.Empty(t, worksmobile.upserts)
|
||||
mockOry.AssertExpectations(t)
|
||||
}
|
||||
|
||||
@@ -2850,6 +3213,66 @@ func TestUserHandler_CreateUser_AutoCreatesPersonalTenantWhenAssignmentMissing(t
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_CreateUserRejectsInternalDomainPersonalAutoTenant(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockOry := new(MockOryProvider)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
OryProvider: mockOry,
|
||||
TenantService: mockTenant,
|
||||
}
|
||||
app.Post("/users", h.CreateUser)
|
||||
|
||||
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil).Maybe()
|
||||
|
||||
body := `{"email":"internal@hanmaceng.co.kr","password":"Password1!","name":"Internal User"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
mockTenant.AssertNotCalled(t, "RegisterTenant", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
mockOry.AssertNotCalled(t, "CreateUser", mock.Anything, mock.Anything)
|
||||
mockKratos.AssertNotCalled(t, "GetIdentity", mock.Anything, mock.Anything)
|
||||
}
|
||||
|
||||
func TestUserHandler_CreateUserRejectsInternalDomainExplicitPersonalTenant(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockOry := new(MockOryProvider)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
OryProvider: mockOry,
|
||||
TenantService: mockTenant,
|
||||
}
|
||||
app.Post("/users", h.CreateUser)
|
||||
|
||||
personalTenantID := "personal-tenant-id"
|
||||
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil).Maybe()
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "personal-team").Return(&domain.Tenant{
|
||||
ID: personalTenantID,
|
||||
Slug: "personal-team",
|
||||
Type: domain.TenantTypePersonal,
|
||||
Status: domain.TenantStatusActive,
|
||||
Config: domain.JSONMap{},
|
||||
}, nil)
|
||||
|
||||
body := `{"email":"internal@samaneng.com","password":"Password1!","name":"Internal User","tenantSlug":"personal-team"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
mockOry.AssertNotCalled(t, "CreateUser", mock.Anything, mock.Anything)
|
||||
mockKratos.AssertNotCalled(t, "GetIdentity", mock.Anything, mock.Anything)
|
||||
mockTenant.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_CreateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
@@ -2925,9 +3348,9 @@ func TestUserHandler_UpdateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing
|
||||
ID: "new-tenant-id",
|
||||
Slug: "new-tenant",
|
||||
}, nil).Maybe()
|
||||
mockTenant.On("GetTenant", mock.Anything, "new-tenant-id").Return(&domain.Tenant{
|
||||
ID: "new-tenant-id",
|
||||
Slug: "new-tenant",
|
||||
mockTenant.On("GetTenant", mock.Anything, "old-tenant-id").Return(&domain.Tenant{
|
||||
ID: "old-tenant-id",
|
||||
Slug: "old-tenant",
|
||||
Config: domain.JSONMap{},
|
||||
}, nil).Maybe()
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.Anything, mock.Anything).Return(&service.KratosIdentity{
|
||||
@@ -2936,7 +3359,7 @@ func TestUserHandler_UpdateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing
|
||||
Traits: map[string]any{
|
||||
"email": "user@test.com",
|
||||
"name": "Test User",
|
||||
"tenant_id": "new-tenant-id",
|
||||
"tenant_id": "old-tenant-id",
|
||||
"role": domain.RoleUser,
|
||||
},
|
||||
}, nil).Maybe()
|
||||
@@ -2952,6 +3375,100 @@ func TestUserHandler_UpdateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUserRejectsInternalDomainMoveToPersonalTenant(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
TenantService: mockTenant,
|
||||
}
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "admin-id",
|
||||
Role: domain.RoleSuperAdmin,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Put("/users/:id", h.UpdateUser)
|
||||
|
||||
identity := &service.KratosIdentity{
|
||||
ID: "user-id",
|
||||
State: "active",
|
||||
Traits: map[string]any{
|
||||
"email": "user@brsw.kr",
|
||||
"name": "Internal User",
|
||||
"tenant_id": "company-tenant-id",
|
||||
"role": domain.RoleUser,
|
||||
},
|
||||
}
|
||||
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(identity, nil)
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "personal-team").Return(&domain.Tenant{
|
||||
ID: "personal-tenant-id",
|
||||
Slug: "personal-team",
|
||||
Type: domain.TenantTypePersonal,
|
||||
Status: domain.TenantStatusActive,
|
||||
Config: domain.JSONMap{},
|
||||
}, nil)
|
||||
|
||||
body := `{"tenantSlug":"personal-team"}`
|
||||
req := httptest.NewRequest(http.MethodPut, "/users/user-id", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
mockKratos.AssertNotCalled(t, "UpdateIdentity", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
mockTenant.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUserRejectsPersonalTenantInternalDomainEmail(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
TenantService: mockTenant,
|
||||
}
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "admin-id",
|
||||
Role: domain.RoleSuperAdmin,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Put("/users/:id", h.UpdateUser)
|
||||
|
||||
identity := &service.KratosIdentity{
|
||||
ID: "user-id",
|
||||
State: "active",
|
||||
Traits: map[string]any{
|
||||
"email": "external@example.com",
|
||||
"name": "External User",
|
||||
"tenant_id": "personal-tenant-id",
|
||||
"role": domain.RoleUser,
|
||||
},
|
||||
}
|
||||
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(identity, nil)
|
||||
mockTenant.On("GetTenant", mock.Anything, "personal-tenant-id").Return(&domain.Tenant{
|
||||
ID: "personal-tenant-id",
|
||||
Slug: "personal-team",
|
||||
Type: domain.TenantTypePersonal,
|
||||
Status: domain.TenantStatusActive,
|
||||
Config: domain.JSONMap{},
|
||||
}, nil)
|
||||
|
||||
body := `{"email":"user@hallasanup.com"}`
|
||||
req := httptest.NewRequest(http.MethodPut, "/users/user-id", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
mockKratos.AssertNotCalled(t, "UpdateIdentity", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
mockTenant.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUserAddTenantKeepsPrimaryAndAddsAppointment(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
@@ -3272,6 +3789,163 @@ func TestUserHandler_BulkUpdateUsersAcceptsTenantSlugAndRejectsCompanyCode(t *te
|
||||
require.Contains(t, legacyErr.Error(), "companyCode is deprecated")
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUserTenantSlugWithoutPrimaryFlagKeepsRepresentativeTenant(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
TenantService: mockTenant,
|
||||
}
|
||||
app.Put("/users/:id", h.UpdateUser)
|
||||
|
||||
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(&service.KratosIdentity{
|
||||
ID: "user-id",
|
||||
State: "active",
|
||||
Traits: map[string]any{
|
||||
"email": "user@test.com",
|
||||
"name": "Test User",
|
||||
"tenant_id": "old-tenant-id",
|
||||
"role": domain.RoleUser,
|
||||
},
|
||||
}, nil).Once()
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "new-tenant").Return(&domain.Tenant{
|
||||
ID: "new-tenant-id",
|
||||
Slug: "new-tenant",
|
||||
}, nil).Maybe()
|
||||
mockTenant.On("GetTenant", mock.Anything, "old-tenant-id").Return(&domain.Tenant{
|
||||
ID: "old-tenant-id",
|
||||
Slug: "old-tenant",
|
||||
Config: domain.JSONMap{},
|
||||
}, nil).Maybe()
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.MatchedBy(func(traits map[string]any) bool {
|
||||
return extractTraitString(traits, "tenant_id") == "old-tenant-id"
|
||||
}), mock.Anything).Return(&service.KratosIdentity{
|
||||
ID: "user-id",
|
||||
State: "active",
|
||||
Traits: map[string]any{
|
||||
"email": "user@test.com",
|
||||
"name": "Test User",
|
||||
"tenant_id": "old-tenant-id",
|
||||
"role": domain.RoleUser,
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
body := `{"tenantSlug":"new-tenant"}`
|
||||
req := httptest.NewRequest(http.MethodPut, "/users/user-id", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
mockKratos.AssertExpectations(t)
|
||||
mockTenant.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUserPrimaryTenantFlagChangesRepresentativeTenant(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
TenantService: mockTenant,
|
||||
}
|
||||
app.Put("/users/:id", h.UpdateUser)
|
||||
|
||||
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(&service.KratosIdentity{
|
||||
ID: "user-id",
|
||||
State: "active",
|
||||
Traits: map[string]any{
|
||||
"email": "user@test.com",
|
||||
"name": "Test User",
|
||||
"tenant_id": "old-tenant-id",
|
||||
"role": domain.RoleUser,
|
||||
},
|
||||
}, nil).Once()
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "new-tenant").Return(&domain.Tenant{
|
||||
ID: "new-tenant-id",
|
||||
Slug: "new-tenant",
|
||||
}, nil).Maybe()
|
||||
mockTenant.On("GetTenant", mock.Anything, "new-tenant-id").Return(&domain.Tenant{
|
||||
ID: "new-tenant-id",
|
||||
Slug: "new-tenant",
|
||||
Config: domain.JSONMap{},
|
||||
}, nil).Maybe()
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.MatchedBy(func(traits map[string]any) bool {
|
||||
return extractTraitString(traits, "tenant_id") == "new-tenant-id"
|
||||
}), mock.Anything).Return(&service.KratosIdentity{
|
||||
ID: "user-id",
|
||||
State: "active",
|
||||
Traits: map[string]any{
|
||||
"email": "user@test.com",
|
||||
"name": "Test User",
|
||||
"tenant_id": "new-tenant-id",
|
||||
"role": domain.RoleUser,
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
body := `{"tenantSlug":"new-tenant","isPrimaryTenant":true}`
|
||||
req := httptest.NewRequest(http.MethodPut, "/users/user-id", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
mockKratos.AssertExpectations(t)
|
||||
mockTenant.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_BulkUpdateUsersRejectsInternalDomainMoveToPersonalTenant(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
TenantService: mockTenant,
|
||||
}
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "admin-id",
|
||||
Role: domain.RoleSuperAdmin,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Put("/users/bulk", h.BulkUpdateUsers)
|
||||
|
||||
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(&service.KratosIdentity{
|
||||
ID: "user-id",
|
||||
State: "active",
|
||||
Traits: map[string]any{
|
||||
"email": "user@brsw.kr",
|
||||
"name": "Internal User",
|
||||
"tenant_id": "company-tenant-id",
|
||||
"role": domain.RoleUser,
|
||||
},
|
||||
}, nil)
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "personal-team").Return(&domain.Tenant{
|
||||
ID: "personal-tenant-id",
|
||||
Slug: "personal-team",
|
||||
Type: domain.TenantTypePersonal,
|
||||
Status: domain.TenantStatusActive,
|
||||
Config: domain.JSONMap{},
|
||||
}, nil)
|
||||
|
||||
body := `{"userIds":["user-id"],"tenantSlug":"personal-team"}`
|
||||
req := httptest.NewRequest(http.MethodPut, "/users/bulk", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var payload map[string][]map[string]any
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload))
|
||||
require.Len(t, payload["results"], 1)
|
||||
require.Equal(t, false, payload["results"][0]["success"])
|
||||
require.Contains(t, payload["results"][0]["message"], "내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다")
|
||||
mockKratos.AssertNotCalled(t, "UpdateIdentity", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
mockTenant.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_MapToLocalUserKeepsRoleAndGradeSeparate(t *testing.T) {
|
||||
handler := &UserHandler{}
|
||||
identity := service.KratosIdentity{
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/repository"
|
||||
"context"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
func markUserProjectionFailed(ctx context.Context, repo repository.UserProjectionRepository, syncErr error) {
|
||||
if repo == nil || syncErr == nil {
|
||||
return
|
||||
}
|
||||
if err := repo.MarkFailed(ctx, syncErr); err != nil {
|
||||
slog.Error("Failed to mark user projection as failed", "syncError", syncErr, "error", err)
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ func TestMain(m *testing.M) {
|
||||
}
|
||||
|
||||
// Auto-migrate
|
||||
err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, &domain.UserLoginID{}, &domain.UserProjectionState{}, &domain.ClientConsent{}, &domain.RPUserMetadata{}, &domain.RPUsageEvent{}, &domain.KetoOutbox{}, &domain.WorksmobileOutbox{})
|
||||
err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, &domain.UserLoginID{}, &domain.ClientConsent{}, &domain.RPUserMetadata{}, &domain.RPUsageEvent{}, &domain.KetoOutbox{}, &domain.WorksmobileOutbox{})
|
||||
if err != nil {
|
||||
log.Fatalf("failed to migrate database: %s", err)
|
||||
}
|
||||
|
||||
@@ -26,26 +26,76 @@ WHERE u.deleted_at IS NULL
|
||||
}
|
||||
|
||||
func ClearOrphanUserTenantMemberships(ctx context.Context, db *gorm.DB) (int64, error) {
|
||||
result := db.WithContext(ctx).Exec(`
|
||||
userResult := db.WithContext(ctx).Exec(`
|
||||
WITH orphan_users AS (
|
||||
SELECT u.id
|
||||
SELECT u.id AS user_id,
|
||||
replacement.id AS replacement_tenant_id
|
||||
FROM users AS u
|
||||
WHERE u.deleted_at IS NULL
|
||||
AND (
|
||||
u.tenant_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM tenants AS t
|
||||
WHERE t.id = u.tenant_id
|
||||
AND t.deleted_at IS NULL
|
||||
JOIN tenants AS deleted_tenant
|
||||
ON deleted_tenant.id = u.tenant_id
|
||||
AND deleted_tenant.deleted_at IS NOT NULL
|
||||
JOIN LATERAL (
|
||||
WITH RECURSIVE ancestors AS (
|
||||
SELECT parent.id, parent.parent_id, parent.deleted_at, 1 AS depth
|
||||
FROM tenants AS parent
|
||||
WHERE parent.id = deleted_tenant.parent_id
|
||||
UNION ALL
|
||||
SELECT parent.id, parent.parent_id, parent.deleted_at, ancestors.depth + 1
|
||||
FROM tenants AS parent
|
||||
JOIN ancestors ON parent.id = ancestors.parent_id
|
||||
WHERE ancestors.parent_id IS NOT NULL
|
||||
AND ancestors.parent_id <> ancestors.id
|
||||
)
|
||||
)
|
||||
SELECT id
|
||||
FROM ancestors
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY depth
|
||||
LIMIT 1
|
||||
) AS replacement ON true
|
||||
WHERE u.deleted_at IS NULL
|
||||
AND u.tenant_id IS NOT NULL
|
||||
)
|
||||
UPDATE users AS u
|
||||
SET tenant_id = NULL,
|
||||
SET tenant_id = ou.replacement_tenant_id,
|
||||
updated_at = NOW()
|
||||
FROM orphan_users AS ou
|
||||
WHERE u.id = ou.id
|
||||
WHERE u.id = ou.user_id
|
||||
`)
|
||||
return result.RowsAffected, result.Error
|
||||
if userResult.Error != nil {
|
||||
return userResult.RowsAffected, userResult.Error
|
||||
}
|
||||
|
||||
loginResult := db.WithContext(ctx).Exec(`
|
||||
WITH orphan_login_ids AS (
|
||||
SELECT uli.id AS login_id,
|
||||
replacement.id AS replacement_tenant_id
|
||||
FROM user_login_ids AS uli
|
||||
JOIN tenants AS deleted_tenant
|
||||
ON deleted_tenant.id = uli.tenant_id
|
||||
AND deleted_tenant.deleted_at IS NOT NULL
|
||||
JOIN LATERAL (
|
||||
WITH RECURSIVE ancestors AS (
|
||||
SELECT parent.id, parent.parent_id, parent.deleted_at, 1 AS depth
|
||||
FROM tenants AS parent
|
||||
WHERE parent.id = deleted_tenant.parent_id
|
||||
UNION ALL
|
||||
SELECT parent.id, parent.parent_id, parent.deleted_at, ancestors.depth + 1
|
||||
FROM tenants AS parent
|
||||
JOIN ancestors ON parent.id = ancestors.parent_id
|
||||
WHERE ancestors.parent_id IS NOT NULL
|
||||
AND ancestors.parent_id <> ancestors.id
|
||||
)
|
||||
SELECT id
|
||||
FROM ancestors
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY depth
|
||||
LIMIT 1
|
||||
) AS replacement ON true
|
||||
)
|
||||
UPDATE user_login_ids AS uli
|
||||
SET tenant_id = oli.replacement_tenant_id
|
||||
FROM orphan_login_ids AS oli
|
||||
WHERE uli.id = oli.login_id
|
||||
`)
|
||||
return userResult.RowsAffected + loginResult.RowsAffected, loginResult.Error
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestClearOrphanUserTenantMemberships(t *testing.T) {
|
||||
require.NoError(t, testDB.Unscoped().Where("slug IN ?", []string{"orphan-active", "orphan-deleted"}).Delete(&domain.Tenant{}).Error)
|
||||
|
||||
activeTenant := &domain.Tenant{Name: "Active Tenant", Slug: "orphan-active", Type: domain.TenantTypeCompany}
|
||||
deletedTenant := &domain.Tenant{Name: "Deleted Tenant", Slug: "orphan-deleted", Type: domain.TenantTypeCompany}
|
||||
deletedTenant := &domain.Tenant{Name: "Deleted Tenant", Slug: "orphan-deleted", Type: domain.TenantTypeUserGroup, ParentID: &activeTenant.ID}
|
||||
require.NoError(t, tenantRepo.Create(ctx, activeTenant))
|
||||
require.NoError(t, tenantRepo.Create(ctx, deletedTenant))
|
||||
require.NoError(t, testDB.Delete(&domain.Tenant{}, "id = ?", deletedTenant.ID).Error)
|
||||
@@ -39,6 +39,13 @@ func TestClearOrphanUserTenantMemberships(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, repo.Create(ctx, activeUser))
|
||||
require.NoError(t, repo.Create(ctx, orphanUser))
|
||||
loginID := &domain.UserLoginID{
|
||||
UserID: orphanUser.ID,
|
||||
TenantID: deletedTenant.ID,
|
||||
FieldKey: "employee_number",
|
||||
LoginID: "orphan-membership-login",
|
||||
}
|
||||
require.NoError(t, testDB.Create(loginID).Error)
|
||||
|
||||
count, err := CountOrphanUserTenantMemberships(ctx, testDB)
|
||||
require.NoError(t, err)
|
||||
@@ -46,7 +53,7 @@ func TestClearOrphanUserTenantMemberships(t *testing.T) {
|
||||
|
||||
affected, err := ClearOrphanUserTenantMemberships(ctx, testDB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), affected)
|
||||
assert.Equal(t, int64(2), affected)
|
||||
|
||||
foundActive, err := repo.FindByEmail(ctx, activeUser.Email)
|
||||
require.NoError(t, err)
|
||||
@@ -56,7 +63,12 @@ func TestClearOrphanUserTenantMemberships(t *testing.T) {
|
||||
|
||||
foundOrphan, err := repo.FindByEmail(ctx, orphanUser.Email)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, foundOrphan.TenantID)
|
||||
require.NotNil(t, foundOrphan.TenantID)
|
||||
assert.Equal(t, activeTenant.ID, *foundOrphan.TenantID)
|
||||
|
||||
var foundLogin domain.UserLoginID
|
||||
require.NoError(t, testDB.First(&foundLogin, "id = ?", loginID.ID).Error)
|
||||
assert.Equal(t, activeTenant.ID, foundLogin.TenantID)
|
||||
|
||||
count, err = CountOrphanUserTenantMemberships(ctx, testDB)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type UserProjectionRepository interface {
|
||||
IsReady(ctx context.Context) (bool, error)
|
||||
GetStatus(ctx context.Context) (domain.UserProjectionStatus, error)
|
||||
CountTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error)
|
||||
CountTenantMembersRecursive(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error)
|
||||
ReplaceAllFromKratos(ctx context.Context, users []domain.User) error
|
||||
MarkFailed(ctx context.Context, syncErr error) error
|
||||
}
|
||||
|
||||
type userProjectionRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewUserProjectionRepository(db *gorm.DB) UserProjectionRepository {
|
||||
return &userProjectionRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *userProjectionRepository) IsReady(ctx context.Context) (bool, error) {
|
||||
status, err := r.GetStatus(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return status.Ready, nil
|
||||
}
|
||||
|
||||
func (r *userProjectionRepository) GetStatus(ctx context.Context) (domain.UserProjectionStatus, error) {
|
||||
var projectedUsers int64
|
||||
if err := r.db.WithContext(ctx).Model(&domain.User{}).Count(&projectedUsers).Error; err != nil {
|
||||
return domain.UserProjectionStatus{}, err
|
||||
}
|
||||
|
||||
var state domain.UserProjectionState
|
||||
err := r.db.WithContext(ctx).First(&state, "name = ?", domain.UserProjectionNameKratos).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return domain.UserProjectionStatus{
|
||||
Name: domain.UserProjectionNameKratos,
|
||||
Status: domain.UserProjectionStatusFailed,
|
||||
Ready: false,
|
||||
ProjectedUsers: projectedUsers,
|
||||
}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.UserProjectionStatus{}, err
|
||||
}
|
||||
return domain.UserProjectionStatus{
|
||||
Name: state.Name,
|
||||
Status: state.Status,
|
||||
Ready: state.Status == domain.UserProjectionStatusReady && state.LastSyncedAt != nil,
|
||||
LastSyncedAt: state.LastSyncedAt,
|
||||
LastError: state.LastError,
|
||||
UpdatedAt: &state.UpdatedAt,
|
||||
ProjectedUsers: projectedUsers,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *userProjectionRepository) CountTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
|
||||
counts := make(map[string]int64, len(tenants))
|
||||
for _, tenant := range tenants {
|
||||
counts[tenant.ID] = 0
|
||||
}
|
||||
if len(tenants) == 0 {
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
valuePlaceholders := make([]string, 0, len(tenants))
|
||||
args := make([]any, 0, len(tenants)*2)
|
||||
for _, tenant := range tenants {
|
||||
valuePlaceholders = append(valuePlaceholders, "(?, ?)")
|
||||
args = append(args, strings.TrimSpace(tenant.ID), strings.TrimSpace(tenant.Slug))
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
WITH requested(tenant_id, slug) AS (
|
||||
VALUES %s
|
||||
)
|
||||
SELECT requested.tenant_id, COUNT(DISTINCT users.id) AS count
|
||||
FROM requested
|
||||
LEFT JOIN users ON users.deleted_at IS NULL AND (
|
||||
users.tenant_id::text = requested.tenant_id
|
||||
)
|
||||
GROUP BY requested.tenant_id
|
||||
`, strings.Join(valuePlaceholders, ","))
|
||||
|
||||
type result struct {
|
||||
TenantID string
|
||||
Count int64
|
||||
}
|
||||
var rows []result
|
||||
if err := r.db.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
counts[row.TenantID] = row.Count
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (r *userProjectionRepository) CountTenantMembersRecursive(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
|
||||
counts := make(map[string]int64, len(tenants))
|
||||
for _, tenant := range tenants {
|
||||
counts[tenant.ID] = 0
|
||||
}
|
||||
if len(tenants) == 0 {
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
valuePlaceholders := make([]string, 0, len(tenants))
|
||||
args := make([]any, 0, len(tenants))
|
||||
for _, tenant := range tenants {
|
||||
valuePlaceholders = append(valuePlaceholders, "(?)")
|
||||
args = append(args, strings.TrimSpace(tenant.ID))
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
WITH RECURSIVE requested(tenant_id) AS (
|
||||
VALUES %s
|
||||
),
|
||||
descendants(root_tenant_id, tenant_id) AS (
|
||||
SELECT requested.tenant_id, requested.tenant_id
|
||||
FROM requested
|
||||
UNION ALL
|
||||
SELECT descendants.root_tenant_id, child.id::text
|
||||
FROM descendants
|
||||
JOIN tenants child
|
||||
ON child.parent_id::text = descendants.tenant_id
|
||||
AND child.deleted_at IS NULL
|
||||
)
|
||||
SELECT requested.tenant_id, COUNT(DISTINCT users.id) AS count
|
||||
FROM requested
|
||||
LEFT JOIN descendants
|
||||
ON descendants.root_tenant_id = requested.tenant_id
|
||||
LEFT JOIN users
|
||||
ON users.deleted_at IS NULL
|
||||
AND users.tenant_id::text = descendants.tenant_id
|
||||
GROUP BY requested.tenant_id
|
||||
`, strings.Join(valuePlaceholders, ","))
|
||||
|
||||
type result struct {
|
||||
TenantID string
|
||||
Count int64
|
||||
}
|
||||
var rows []result
|
||||
if err := r.db.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
counts[row.TenantID] = row.Count
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (r *userProjectionRepository) ReplaceAllFromKratos(ctx context.Context, users []domain.User) error {
|
||||
now := time.Now()
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
for i := range users {
|
||||
users[i].DeletedAt = gorm.DeletedAt{}
|
||||
if users[i].CreatedAt.IsZero() {
|
||||
users[i].CreatedAt = now
|
||||
}
|
||||
if users[i].UpdatedAt.IsZero() {
|
||||
users[i].UpdatedAt = now
|
||||
}
|
||||
}
|
||||
|
||||
if len(users) > 0 {
|
||||
// [FIX] Handle email conflicts before bulk upsert
|
||||
for _, u := range users {
|
||||
if u.Email != "" {
|
||||
// Hard-delete any record with same email but different ID to clear unique constraint
|
||||
_ = tx.Unscoped().Where("email = ? AND id != ?", u.Email, u.ID).Delete(&domain.User{}).Error
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
UpdateAll: true,
|
||||
}).Create(&users).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return upsertUserProjectionState(tx, domain.UserProjectionStatusReady, &now, "")
|
||||
})
|
||||
}
|
||||
|
||||
func (r *userProjectionRepository) MarkFailed(ctx context.Context, syncErr error) error {
|
||||
message := ""
|
||||
if syncErr != nil {
|
||||
message = syncErr.Error()
|
||||
}
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return upsertUserProjectionState(tx, domain.UserProjectionStatusFailed, nil, message)
|
||||
})
|
||||
}
|
||||
|
||||
func upsertUserProjectionState(tx *gorm.DB, status string, syncedAt *time.Time, lastError string) error {
|
||||
state := domain.UserProjectionState{
|
||||
Name: domain.UserProjectionNameKratos,
|
||||
Status: status,
|
||||
LastSyncedAt: syncedAt,
|
||||
LastError: lastError,
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
return tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "name"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"status",
|
||||
"last_synced_at",
|
||||
"last_error",
|
||||
"updated_at",
|
||||
}),
|
||||
}).Create(&state).Error
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUserProjectionRepository_ReplaceAllFromKratosMarksReadyWithoutDeletingUsersMissingFromPartialList(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := NewUserProjectionRepository(testDB)
|
||||
|
||||
require.NoError(t, testDB.Exec("DELETE FROM user_projection_states").Error)
|
||||
require.NoError(t, testDB.Exec("DELETE FROM user_login_ids").Error)
|
||||
require.NoError(t, testDB.Exec("DELETE FROM users").Error)
|
||||
|
||||
tenantID := "10000000-0000-0000-0000-000000000001"
|
||||
tenantSlug := "projection-saman"
|
||||
require.NoError(t, testDB.Create(&domain.Tenant{
|
||||
ID: tenantID,
|
||||
Name: "Projection Saman",
|
||||
Slug: tenantSlug,
|
||||
Type: domain.TenantTypeCompany,
|
||||
Status: domain.TenantStatusActive,
|
||||
}).Error)
|
||||
existing := &domain.User{
|
||||
ID: "00000000-0000-0000-0000-000000000099",
|
||||
Email: "existing@example.com",
|
||||
Name: "Existing",
|
||||
CompanyCode: tenantSlug,
|
||||
TenantID: &tenantID,
|
||||
}
|
||||
require.NoError(t, NewUserRepository(testDB).Create(ctx, existing))
|
||||
|
||||
users := []domain.User{
|
||||
{
|
||||
ID: "00000000-0000-0000-0000-000000000101",
|
||||
Email: "one@example.com",
|
||||
Name: "One",
|
||||
CompanyCode: tenantSlug,
|
||||
TenantID: &tenantID,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
{
|
||||
ID: "00000000-0000-0000-0000-000000000102",
|
||||
Email: "two@example.com",
|
||||
Name: "Two",
|
||||
TenantID: &tenantID,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, repo.ReplaceAllFromKratos(ctx, users))
|
||||
|
||||
ready, err := repo.IsReady(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, ready)
|
||||
|
||||
counts, err := repo.CountTenantMembers(ctx, []domain.Tenant{
|
||||
{ID: tenantID, Slug: tenantSlug},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(3), counts[tenantID])
|
||||
|
||||
var activeCount int64
|
||||
require.NoError(t, testDB.Model(&domain.User{}).Count(&activeCount).Error)
|
||||
assert.Equal(t, int64(3), activeCount)
|
||||
|
||||
var existingCount int64
|
||||
require.NoError(t, testDB.Model(&domain.User{}).Where("id = ?", existing.ID).Count(&existingCount).Error)
|
||||
assert.Equal(t, int64(1), existingCount)
|
||||
|
||||
var existingRow domain.User
|
||||
require.NoError(t, testDB.Unscoped().First(&existingRow, "id = ?", existing.ID).Error)
|
||||
assert.False(t, existingRow.DeletedAt.Valid)
|
||||
}
|
||||
|
||||
func TestUserProjectionRepository_CountTenantMembersRecursiveIncludesDescendantsAndExcludesSoftDeletedUsers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := NewUserProjectionRepository(testDB)
|
||||
|
||||
parentID := "20000000-0000-0000-0000-000000000001"
|
||||
childID := "20000000-0000-0000-0000-000000000002"
|
||||
grandchildID := "20000000-0000-0000-0000-000000000003"
|
||||
siblingID := "20000000-0000-0000-0000-000000000004"
|
||||
tenantIDs := []string{parentID, childID, grandchildID, siblingID}
|
||||
|
||||
require.NoError(t, testDB.Exec("DELETE FROM user_login_ids").Error)
|
||||
require.NoError(t, testDB.Exec("DELETE FROM users").Error)
|
||||
require.NoError(t, testDB.Unscoped().Where("id IN ?", tenantIDs).Delete(&domain.Tenant{}).Error)
|
||||
|
||||
require.NoError(t, testDB.Create(&domain.Tenant{
|
||||
ID: parentID,
|
||||
Name: "Recursive Parent",
|
||||
Slug: "recursive-parent",
|
||||
Type: domain.TenantTypeCompany,
|
||||
Status: domain.TenantStatusActive,
|
||||
}).Error)
|
||||
require.NoError(t, testDB.Create(&domain.Tenant{
|
||||
ID: childID,
|
||||
Name: "Recursive Child",
|
||||
Slug: "recursive-child",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
Status: domain.TenantStatusActive,
|
||||
ParentID: &parentID,
|
||||
}).Error)
|
||||
require.NoError(t, testDB.Create(&domain.Tenant{
|
||||
ID: grandchildID,
|
||||
Name: "Recursive Grandchild",
|
||||
Slug: "recursive-grandchild",
|
||||
Type: domain.TenantTypeUserGroup,
|
||||
Status: domain.TenantStatusActive,
|
||||
ParentID: &childID,
|
||||
}).Error)
|
||||
require.NoError(t, testDB.Create(&domain.Tenant{
|
||||
ID: siblingID,
|
||||
Name: "Recursive Sibling",
|
||||
Slug: "recursive-sibling",
|
||||
Type: domain.TenantTypeCompany,
|
||||
Status: domain.TenantStatusActive,
|
||||
}).Error)
|
||||
|
||||
users := []domain.User{
|
||||
{ID: "21000000-0000-0000-0000-000000000001", Email: "parent@example.com", Name: "Parent", TenantID: &parentID},
|
||||
{ID: "21000000-0000-0000-0000-000000000002", Email: "child@example.com", Name: "Child", TenantID: &childID},
|
||||
{ID: "21000000-0000-0000-0000-000000000003", Email: "grandchild@example.com", Name: "Grandchild", TenantID: &grandchildID},
|
||||
{ID: "21000000-0000-0000-0000-000000000004", Email: "deleted-grandchild@example.com", Name: "Deleted Grandchild", TenantID: &grandchildID},
|
||||
{ID: "21000000-0000-0000-0000-000000000005", Email: "sibling@example.com", Name: "Sibling", TenantID: &siblingID},
|
||||
}
|
||||
for i := range users {
|
||||
require.NoError(t, testDB.Create(&users[i]).Error)
|
||||
}
|
||||
require.NoError(t, testDB.Delete(&domain.User{}, "id = ?", users[3].ID).Error)
|
||||
|
||||
directCounts, err := repo.CountTenantMembers(ctx, []domain.Tenant{{ID: parentID}, {ID: childID}, {ID: grandchildID}, {ID: siblingID}})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), directCounts[parentID])
|
||||
assert.Equal(t, int64(1), directCounts[childID])
|
||||
assert.Equal(t, int64(1), directCounts[grandchildID])
|
||||
assert.Equal(t, int64(1), directCounts[siblingID])
|
||||
|
||||
recursiveCounts, err := repo.CountTenantMembersRecursive(ctx, []domain.Tenant{{ID: parentID}, {ID: childID}, {ID: grandchildID}, {ID: siblingID}})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(3), recursiveCounts[parentID])
|
||||
assert.Equal(t, int64(2), recursiveCounts[childID])
|
||||
assert.Equal(t, int64(1), recursiveCounts[grandchildID])
|
||||
assert.Equal(t, int64(1), recursiveCounts[siblingID])
|
||||
}
|
||||
|
||||
func TestUserProjectionRepository_MarkFailedMakesProjectionNotReady(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := NewUserProjectionRepository(testDB)
|
||||
|
||||
require.NoError(t, testDB.Exec("DELETE FROM user_projection_states").Error)
|
||||
|
||||
require.NoError(t, repo.MarkFailed(ctx, errors.New("kratos down")))
|
||||
|
||||
ready, err := repo.IsReady(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, ready)
|
||||
}
|
||||
@@ -272,7 +272,12 @@ func (r *userRepository) List(ctx context.Context, offset, limit int, search str
|
||||
}
|
||||
|
||||
func (r *userRepository) Delete(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).Delete(&domain.User{}, "id = ?", id).Error
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Unscoped().Where("user_id = ?", id).Delete(&domain.UserLoginID{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Unscoped().Delete(&domain.User{}, "id = ?", id).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *userRepository) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestUserRepository(t *testing.T) {
|
||||
@@ -95,8 +96,14 @@ func TestUserRepository(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Delete User", func(t *testing.T) {
|
||||
require.NoError(t, testDB.AutoMigrate(&domain.UserLoginID{}))
|
||||
require.NoError(t, testDB.Exec("DELETE FROM user_login_ids").Error)
|
||||
require.NoError(t, testDB.Exec("DELETE FROM users WHERE email = ?", "delete@example.com").Error)
|
||||
user := &domain.User{Email: "delete@example.com", Name: "To Delete"}
|
||||
_ = repo.Create(ctx, user)
|
||||
require.NoError(t, repo.Create(ctx, user))
|
||||
require.NoError(t, repo.UpdateUserLoginIDs(ctx, user.ID, []domain.UserLoginID{
|
||||
{UserID: user.ID, TenantID: uuid.NewString(), FieldKey: "employee_id", LoginID: "DELETE001"},
|
||||
}))
|
||||
|
||||
err := repo.Delete(ctx, user.ID)
|
||||
assert.NoError(t, err)
|
||||
@@ -104,6 +111,14 @@ func TestUserRepository(t *testing.T) {
|
||||
found, err := repo.FindByEmail(ctx, "delete@example.com")
|
||||
assert.Error(t, err) // Should not be found
|
||||
assert.Nil(t, found)
|
||||
|
||||
var hardDeleted domain.User
|
||||
err = testDB.Unscoped().Where("id = ?", user.ID).First(&hardDeleted).Error
|
||||
require.ErrorIs(t, err, gorm.ErrRecordNotFound)
|
||||
|
||||
var loginIDCount int64
|
||||
require.NoError(t, testDB.Unscoped().Model(&domain.UserLoginID{}).Where("user_id = ?", user.ID).Count(&loginIDCount).Error)
|
||||
require.Zero(t, loginIDCount)
|
||||
})
|
||||
|
||||
t.Run("CountByCompanyCodes", func(t *testing.T) {
|
||||
|
||||
@@ -102,6 +102,44 @@ func (s *RedisService) Delete(key string) error {
|
||||
return s.Client.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
func (s *RedisService) DeleteByPrefix(ctx context.Context, prefix string) (int64, error) {
|
||||
if s == nil || s.Client == nil {
|
||||
return 0, os.ErrInvalid
|
||||
}
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
if prefix == "" {
|
||||
return 0, os.ErrInvalid
|
||||
}
|
||||
|
||||
var deleted int64
|
||||
var cursor uint64
|
||||
pattern := prefix + "*"
|
||||
for {
|
||||
keys, next, err := s.Client.Scan(ctx, cursor, pattern, 250).Result()
|
||||
if err != nil {
|
||||
return deleted, err
|
||||
}
|
||||
for len(keys) > 0 {
|
||||
chunkSize := len(keys)
|
||||
if chunkSize > 500 {
|
||||
chunkSize = 500
|
||||
}
|
||||
chunk := keys[:chunkSize]
|
||||
count, err := s.Client.Del(ctx, chunk...).Result()
|
||||
if err != nil {
|
||||
return deleted, err
|
||||
}
|
||||
deleted += count
|
||||
keys = keys[chunkSize:]
|
||||
}
|
||||
cursor = next
|
||||
if cursor == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func (s *RedisService) GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error) {
|
||||
if s == nil || s.Client == nil {
|
||||
return domain.IdentityCacheStatus{
|
||||
|
||||
@@ -134,6 +134,27 @@ func TestRedisServiceFlushIdentityCacheDeletesOnlyIdentityMirrorAndIndexKeys(t *
|
||||
}, stub.deleted)
|
||||
}
|
||||
|
||||
func TestRedisServiceDeleteByPrefixScansAndDeletesMatchingKeys(t *testing.T) {
|
||||
stub := &redisCommandStub{
|
||||
scans: map[string][]string{
|
||||
"orgchart:snapshot:v1:*": {
|
||||
"orgchart:snapshot:v1:super_admin:all:none",
|
||||
"orgchart:snapshot:v1:user:user-1:tenant-1",
|
||||
},
|
||||
},
|
||||
}
|
||||
service := newStubbedRedisService(stub)
|
||||
|
||||
deleted, err := service.DeleteByPrefix(context.Background(), "orgchart:snapshot:v1:")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(2), deleted)
|
||||
require.ElementsMatch(t, []string{
|
||||
"orgchart:snapshot:v1:super_admin:all:none",
|
||||
"orgchart:snapshot:v1:user:user-1:tenant-1",
|
||||
}, stub.deleted)
|
||||
}
|
||||
|
||||
func TestRedisServiceGetIdentityCacheStatusReturnsUnavailableWithoutClient(t *testing.T) {
|
||||
status, err := (*RedisService)(nil).GetIdentityCacheStatus(context.Background())
|
||||
|
||||
|
||||
@@ -243,7 +243,7 @@ func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string
|
||||
if err := s.userRepo.Update(ctx, localUser); err != nil {
|
||||
slog.Error("Failed to sync local user during AddMember", "user", userID, "error", err)
|
||||
} else if s.worksmobile != nil {
|
||||
if err := s.worksmobile.EnqueueUserUpsertIfInScope(ctx, *localUser); err != nil {
|
||||
if err := s.worksmobile.EnqueueUserUpdateIfInScope(ctx, *localUser); err != nil {
|
||||
slog.Warn("Failed to enqueue Worksmobile user sync during AddMember", "user", userID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +156,11 @@ func (f *fakeUserGroupWorksmobileSyncer) EnqueueUserUpsertIfInScope(ctx context.
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeUserGroupWorksmobileSyncer) EnqueueUserUpdateIfInScope(ctx context.Context, user domain.User) error {
|
||||
f.userUpserts = append(f.userUpserts, user)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeUserGroupWorksmobileSyncer) EnqueueUserDeleteIfInScope(ctx context.Context, user domain.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/repository"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type UserProjectionSyncService struct {
|
||||
kratos KratosAdminService
|
||||
repo repository.UserProjectionRepository
|
||||
}
|
||||
|
||||
type UserProjectionReconciler interface {
|
||||
Reconcile(ctx context.Context) (int, error)
|
||||
}
|
||||
|
||||
func NewUserProjectionSyncService(kratos KratosAdminService, repo repository.UserProjectionRepository) *UserProjectionSyncService {
|
||||
return &UserProjectionSyncService{
|
||||
kratos: kratos,
|
||||
repo: repo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *UserProjectionSyncService) Reconcile(ctx context.Context) (int, error) {
|
||||
if s == nil || s.kratos == nil || s.repo == nil {
|
||||
return 0, fmt.Errorf("user projection sync dependencies are not configured")
|
||||
}
|
||||
|
||||
identities, err := s.kratos.ListIdentities(ctx)
|
||||
if err != nil {
|
||||
_ = s.repo.MarkFailed(ctx, err)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
users := make([]domain.User, 0, len(identities))
|
||||
for _, identity := range identities {
|
||||
users = append(users, MapKratosIdentityToLocalUser(identity))
|
||||
}
|
||||
if err := s.repo.ReplaceAllFromKratos(ctx, users); err != nil {
|
||||
_ = s.repo.MarkFailed(ctx, err)
|
||||
return 0, err
|
||||
}
|
||||
return len(users), nil
|
||||
}
|
||||
|
||||
func MapKratosIdentityToLocalUser(identity KratosIdentity) domain.User {
|
||||
traits := identity.Traits
|
||||
now := time.Now()
|
||||
createdAt := identity.CreatedAt
|
||||
if createdAt.IsZero() {
|
||||
createdAt = now
|
||||
}
|
||||
updatedAt := identity.UpdatedAt
|
||||
if updatedAt.IsZero() {
|
||||
updatedAt = now
|
||||
}
|
||||
|
||||
role, ok := domain.NormalizeRoleAlias(kratosProjectionTraitString(traits, "role"))
|
||||
if !ok {
|
||||
role, ok = domain.NormalizeRoleAlias(kratosProjectionTraitString(traits, "grade"))
|
||||
if !ok {
|
||||
role = domain.RoleUser
|
||||
}
|
||||
}
|
||||
grade := kratosProjectionTraitString(traits, "grade")
|
||||
if _, ok := domain.NormalizeRoleAlias(grade); ok {
|
||||
grade = ""
|
||||
}
|
||||
|
||||
user := domain.User{
|
||||
ID: identity.ID,
|
||||
Email: kratosProjectionTraitString(traits, "email"),
|
||||
Name: kratosProjectionTraitString(traits, "name"),
|
||||
Phone: domain.NormalizePhoneNumber(kratosProjectionTraitString(traits, "phone_number")),
|
||||
Role: role,
|
||||
Status: normalizeProjectionStatus(identity.State),
|
||||
Department: kratosProjectionTraitString(traits, "department"),
|
||||
Grade: grade,
|
||||
Position: kratosProjectionTraitString(traits, "position"),
|
||||
JobTitle: kratosProjectionTraitString(traits, "jobTitle"),
|
||||
AffiliationType: kratosProjectionTraitString(traits, "affiliationType"),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
Metadata: make(domain.JSONMap),
|
||||
}
|
||||
if tenantID := kratosProjectionTraitString(traits, "tenant_id"); tenantID != "" {
|
||||
user.TenantID = &tenantID
|
||||
}
|
||||
if relyingPartyID := kratosProjectionTraitString(traits, "relying_party_id"); relyingPartyID != "" {
|
||||
user.RelyingPartyID = &relyingPartyID
|
||||
}
|
||||
|
||||
coreTraits := map[string]bool{
|
||||
"email": true, "name": true, "phone_number": true,
|
||||
"grade": true, "role": true,
|
||||
"companyCode": true, "company_code": true, "companyCodes": true,
|
||||
"tenant_id": true, "department": true,
|
||||
"position": true, "jobTitle": true, "affiliationType": true,
|
||||
"relying_party_id": true, "custom_login_ids": true, "id": true,
|
||||
}
|
||||
for key, value := range traits {
|
||||
if !coreTraits[key] {
|
||||
user.Metadata[key] = value
|
||||
}
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
func kratosProjectionTraitString(traits map[string]any, key string) string {
|
||||
if traits == nil {
|
||||
return ""
|
||||
}
|
||||
value, ok := traits[key]
|
||||
if !ok || value == nil {
|
||||
return ""
|
||||
}
|
||||
if str, ok := value.(string); ok {
|
||||
return str
|
||||
}
|
||||
return fmt.Sprint(value)
|
||||
}
|
||||
|
||||
func kratosProjectionTraitStringArray(traits map[string]any, key string) []string {
|
||||
if traits == nil {
|
||||
return nil
|
||||
}
|
||||
switch value := traits[key].(type) {
|
||||
case []string:
|
||||
return value
|
||||
case []any:
|
||||
items := make([]string, 0, len(value))
|
||||
for _, item := range value {
|
||||
if str, ok := item.(string); ok && strings.TrimSpace(str) != "" {
|
||||
items = append(items, str)
|
||||
}
|
||||
}
|
||||
return items
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeProjectionStatus(state string) string {
|
||||
normalized := domain.NormalizeUserStatus(state)
|
||||
if normalized == "" {
|
||||
return domain.UserStatusActive
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type fakeUserProjectionRepo struct {
|
||||
replacedUsers []domain.User
|
||||
failedErr error
|
||||
replaceErr error
|
||||
}
|
||||
|
||||
func (f *fakeUserProjectionRepo) IsReady(ctx context.Context) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (f *fakeUserProjectionRepo) GetStatus(ctx context.Context) (domain.UserProjectionStatus, error) {
|
||||
return domain.UserProjectionStatus{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeUserProjectionRepo) CountTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeUserProjectionRepo) CountTenantMembersRecursive(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeUserProjectionRepo) ReplaceAllFromKratos(ctx context.Context, users []domain.User) error {
|
||||
f.replacedUsers = append([]domain.User(nil), users...)
|
||||
return f.replaceErr
|
||||
}
|
||||
|
||||
func (f *fakeUserProjectionRepo) MarkFailed(ctx context.Context, syncErr error) error {
|
||||
f.failedErr = syncErr
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestUserProjectionSyncService_ReconcileReplacesProjectionFromKratos(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
kratos := new(MockKratosAdminServiceShared)
|
||||
repo := &fakeUserProjectionRepo{}
|
||||
svc := NewUserProjectionSyncService(kratos, repo)
|
||||
|
||||
tenantID := "00000000-0000-0000-0000-000000000001"
|
||||
kratos.On("ListIdentities", ctx).Return([]KratosIdentity{
|
||||
{
|
||||
ID: "00000000-0000-0000-0000-000000000101",
|
||||
Traits: map[string]any{
|
||||
"email": "one@example.com",
|
||||
"name": "One",
|
||||
"phone_number": "+821012345678",
|
||||
"companyCode": "saman",
|
||||
"companyCodes": []any{"saman", "group-a"},
|
||||
"tenant_id": tenantID,
|
||||
"department": "DX",
|
||||
"customAttr": "kept",
|
||||
},
|
||||
State: "active",
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
count, err := svc.Reconcile(ctx)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, count)
|
||||
require.Len(t, repo.replacedUsers, 1)
|
||||
assert.Equal(t, "one@example.com", repo.replacedUsers[0].Email)
|
||||
assert.Equal(t, "One", repo.replacedUsers[0].Name)
|
||||
assert.Equal(t, "+821012345678", repo.replacedUsers[0].Phone)
|
||||
assert.Empty(t, repo.replacedUsers[0].CompanyCode)
|
||||
assert.Empty(t, repo.replacedUsers[0].CompanyCodes)
|
||||
require.NotNil(t, repo.replacedUsers[0].TenantID)
|
||||
assert.Equal(t, tenantID, *repo.replacedUsers[0].TenantID)
|
||||
assert.Equal(t, "kept", repo.replacedUsers[0].Metadata["customAttr"])
|
||||
assert.NoError(t, repo.failedErr)
|
||||
kratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserProjectionSyncService_ReconcileDeduplicatesKoreanCountryCodePhone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
kratos := new(MockKratosAdminServiceShared)
|
||||
repo := &fakeUserProjectionRepo{}
|
||||
svc := NewUserProjectionSyncService(kratos, repo)
|
||||
|
||||
kratos.On("ListIdentities", ctx).Return([]KratosIdentity{
|
||||
{
|
||||
ID: "00000000-0000-0000-0000-000000000102",
|
||||
Traits: map[string]any{
|
||||
"email": "two@example.com",
|
||||
"name": "Two",
|
||||
"phone_number": "+82 +821091917771",
|
||||
},
|
||||
State: "active",
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
count, err := svc.Reconcile(ctx)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, count)
|
||||
require.Len(t, repo.replacedUsers, 1)
|
||||
assert.Equal(t, "+821091917771", repo.replacedUsers[0].Phone)
|
||||
kratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserProjectionSyncService_ReconcileMarksFailedWhenKratosFails(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
kratos := new(MockKratosAdminServiceShared)
|
||||
repo := &fakeUserProjectionRepo{}
|
||||
svc := NewUserProjectionSyncService(kratos, repo)
|
||||
|
||||
expectedErr := errors.New("kratos down")
|
||||
kratos.On("ListIdentities", ctx).Return([]KratosIdentity{}, expectedErr).Once()
|
||||
|
||||
count, err := svc.Reconcile(ctx)
|
||||
|
||||
assert.Equal(t, 0, count)
|
||||
assert.ErrorIs(t, err, expectedErr)
|
||||
assert.ErrorIs(t, repo.failedErr, expectedErr)
|
||||
assert.Empty(t, repo.replacedUsers)
|
||||
kratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestMapKratosIdentityToLocalUserPreservesArchivedStatus(t *testing.T) {
|
||||
user := MapKratosIdentityToLocalUser(KratosIdentity{
|
||||
ID: "00000000-0000-0000-0000-000000000201",
|
||||
State: domain.UserStatusArchived,
|
||||
Traits: map[string]any{
|
||||
"email": "archived@example.com",
|
||||
"name": "Archived User",
|
||||
},
|
||||
})
|
||||
|
||||
assert.Equal(t, domain.UserStatusArchived, user.Status)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -33,6 +34,7 @@ type WorksmobileDirectoryClient interface {
|
||||
DeleteOrgUnit(ctx context.Context, orgUnitID string) error
|
||||
CreateUser(ctx context.Context, payload WorksmobileUserPayload) error
|
||||
UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error
|
||||
UpdateUserOnly(ctx context.Context, payload WorksmobileUserPayload) error
|
||||
AddUserAliasEmail(ctx context.Context, userID string, email string) error
|
||||
ResetUserPassword(ctx context.Context, userID string, password string) error
|
||||
DeleteUser(ctx context.Context, userID string) error
|
||||
@@ -324,17 +326,14 @@ func (c *WorksmobileHTTPClient) DeleteOrgUnit(ctx context.Context, orgUnitID str
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) CreateUser(ctx context.Context, payload WorksmobileUserPayload) error {
|
||||
payload = normalizeWorksmobileUserCreatePayload(payload)
|
||||
return c.sendDirectoryJSON(ctx, http.MethodPost, "/v1.0/users", payload)
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error {
|
||||
err := c.CreateUser(ctx, payload)
|
||||
if apiErr, ok := err.(WorksmobileHTTPError); ok && apiErr.StatusCode == http.StatusConflict {
|
||||
identifier := strings.TrimSpace(payload.Email)
|
||||
if identifier == "" {
|
||||
identifier = strings.TrimSpace(payload.UserExternalKey)
|
||||
}
|
||||
if patchErr := c.PatchUser(ctx, identifier, NewWorksmobileUserPatchPayload(payload)); patchErr != nil {
|
||||
if patchErr := c.updateUserByPatchOnly(ctx, payload); patchErr != nil {
|
||||
return fmt.Errorf("worksmobile user create conflict: %w; patch after conflict failed: %v", err, patchErr)
|
||||
}
|
||||
return nil
|
||||
@@ -342,6 +341,163 @@ func (c *WorksmobileHTTPClient) UpsertUser(ctx context.Context, payload Worksmob
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) UpdateUserOnly(ctx context.Context, payload WorksmobileUserPayload) error {
|
||||
return c.updateUserByPatchOnly(ctx, payload)
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) updateUserByPatchOnly(ctx context.Context, payload WorksmobileUserPayload) error {
|
||||
identifier := strings.TrimSpace(payload.Email)
|
||||
if identifier == "" {
|
||||
identifier = strings.TrimSpace(payload.UserExternalKey)
|
||||
}
|
||||
patchPayload := NewWorksmobileUserPatchPayload(payload)
|
||||
if patchErr := c.PatchUser(ctx, identifier, patchPayload); patchErr != nil {
|
||||
externalKey := strings.TrimSpace(payload.UserExternalKey)
|
||||
if patchAPIError, ok := patchErr.(WorksmobileHTTPError); ok && patchAPIError.StatusCode == http.StatusNotFound && externalKey != "" && externalKey != identifier {
|
||||
if externalKeyPatchErr := c.PatchUser(ctx, externalKey, patchPayload); externalKeyPatchErr == nil {
|
||||
return nil
|
||||
} else {
|
||||
if externalKeyPatchAPIError, ok := externalKeyPatchErr.(WorksmobileHTTPError); ok && externalKeyPatchAPIError.StatusCode == http.StatusNotFound {
|
||||
if lookupPatchErr := c.patchUserByExternalKeyLookup(ctx, externalKey, payload.DomainID, patchPayload); lookupPatchErr == nil {
|
||||
return nil
|
||||
} else {
|
||||
return fmt.Errorf("patch failed: %w; external key patch failed: %v; external key lookup patch failed: %v", patchErr, externalKeyPatchErr, lookupPatchErr)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("patch failed: %w; external key patch failed: %v", patchErr, externalKeyPatchErr)
|
||||
}
|
||||
}
|
||||
return patchErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) patchUserByExternalKeyLookup(ctx context.Context, externalKey string, requestedDomainID int64, payload WorksmobileUserPatchPayload) error {
|
||||
externalKey = strings.TrimSpace(externalKey)
|
||||
if externalKey == "" {
|
||||
return fmt.Errorf("worksmobile user external key is required")
|
||||
}
|
||||
matches, err := c.findUsersByExternalKey(ctx, externalKey, requestedDomainID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return fmt.Errorf("worksmobile user external key match not found after create conflict: %s", externalKey)
|
||||
}
|
||||
if len(matches) > 1 {
|
||||
domainIDs := worksmobileRemoteUserDomainIDs(matches)
|
||||
userIDs := worksmobileRemoteUserIDs(matches)
|
||||
slog.Error(
|
||||
"Worksmobile external key matched multiple users during upsert conflict recovery",
|
||||
"externalKey", externalKey,
|
||||
"requestedDomainID", requestedDomainID,
|
||||
"domainIDs", domainIDs,
|
||||
"userIDs", userIDs,
|
||||
"matchCount", len(matches),
|
||||
)
|
||||
return fmt.Errorf("multiple worksmobile users matched external key: externalKey=%s requestedDomainID=%d domainIDs=%v userIDs=%v", externalKey, requestedDomainID, domainIDs, userIDs)
|
||||
}
|
||||
remote := matches[0]
|
||||
identifiers := compactUniqueStrings(remote.ID, remote.Email, remote.UserName)
|
||||
if len(identifiers) == 0 {
|
||||
return fmt.Errorf("worksmobile user external key match has no patch identifier: %s", externalKey)
|
||||
}
|
||||
var lastErr error
|
||||
for _, identifier := range identifiers {
|
||||
if err := c.PatchUser(ctx, identifier, payload); err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) findUsersByExternalKey(ctx context.Context, externalKey string, requestedDomainID int64) ([]WorksmobileRemoteUser, error) {
|
||||
externalKey = strings.TrimSpace(externalKey)
|
||||
domainIDs := worksmobileExternalKeyLookupDomainIDs(requestedDomainID, c.DomainIDs)
|
||||
if c.directoryAuthConfigured() && len(domainIDs) > 0 {
|
||||
matches := make([]WorksmobileRemoteUser, 0, 1)
|
||||
for _, domainID := range domainIDs {
|
||||
users, err := c.listDirectoryUsers(ctx, []int64{domainID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, user := range users {
|
||||
if user.ExternalID == externalKey {
|
||||
matches = append(matches, user)
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches, nil
|
||||
}
|
||||
users, err := c.ListUsers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
matches := make([]WorksmobileRemoteUser, 0, 1)
|
||||
for _, user := range users {
|
||||
if user.ExternalID == externalKey {
|
||||
matches = append(matches, user)
|
||||
}
|
||||
}
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func worksmobileExternalKeyLookupDomainIDs(requestedDomainID int64, configuredDomainIDs []int64) []int64 {
|
||||
domainIDs := make([]int64, 0, len(configuredDomainIDs)+1)
|
||||
if requestedDomainID > 0 {
|
||||
domainIDs = append(domainIDs, requestedDomainID)
|
||||
}
|
||||
domainIDs = append(domainIDs, configuredDomainIDs...)
|
||||
return uniqueWorksmobileDomainIDs(domainIDs)
|
||||
}
|
||||
|
||||
func worksmobileRemoteUserDomainIDs(users []WorksmobileRemoteUser) []int64 {
|
||||
domainIDs := make([]int64, 0, len(users))
|
||||
for _, user := range users {
|
||||
domainIDs = append(domainIDs, user.DomainID)
|
||||
}
|
||||
return uniqueWorksmobileDomainIDs(domainIDs)
|
||||
}
|
||||
|
||||
func worksmobileRemoteUserIDs(users []WorksmobileRemoteUser) []string {
|
||||
ids := make([]string, 0, len(users))
|
||||
for _, user := range users {
|
||||
if id := strings.TrimSpace(user.ID); id != "" {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return compactUniqueStrings(ids...)
|
||||
}
|
||||
|
||||
func compactUniqueStrings(values ...string) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeWorksmobileUserCreatePayload(payload WorksmobileUserPayload) WorksmobileUserPayload {
|
||||
payload.Email = strings.TrimSpace(payload.Email)
|
||||
payload.PrivateEmail = strings.TrimSpace(payload.PrivateEmail)
|
||||
payload.CellPhone = normalizeWorksmobileOutboundCellPhone(payload.CellPhone)
|
||||
if strings.EqualFold(strings.TrimSpace(payload.PasswordConfig.PasswordCreationType), "ADMIN") &&
|
||||
strings.TrimSpace(payload.PasswordConfig.Password) != "" &&
|
||||
payload.PasswordConfig.ChangePasswordAtNextLogin == nil {
|
||||
changePasswordAtNextLogin := true
|
||||
payload.PasswordConfig.ChangePasswordAtNextLogin = &changePasswordAtNextLogin
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) AddUserAliasEmail(ctx context.Context, userID string, email string) error {
|
||||
userID = strings.TrimSpace(userID)
|
||||
email = strings.TrimSpace(email)
|
||||
@@ -995,7 +1151,22 @@ func NewWorksmobileSCIMUserPayload(payload WorksmobileUserPayload) WorksmobileSC
|
||||
}
|
||||
|
||||
func normalizeWorksmobileOutboundCellPhone(value string) string {
|
||||
return domain.NormalizePhoneNumber(value)
|
||||
normalized := domain.NormalizePhoneNumber(value)
|
||||
if !strings.HasPrefix(normalized, "+82") {
|
||||
if strings.HasPrefix(normalized, "0") {
|
||||
return "+82 " + normalized
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
national := strings.TrimPrefix(normalized, "+82")
|
||||
if national == "" {
|
||||
return normalized
|
||||
}
|
||||
national = strings.TrimLeft(national, "0")
|
||||
if national == "" {
|
||||
return "+82 0"
|
||||
}
|
||||
return "+82 0" + national
|
||||
}
|
||||
|
||||
func NewWorksmobileUserPatchPayload(payload WorksmobileUserPayload) WorksmobileUserPatchPayload {
|
||||
|
||||
@@ -36,6 +36,7 @@ func TestWorksmobileHTTPClientCreateUserPostsDirectoryAdminPasswordPayload(t *te
|
||||
Email: "tester@samaneng.com",
|
||||
UserExternalKey: "user-1",
|
||||
UserName: WorksmobileUserName{LastName: "Tester"},
|
||||
CellPhone: "+821041585840",
|
||||
AliasEmails: []string{"tester.alias@samaneng.com", "tester.alias2@samaneng.com"},
|
||||
Locale: "ko_KR",
|
||||
PasswordConfig: WorksmobilePasswordConfig{
|
||||
@@ -57,11 +58,13 @@ func TestWorksmobileHTTPClientCreateUserPostsDirectoryAdminPasswordPayload(t *te
|
||||
require.NoError(t, json.Unmarshal(transport.requestBody, &payload))
|
||||
require.Equal(t, "tester@samaneng.com", payload["email"])
|
||||
require.Equal(t, "user-1", payload["userExternalKey"])
|
||||
require.Equal(t, "+82 01041585840", payload["cellPhone"])
|
||||
require.NotContains(t, payload, "privateEmail")
|
||||
require.Equal(t, []any{"tester.alias@samaneng.com", "tester.alias2@samaneng.com"}, payload["aliasEmails"])
|
||||
passwordConfig := payload["passwordConfig"].(map[string]any)
|
||||
require.Equal(t, "ADMIN", passwordConfig["passwordCreationType"])
|
||||
require.Len(t, passwordConfig["password"], 16)
|
||||
require.Equal(t, true, passwordConfig["changePasswordAtNextLogin"])
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientDeleteUserUsesDirectDirectoryDeleteForEmail(t *testing.T) {
|
||||
@@ -92,7 +95,7 @@ func TestNewWorksmobileUserPatchPayloadNormalizesMalformedKoreanCellPhone(t *tes
|
||||
UserName: WorksmobileUserName{LastName: "Phone Canonical User"},
|
||||
})
|
||||
|
||||
require.Equal(t, "+821062836786", payload.CellPhone)
|
||||
require.Equal(t, "+82 01062836786", payload.CellPhone)
|
||||
}
|
||||
|
||||
func TestNewWorksmobileSCIMUserPayloadNormalizesMalformedKoreanCellPhone(t *testing.T) {
|
||||
@@ -103,7 +106,7 @@ func TestNewWorksmobileSCIMUserPayloadNormalizesMalformedKoreanCellPhone(t *test
|
||||
})
|
||||
|
||||
require.Len(t, payload.PhoneNumbers, 1)
|
||||
require.Equal(t, "+821062836786", payload.PhoneNumbers[0].Value)
|
||||
require.Equal(t, "+82 01062836786", payload.PhoneNumbers[0].Value)
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientUpsertUserPatchesOnCreateConflictWithoutPasswordOrPrivateEmail(t *testing.T) {
|
||||
@@ -155,6 +158,196 @@ func TestWorksmobileHTTPClientUpsertUserPatchesOnCreateConflictWithoutPasswordOr
|
||||
require.Equal(t, "user-1", patchPayload["userExternalKey"])
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientUpdateUserOnlyPatchesWithoutCreateOrPassword(t *testing.T) {
|
||||
transport := &captureRoundTripper{
|
||||
statusCode: http.StatusOK,
|
||||
body: `{}`,
|
||||
}
|
||||
client := &WorksmobileHTTPClient{
|
||||
BaseURL: "https://works.example.test",
|
||||
DirectoryToken: "directory-token-1",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
}
|
||||
|
||||
err := client.UpdateUserOnly(context.Background(), WorksmobileUserPayload{
|
||||
DomainID: 300285955,
|
||||
Email: "moved@samaneng.com",
|
||||
UserExternalKey: "user-1",
|
||||
UserName: WorksmobileUserName{LastName: "Moved User"},
|
||||
PrivateEmail: "private@example.com",
|
||||
PasswordConfig: WorksmobilePasswordConfig{
|
||||
PasswordCreationType: "ADMIN",
|
||||
Password: "Aa1!Aa1!Aa1!Aa1!",
|
||||
},
|
||||
Organizations: []WorksmobileUserOrganization{
|
||||
{
|
||||
DomainID: 300285955,
|
||||
Primary: true,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{
|
||||
{OrgUnitID: "externalKey:new-tenant", Primary: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, transport.requests, 1)
|
||||
require.Equal(t, http.MethodPatch, transport.requests[0].Method)
|
||||
require.Equal(t, "/v1.0/users/moved@samaneng.com", transport.requests[0].URL.Path)
|
||||
|
||||
var patchPayload map[string]any
|
||||
require.NoError(t, json.Unmarshal(transport.requestBodies[0], &patchPayload))
|
||||
require.NotContains(t, patchPayload, "passwordConfig")
|
||||
require.NotContains(t, patchPayload, "privateEmail")
|
||||
require.Equal(t, "moved@samaneng.com", patchPayload["email"])
|
||||
require.Equal(t, "user-1", patchPayload["userExternalKey"])
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientUpsertUserFallsBackToExternalKeyPatchWhenEmailPatchNotFound(t *testing.T) {
|
||||
transport := &captureRoundTripper{
|
||||
responses: []captureResponse{
|
||||
{statusCode: http.StatusConflict, body: `{"code":"CONFLICT","description":"This externalKey(user-1) of user already exists."}`},
|
||||
{statusCode: http.StatusNotFound, body: `{"code":"NOT_FOUND","description":"User (new-email@example.com) does not exist."}`},
|
||||
{statusCode: http.StatusOK, body: `{}`},
|
||||
},
|
||||
}
|
||||
client := &WorksmobileHTTPClient{
|
||||
BaseURL: "https://works.example.test",
|
||||
DirectoryToken: "directory-token-1",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
}
|
||||
|
||||
err := client.UpsertUser(context.Background(), WorksmobileUserPayload{
|
||||
DomainID: 300286336,
|
||||
Email: "new-email@example.com",
|
||||
UserExternalKey: "user-1",
|
||||
UserName: WorksmobileUserName{LastName: "Tester"},
|
||||
Organizations: []WorksmobileUserOrganization{
|
||||
{
|
||||
DomainID: 300286336,
|
||||
Primary: true,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{
|
||||
{OrgUnitID: "externalKey:tenant-hanmac", Primary: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, transport.requests, 3)
|
||||
require.Equal(t, http.MethodPost, transport.requests[0].Method)
|
||||
require.Equal(t, http.MethodPatch, transport.requests[1].Method)
|
||||
require.Equal(t, "/v1.0/users/new-email@example.com", transport.requests[1].URL.Path)
|
||||
require.Equal(t, http.MethodPatch, transport.requests[2].Method)
|
||||
require.Equal(t, "/v1.0/users/user-1", transport.requests[2].URL.Path)
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientUpsertUserFallsBackToRemoteIDPatchWhenExternalKeyPatchNotFound(t *testing.T) {
|
||||
transport := &captureRoundTripper{
|
||||
responses: []captureResponse{
|
||||
{statusCode: http.StatusConflict, body: `{"code":"CONFLICT","description":"This externalKey(user-1) of user already exists."}`},
|
||||
{statusCode: http.StatusNotFound, body: `{"code":"NOT_FOUND","description":"User (new-email@example.com) does not exist."}`},
|
||||
{statusCode: http.StatusNotFound, body: `{"code":"NOT_FOUND","description":"User (user-1) does not exist."}`},
|
||||
{statusCode: http.StatusOK, body: `{"users":[{"userId":"works-user-1","userExternalKey":"user-1","email":"old-email@example.com"}],"responseMetaData":{}}`},
|
||||
{statusCode: http.StatusOK, body: `{}`},
|
||||
},
|
||||
}
|
||||
client := &WorksmobileHTTPClient{
|
||||
BaseURL: "https://works.example.test",
|
||||
DirectoryToken: "directory-token-1",
|
||||
DomainIDs: []int64{300286336},
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
}
|
||||
|
||||
err := client.UpsertUser(context.Background(), WorksmobileUserPayload{
|
||||
DomainID: 300286336,
|
||||
Email: "new-email@example.com",
|
||||
UserExternalKey: "user-1",
|
||||
UserName: WorksmobileUserName{LastName: "Tester"},
|
||||
Organizations: []WorksmobileUserOrganization{
|
||||
{
|
||||
DomainID: 300286336,
|
||||
Primary: true,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{
|
||||
{OrgUnitID: "externalKey:tenant-hanmac", Primary: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, transport.requests, 5)
|
||||
require.Equal(t, http.MethodGet, transport.requests[3].Method)
|
||||
require.Equal(t, "/v1.0/users", transport.requests[3].URL.Path)
|
||||
require.Equal(t, "300286336", transport.requests[3].URL.Query().Get("domainId"))
|
||||
require.Equal(t, http.MethodPatch, transport.requests[4].Method)
|
||||
require.Equal(t, "/v1.0/users/works-user-1", transport.requests[4].URL.Path)
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientExternalKeyLookupStartsWithPayloadDomain(t *testing.T) {
|
||||
transport := &captureRoundTripper{
|
||||
responses: []captureResponse{
|
||||
{statusCode: http.StatusConflict, body: `{"code":"CONFLICT","description":"This externalKey(user-1) of user already exists."}`},
|
||||
{statusCode: http.StatusNotFound, body: `{"code":"NOT_FOUND","description":"User (new-email@example.com) does not exist."}`},
|
||||
{statusCode: http.StatusNotFound, body: `{"code":"NOT_FOUND","description":"User (user-1) does not exist."}`},
|
||||
{statusCode: http.StatusOK, body: `{"users":[],"responseMetaData":{}}`},
|
||||
{statusCode: http.StatusOK, body: `{"users":[{"userId":"works-user-1","userExternalKey":"user-1","email":"old-email@example.com"}],"responseMetaData":{}}`},
|
||||
{statusCode: http.StatusOK, body: `{}`},
|
||||
},
|
||||
}
|
||||
client := &WorksmobileHTTPClient{
|
||||
BaseURL: "https://works.example.test",
|
||||
DirectoryToken: "directory-token-1",
|
||||
DomainIDs: []int64{300285955, 300286336},
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
}
|
||||
|
||||
err := client.UpsertUser(context.Background(), WorksmobileUserPayload{
|
||||
DomainID: 300286336,
|
||||
Email: "new-email@example.com",
|
||||
UserExternalKey: "user-1",
|
||||
UserName: WorksmobileUserName{LastName: "Tester"},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, transport.requests, 6)
|
||||
require.Equal(t, http.MethodGet, transport.requests[3].Method)
|
||||
require.Equal(t, "300286336", transport.requests[3].URL.Query().Get("domainId"))
|
||||
require.Equal(t, http.MethodGet, transport.requests[4].Method)
|
||||
require.Equal(t, "300285955", transport.requests[4].URL.Query().Get("domainId"))
|
||||
require.Equal(t, http.MethodPatch, transport.requests[5].Method)
|
||||
require.Equal(t, "/v1.0/users/works-user-1", transport.requests[5].URL.Path)
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientExternalKeyLookupRejectsDuplicateMatches(t *testing.T) {
|
||||
transport := &captureRoundTripper{
|
||||
responses: []captureResponse{
|
||||
{statusCode: http.StatusConflict, body: `{"code":"CONFLICT","description":"This externalKey(user-1) of user already exists."}`},
|
||||
{statusCode: http.StatusNotFound, body: `{"code":"NOT_FOUND","description":"User (new-email@example.com) does not exist."}`},
|
||||
{statusCode: http.StatusNotFound, body: `{"code":"NOT_FOUND","description":"User (user-1) does not exist."}`},
|
||||
{statusCode: http.StatusOK, body: `{"users":[{"userId":"works-user-1","userExternalKey":"user-1","email":"old-email-1@example.com"}],"responseMetaData":{}}`},
|
||||
{statusCode: http.StatusOK, body: `{"users":[{"userId":"works-user-2","userExternalKey":"user-1","email":"old-email-2@example.com"}],"responseMetaData":{}}`},
|
||||
},
|
||||
}
|
||||
client := &WorksmobileHTTPClient{
|
||||
BaseURL: "https://works.example.test",
|
||||
DirectoryToken: "directory-token-1",
|
||||
DomainIDs: []int64{300286336, 300285955},
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
}
|
||||
|
||||
err := client.UpsertUser(context.Background(), WorksmobileUserPayload{
|
||||
DomainID: 300286336,
|
||||
Email: "new-email@example.com",
|
||||
UserExternalKey: "user-1",
|
||||
UserName: WorksmobileUserName{LastName: "Tester"},
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "multiple worksmobile users matched external key")
|
||||
require.Len(t, transport.requests, 5)
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientAddUserAliasEmailPostsDirectoryAliasEndpoint(t *testing.T) {
|
||||
transport := &captureRoundTripper{
|
||||
statusCode: http.StatusCreated,
|
||||
@@ -637,6 +830,45 @@ func TestWorksmobileRelayWorkerProcessesUserCreateAndMarksProcessed(t *testing.T
|
||||
require.Equal(t, "tester@samaneng.com", client.createdUsers[0].Email)
|
||||
}
|
||||
|
||||
func TestWorksmobileRelayWorkerProcessesAutomaticUserUpdateOnlyWithoutCreate(t *testing.T) {
|
||||
repo := &fakeWorksmobileOutboxRepo{
|
||||
ready: []domain.WorksmobileOutbox{
|
||||
{
|
||||
ID: "job-1",
|
||||
ResourceType: domain.WorksmobileResourceUser,
|
||||
ResourceID: "user-1",
|
||||
Action: domain.WorksmobileActionUpsert,
|
||||
Status: domain.WorksmobileOutboxStatusPending,
|
||||
Payload: worksmobileUserOutboxPayload("root-1", WorksmobileUserPayload{
|
||||
Email: "moved@samaneng.com",
|
||||
UserExternalKey: "user-1",
|
||||
UserName: WorksmobileUserName{LastName: "Moved User"},
|
||||
Organizations: []WorksmobileUserOrganization{
|
||||
{
|
||||
DomainID: 300285955,
|
||||
Primary: true,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{
|
||||
{OrgUnitID: "externalKey:new-tenant", Primary: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
repo.ready[0].Payload["provisioningMode"] = "update_only"
|
||||
client := &fakeWorksmobileDirectoryClient{}
|
||||
worker := NewWorksmobileRelayWorker(repo, client)
|
||||
|
||||
err := worker.ProcessOnce(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"job-1"}, repo.processedIDs)
|
||||
require.Empty(t, client.createdUsers)
|
||||
require.Len(t, client.updatedUsers, 1)
|
||||
require.Equal(t, "moved@samaneng.com", client.updatedUsers[0].Email)
|
||||
}
|
||||
|
||||
func TestWorksmobileRelayWorkerRegistersAliasEmailsAfterUserUpsert(t *testing.T) {
|
||||
repo := &fakeWorksmobileOutboxRepo{
|
||||
ready: []domain.WorksmobileOutbox{
|
||||
@@ -1482,6 +1714,7 @@ type fakeWorksmobileDirectoryClient struct {
|
||||
createdOrgUnits []WorksmobileOrgUnitPayload
|
||||
deletedOrgUnits []string
|
||||
createdUsers []WorksmobileUserPayload
|
||||
updatedUsers []WorksmobileUserPayload
|
||||
deletedUsers []string
|
||||
activeUsers []string
|
||||
suspendedUsers []string
|
||||
@@ -1610,6 +1843,11 @@ func (f *fakeWorksmobileDirectoryClient) UpsertUser(ctx context.Context, payload
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileDirectoryClient) UpdateUserOnly(ctx context.Context, payload WorksmobileUserPayload) error {
|
||||
f.updatedUsers = append(f.updatedUsers, payload)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileDirectoryClient) AddUserAliasEmail(ctx context.Context, userID string, email string) error {
|
||||
f.aliasEmails = append(f.aliasEmails, userID+":"+email)
|
||||
return nil
|
||||
|
||||
@@ -202,6 +202,14 @@ func BuildWorksmobileUserPayloadForDomainTenant(user domain.User, tenant domain.
|
||||
}
|
||||
|
||||
func BuildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain.Tenant, tenantByID map[string]domain.Tenant, rootConfig domain.JSONMap) (WorksmobileUserPayload, error) {
|
||||
return buildWorksmobileUserPayloadForDomainTenants(user, tenant, tenantByID, rootConfig, true)
|
||||
}
|
||||
|
||||
func BuildWorksmobileUserPayloadForScopedDomainTenants(user domain.User, tenant domain.Tenant, tenantByID map[string]domain.Tenant, rootConfig domain.JSONMap) (WorksmobileUserPayload, error) {
|
||||
return buildWorksmobileUserPayloadForDomainTenants(user, tenant, tenantByID, rootConfig, false)
|
||||
}
|
||||
|
||||
func buildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain.Tenant, tenantByID map[string]domain.Tenant, rootConfig domain.JSONMap, includeFallbackTenant bool) (WorksmobileUserPayload, error) {
|
||||
if err := ValidateWorksmobileExternalKey(user.ID); err != nil {
|
||||
return WorksmobileUserPayload{}, err
|
||||
}
|
||||
@@ -211,7 +219,9 @@ func BuildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain
|
||||
if tenantByID == nil {
|
||||
tenantByID = map[string]domain.Tenant{}
|
||||
}
|
||||
tenantByID[tenant.ID] = tenant
|
||||
if includeFallbackTenant {
|
||||
tenantByID[tenant.ID] = tenant
|
||||
}
|
||||
domainID, err := ResolveWorksmobileAccountDomainIDFromEmail(user.Email, tenant, rootConfig)
|
||||
if err != nil {
|
||||
return WorksmobileUserPayload{}, err
|
||||
@@ -253,7 +263,7 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
|
||||
appointments := worksmobileAppointmentsFromMetadata(user.Metadata)
|
||||
if len(appointments) == 0 {
|
||||
appointments = []worksmobileAppointment{{TenantID: tenant.ID, IsPrimary: true}}
|
||||
} else if !worksmobileAppointmentsContainTenant(appointments, tenant.ID) && !worksmobileAppointmentsHavePrimary(appointments) {
|
||||
} else if !worksmobileAppointmentsContainSyncableOrgUnit(appointments, tenantByID) && !worksmobileAppointmentsContainTenant(appointments, tenant.ID) {
|
||||
appointments = append([]worksmobileAppointment{{
|
||||
TenantID: tenant.ID,
|
||||
IsPrimary: true,
|
||||
@@ -284,6 +294,10 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if worksmobileTenantExcludedFromSync(appointmentTenant, tenantByID) {
|
||||
seen[appointment.TenantID] = true
|
||||
continue
|
||||
}
|
||||
if worksmobileShouldSkipEmailDomainRootAppointment(appointment, appointmentTenant, appointments, tenantByID) {
|
||||
seen[appointment.TenantID] = true
|
||||
continue
|
||||
@@ -303,8 +317,7 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
isAccountDomain := worksmobileTenantDomainIDEnvKey(domainTenant) == accountDomainEnvKey
|
||||
isPrimaryOrganization := isAccountDomain && !worksmobileOrganizationsHavePrimary(organizations)
|
||||
isPrimaryOrganization := !worksmobileOrganizationsHavePrimary(organizations)
|
||||
organizationIndex, organizationExists := organizationIndexByDomainID[domainID]
|
||||
orgUnit := WorksmobileUserOrgUnit{
|
||||
OrgUnitID: "externalKey:" + appointmentTenant.ID,
|
||||
@@ -361,6 +374,23 @@ func worksmobileAppointmentsContainTenant(appointments []worksmobileAppointment,
|
||||
return false
|
||||
}
|
||||
|
||||
func worksmobileAppointmentsContainSyncableOrgUnit(appointments []worksmobileAppointment, tenantByID map[string]domain.Tenant) bool {
|
||||
for _, appointment := range appointments {
|
||||
tenant, ok := tenantByID[strings.TrimSpace(appointment.TenantID)]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if worksmobileTenantExcludedFromSync(tenant, tenantByID) {
|
||||
continue
|
||||
}
|
||||
if isWorksmobileDomainRootTenant(tenant) {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func worksmobileAppointmentsHavePrimary(appointments []worksmobileAppointment) bool {
|
||||
for _, appointment := range appointments {
|
||||
if appointment.IsPrimary {
|
||||
@@ -376,6 +406,9 @@ func worksmobileAppointmentsContainDomain(appointments []worksmobileAppointment,
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if worksmobileTenantExcludedFromSync(tenant, tenantByID) {
|
||||
continue
|
||||
}
|
||||
domainTenant := worksmobileDomainClassificationTenant(tenant, tenantByID)
|
||||
if worksmobileTenantDomainIDEnvKey(domainTenant) == envKey {
|
||||
return true
|
||||
@@ -384,6 +417,26 @@ func worksmobileAppointmentsContainDomain(appointments []worksmobileAppointment,
|
||||
return false
|
||||
}
|
||||
|
||||
func worksmobileTenantExcludedFromSync(tenant domain.Tenant, tenantByID map[string]domain.Tenant) bool {
|
||||
visited := map[string]bool{}
|
||||
current := tenant
|
||||
for {
|
||||
if WorksmobileExcluded(current.Config) {
|
||||
return true
|
||||
}
|
||||
parentID := worksmobileTenantParentID(current)
|
||||
if parentID == "" || visited[parentID] {
|
||||
return false
|
||||
}
|
||||
visited[parentID] = true
|
||||
parent, ok := tenantByID[parentID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
func worksmobileShouldSkipEmailDomainRootAppointment(appointment worksmobileAppointment, tenant domain.Tenant, appointments []worksmobileAppointment, tenantByID map[string]domain.Tenant) bool {
|
||||
if strings.TrimSpace(appointment.Source) != "email_domain" || !isWorksmobileDomainRootTenant(tenant) {
|
||||
return false
|
||||
|
||||
@@ -315,19 +315,164 @@ func TestBuildWorksmobileUserPayloadMapsAdditionalAppointmentsToOrgUnits(t *test
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Engineering", payload.Task)
|
||||
require.Equal(t, "PM", payload.Task)
|
||||
require.Len(t, payload.Organizations, 2)
|
||||
require.Equal(t, int64(1002), payload.Organizations[0].DomainID)
|
||||
require.True(t, payload.Organizations[0].Primary)
|
||||
require.Equal(t, "externalKey:"+secondaryTenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
|
||||
require.True(t, payload.Organizations[0].OrgUnits[0].Primary)
|
||||
require.NotNil(t, payload.Organizations[0].OrgUnits[0].IsManager)
|
||||
require.True(t, *payload.Organizations[0].OrgUnits[0].IsManager)
|
||||
require.Equal(t, int64(1001), payload.Organizations[1].DomainID)
|
||||
require.False(t, payload.Organizations[1].Primary)
|
||||
require.Equal(t, "externalKey:"+primaryTenantID, payload.Organizations[1].OrgUnits[0].OrgUnitID)
|
||||
require.True(t, payload.Organizations[1].OrgUnits[0].Primary)
|
||||
require.Nil(t, payload.Organizations[1].OrgUnits[0].IsManager)
|
||||
}
|
||||
|
||||
func TestBuildWorksmobileUserPayloadUsesFirstSyncableAppointmentAsWorksmobilePrimary(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
t.Setenv("HANMAC_DOMAIN_ID", "1002")
|
||||
hanmacRootID := "11111111-1111-1111-1111-111111111111"
|
||||
samanRootID := "22222222-2222-2222-2222-222222222222"
|
||||
firstTenantID := "33333333-3333-3333-3333-333333333333"
|
||||
secondTenantID := "44444444-4444-4444-4444-444444444444"
|
||||
user := domain.User{
|
||||
ID: "55555555-5555-5555-5555-555555555555",
|
||||
Email: "first-order@samaneng.com",
|
||||
Name: "First Order User",
|
||||
TenantID: &secondTenantID,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": firstTenantID,
|
||||
"isPrimary": false,
|
||||
},
|
||||
map[string]any{
|
||||
"tenantId": secondTenantID,
|
||||
"isPrimary": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
hanmacRoot := domain.Tenant{
|
||||
ID: hanmacRootID,
|
||||
Slug: "hanmac",
|
||||
Name: "한맥기술",
|
||||
Type: domain.TenantTypeCompany,
|
||||
Domains: []domain.TenantDomain{{Domain: "hanmaceng.co.kr"}},
|
||||
}
|
||||
samanRoot := domain.Tenant{
|
||||
ID: samanRootID,
|
||||
Slug: "saman",
|
||||
Name: "삼안",
|
||||
Type: domain.TenantTypeCompany,
|
||||
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||
}
|
||||
firstTenant := domain.Tenant{
|
||||
ID: firstTenantID,
|
||||
Slug: "first-team",
|
||||
Name: "First Team",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
ParentID: &hanmacRootID,
|
||||
}
|
||||
secondTenant := domain.Tenant{
|
||||
ID: secondTenantID,
|
||||
Slug: "second-team",
|
||||
Name: "Second Team",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
ParentID: &samanRootID,
|
||||
}
|
||||
|
||||
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
|
||||
user,
|
||||
secondTenant,
|
||||
map[string]domain.Tenant{
|
||||
hanmacRootID: hanmacRoot,
|
||||
samanRootID: samanRoot,
|
||||
firstTenantID: firstTenant,
|
||||
secondTenantID: secondTenant,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, payload.Organizations, 2)
|
||||
require.Equal(t, int64(1002), payload.Organizations[0].DomainID)
|
||||
require.True(t, payload.Organizations[0].Primary)
|
||||
require.Equal(t, "externalKey:"+firstTenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
|
||||
require.True(t, payload.Organizations[0].OrgUnits[0].Primary)
|
||||
require.Equal(t, int64(1001), payload.Organizations[1].DomainID)
|
||||
require.False(t, payload.Organizations[1].Primary)
|
||||
}
|
||||
|
||||
func TestBuildWorksmobileUserPayloadSkipsExcludedAppointmentWhenChoosingWorksmobilePrimary(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
t.Setenv("HANMAC_DOMAIN_ID", "1002")
|
||||
excludedRootID := "11111111-1111-1111-1111-111111111111"
|
||||
includedRootID := "22222222-2222-2222-2222-222222222222"
|
||||
excludedTenantID := "33333333-3333-3333-3333-333333333333"
|
||||
includedTenantID := "44444444-4444-4444-4444-444444444444"
|
||||
user := domain.User{
|
||||
ID: "55555555-5555-5555-5555-555555555555",
|
||||
Email: "skip-excluded@samaneng.com",
|
||||
Name: "Skip Excluded User",
|
||||
TenantID: &includedTenantID,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{"tenantId": excludedTenantID},
|
||||
map[string]any{"tenantId": includedTenantID},
|
||||
},
|
||||
},
|
||||
}
|
||||
excludedRoot := domain.Tenant{
|
||||
ID: excludedRootID,
|
||||
Slug: "hanmac",
|
||||
Name: "한맥기술",
|
||||
Type: domain.TenantTypeCompany,
|
||||
Domains: []domain.TenantDomain{{Domain: "hanmaceng.co.kr"}},
|
||||
Config: domain.JSONMap{"worksmobileExcluded": true},
|
||||
}
|
||||
includedRoot := domain.Tenant{
|
||||
ID: includedRootID,
|
||||
Slug: "saman",
|
||||
Name: "삼안",
|
||||
Type: domain.TenantTypeCompany,
|
||||
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||
}
|
||||
excludedTenant := domain.Tenant{
|
||||
ID: excludedTenantID,
|
||||
Slug: "excluded-team",
|
||||
Name: "Excluded Team",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
ParentID: &excludedRootID,
|
||||
}
|
||||
includedTenant := domain.Tenant{
|
||||
ID: includedTenantID,
|
||||
Slug: "included-team",
|
||||
Name: "Included Team",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
ParentID: &includedRootID,
|
||||
}
|
||||
|
||||
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
|
||||
user,
|
||||
includedTenant,
|
||||
map[string]domain.Tenant{
|
||||
excludedRootID: excludedRoot,
|
||||
includedRootID: includedRoot,
|
||||
excludedTenantID: excludedTenant,
|
||||
includedTenantID: includedTenant,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, payload.Organizations, 1)
|
||||
require.Equal(t, int64(1001), payload.Organizations[0].DomainID)
|
||||
require.True(t, payload.Organizations[0].Primary)
|
||||
require.Equal(t, "externalKey:"+primaryTenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
|
||||
require.Equal(t, "externalKey:"+includedTenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
|
||||
require.True(t, payload.Organizations[0].OrgUnits[0].Primary)
|
||||
require.Nil(t, payload.Organizations[0].OrgUnits[0].IsManager)
|
||||
require.Equal(t, int64(1002), payload.Organizations[1].DomainID)
|
||||
require.False(t, payload.Organizations[1].Primary)
|
||||
require.Equal(t, "externalKey:"+secondaryTenantID, payload.Organizations[1].OrgUnits[0].OrgUnitID)
|
||||
require.True(t, payload.Organizations[1].OrgUnits[0].Primary)
|
||||
require.NotNil(t, payload.Organizations[1].OrgUnits[0].IsManager)
|
||||
require.True(t, *payload.Organizations[1].OrgUnits[0].IsManager)
|
||||
}
|
||||
|
||||
func TestBuildWorksmobileUserPayloadKeepsPrimaryTenantWhenEmailDomainAppointmentExists(t *testing.T) {
|
||||
|
||||
@@ -141,7 +141,11 @@ func (w *WorksmobileRelayWorker) dispatch(ctx context.Context, job domain.Worksm
|
||||
}
|
||||
aliasEmails := append([]string(nil), payload.AliasEmails...)
|
||||
payload.AliasEmails = nil
|
||||
if err := w.client.UpsertUser(ctx, payload); err != nil {
|
||||
if stringValue(job.Payload[worksmobileProvisioningModeKey]) == worksmobileProvisioningUpdateOnly {
|
||||
if err := w.client.UpdateUserOnly(ctx, payload); err != nil {
|
||||
return fmt.Errorf("worksmobile user update failed: %w", err)
|
||||
}
|
||||
} else if err := w.client.UpsertUser(ctx, payload); err != nil {
|
||||
return fmt.Errorf("worksmobile user upsert failed: %w", err)
|
||||
}
|
||||
for _, aliasEmail := range aliasEmails {
|
||||
|
||||
@@ -16,14 +16,18 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
HanmacFamilyTenantSlug = "hanmac-family"
|
||||
worksmobileExcludedConfigKey = "worksmobileExcluded"
|
||||
HanmacFamilyTenantSlug = "hanmac-family"
|
||||
worksmobileExcludedConfigKey = "worksmobileExcluded"
|
||||
worksmobileIdentityMirrorVersion = "kratos-full-pagination-v1"
|
||||
worksmobileProvisioningModeKey = "provisioningMode"
|
||||
worksmobileProvisioningUpdateOnly = "update_only"
|
||||
)
|
||||
|
||||
type WorksmobileSyncer interface {
|
||||
EnqueueTenantUpsertIfInScope(ctx context.Context, tenant domain.Tenant) error
|
||||
EnqueueTenantDeleteIfInScope(ctx context.Context, tenant domain.Tenant) error
|
||||
EnqueueUserUpsertIfInScope(ctx context.Context, user domain.User) error
|
||||
EnqueueUserUpdateIfInScope(ctx context.Context, user domain.User) error
|
||||
EnqueueUserDeleteIfInScope(ctx context.Context, user domain.User) error
|
||||
}
|
||||
|
||||
@@ -103,55 +107,62 @@ type WorksmobileComparison struct {
|
||||
}
|
||||
|
||||
type WorksmobileComparisonItem struct {
|
||||
ResourceType string `json:"resourceType"`
|
||||
BaronID string `json:"baronId,omitempty"`
|
||||
BaronSlug string `json:"baronSlug,omitempty"`
|
||||
BaronName string `json:"baronName,omitempty"`
|
||||
BaronEmail string `json:"baronEmail,omitempty"`
|
||||
BaronPhone string `json:"baronPhone,omitempty"`
|
||||
BaronEmployeeNumber string `json:"baronEmployeeNumber,omitempty"`
|
||||
BaronPrimaryOrgID string `json:"baronPrimaryOrgId,omitempty"`
|
||||
BaronPrimaryOrgSlug string `json:"baronPrimaryOrgSlug,omitempty"`
|
||||
BaronPrimaryOrgName string `json:"baronPrimaryOrgName,omitempty"`
|
||||
BaronParentID string `json:"baronParentId,omitempty"`
|
||||
BaronParentSlug string `json:"baronParentSlug,omitempty"`
|
||||
BaronParentName string `json:"baronParentName,omitempty"`
|
||||
WorksmobileID string `json:"worksmobileId,omitempty"`
|
||||
ExternalKey string `json:"externalKey,omitempty"`
|
||||
WorksmobileName string `json:"worksmobileName,omitempty"`
|
||||
WorksmobileEmail string `json:"worksmobileEmail,omitempty"`
|
||||
WorksmobilePhone string `json:"worksmobilePhone,omitempty"`
|
||||
WorksmobileEmployeeNumber string `json:"worksmobileEmployeeNumber,omitempty"`
|
||||
WorksmobileAccountStatus string `json:"worksmobileAccountStatus,omitempty"`
|
||||
WorksmobileLevelID string `json:"worksmobileLevelId,omitempty"`
|
||||
WorksmobileLevelName string `json:"worksmobileLevelName,omitempty"`
|
||||
WorksmobileTask string `json:"worksmobileTask,omitempty"`
|
||||
WorksmobileDomainID int64 `json:"worksmobileDomainId,omitempty"`
|
||||
WorksmobileDomainName string `json:"worksmobileDomainName,omitempty"`
|
||||
WorksmobilePrimaryOrgID string `json:"worksmobilePrimaryOrgId,omitempty"`
|
||||
WorksmobilePrimaryOrgName string `json:"worksmobilePrimaryOrgName,omitempty"`
|
||||
WorksmobilePrimaryOrgPositionID string `json:"worksmobilePrimaryOrgPositionId,omitempty"`
|
||||
WorksmobilePrimaryOrgPositionName string `json:"worksmobilePrimaryOrgPositionName,omitempty"`
|
||||
WorksmobilePrimaryOrgIsManager *bool `json:"worksmobilePrimaryOrgIsManager,omitempty"`
|
||||
BaronParentWorksmobileID string `json:"baronParentWorksmobileId,omitempty"`
|
||||
BaronParentWorksmobileName string `json:"baronParentWorksmobileName,omitempty"`
|
||||
BaronParentWorksmobileEmail string `json:"baronParentWorksmobileEmail,omitempty"`
|
||||
WorksmobileParentID string `json:"worksmobileParentId,omitempty"`
|
||||
WorksmobileParentName string `json:"worksmobileParentName,omitempty"`
|
||||
WorksmobileParentEmail string `json:"worksmobileParentEmail,omitempty"`
|
||||
WorksmobileParentExternalKey string `json:"worksmobileParentExternalKey,omitempty"`
|
||||
WorksmobileJobStatus string `json:"worksmobileJobStatus,omitempty"`
|
||||
WorksmobileJobRetryCount int `json:"worksmobileJobRetryCount,omitempty"`
|
||||
WorksmobileLastError string `json:"worksmobileLastError,omitempty"`
|
||||
WorksmobileLastAttemptAt string `json:"worksmobileLastAttemptAt,omitempty"`
|
||||
Status string `json:"status"`
|
||||
ResourceType string `json:"resourceType"`
|
||||
BaronID string `json:"baronId,omitempty"`
|
||||
BaronSlug string `json:"baronSlug,omitempty"`
|
||||
BaronName string `json:"baronName,omitempty"`
|
||||
BaronEmail string `json:"baronEmail,omitempty"`
|
||||
BaronPhone string `json:"baronPhone,omitempty"`
|
||||
BaronEmployeeNumber string `json:"baronEmployeeNumber,omitempty"`
|
||||
BaronPrimaryOrgID string `json:"baronPrimaryOrgId,omitempty"`
|
||||
BaronPrimaryOrgSlug string `json:"baronPrimaryOrgSlug,omitempty"`
|
||||
BaronPrimaryOrgName string `json:"baronPrimaryOrgName,omitempty"`
|
||||
BaronParentID string `json:"baronParentId,omitempty"`
|
||||
BaronParentSlug string `json:"baronParentSlug,omitempty"`
|
||||
BaronParentName string `json:"baronParentName,omitempty"`
|
||||
WorksmobileID string `json:"worksmobileId,omitempty"`
|
||||
ExternalKey string `json:"externalKey,omitempty"`
|
||||
WorksmobileName string `json:"worksmobileName,omitempty"`
|
||||
WorksmobileEmail string `json:"worksmobileEmail,omitempty"`
|
||||
WorksmobilePhone string `json:"worksmobilePhone,omitempty"`
|
||||
WorksmobileEmployeeNumber string `json:"worksmobileEmployeeNumber,omitempty"`
|
||||
WorksmobileAccountStatus string `json:"worksmobileAccountStatus,omitempty"`
|
||||
WorksmobileLevelID string `json:"worksmobileLevelId,omitempty"`
|
||||
WorksmobileLevelName string `json:"worksmobileLevelName,omitempty"`
|
||||
WorksmobileTask string `json:"worksmobileTask,omitempty"`
|
||||
WorksmobileDomainID int64 `json:"worksmobileDomainId,omitempty"`
|
||||
WorksmobileDomainName string `json:"worksmobileDomainName,omitempty"`
|
||||
WorksmobilePrimaryOrgID string `json:"worksmobilePrimaryOrgId,omitempty"`
|
||||
WorksmobilePrimaryOrgName string `json:"worksmobilePrimaryOrgName,omitempty"`
|
||||
WorksmobilePrimaryOrgPositionID string `json:"worksmobilePrimaryOrgPositionId,omitempty"`
|
||||
WorksmobilePrimaryOrgPositionName string `json:"worksmobilePrimaryOrgPositionName,omitempty"`
|
||||
WorksmobilePrimaryOrgIsManager *bool `json:"worksmobilePrimaryOrgIsManager,omitempty"`
|
||||
BaronParentWorksmobileID string `json:"baronParentWorksmobileId,omitempty"`
|
||||
BaronParentWorksmobileName string `json:"baronParentWorksmobileName,omitempty"`
|
||||
BaronParentWorksmobileEmail string `json:"baronParentWorksmobileEmail,omitempty"`
|
||||
WorksmobileParentID string `json:"worksmobileParentId,omitempty"`
|
||||
WorksmobileParentName string `json:"worksmobileParentName,omitempty"`
|
||||
WorksmobileParentEmail string `json:"worksmobileParentEmail,omitempty"`
|
||||
WorksmobileParentExternalKey string `json:"worksmobileParentExternalKey,omitempty"`
|
||||
WorksmobileJobStatus string `json:"worksmobileJobStatus,omitempty"`
|
||||
WorksmobileJobRetryCount int `json:"worksmobileJobRetryCount,omitempty"`
|
||||
WorksmobileLastError string `json:"worksmobileLastError,omitempty"`
|
||||
WorksmobileLastAttemptAt string `json:"worksmobileLastAttemptAt,omitempty"`
|
||||
UpdateReasons []string `json:"updateReasons,omitempty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type worksmobileSyncService struct {
|
||||
tenantService TenantService
|
||||
userRepo repository.UserRepository
|
||||
outboxRepo repository.WorksmobileOutboxRepository
|
||||
client WorksmobileDirectoryClient
|
||||
tenantService TenantService
|
||||
userRepo repository.UserRepository
|
||||
outboxRepo repository.WorksmobileOutboxRepository
|
||||
client WorksmobileDirectoryClient
|
||||
identityMirror WorksmobileIdentityMirror
|
||||
}
|
||||
|
||||
type WorksmobileIdentityMirror interface {
|
||||
GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error)
|
||||
ListIdentityMirrors(ctx context.Context) ([]KratosIdentity, error)
|
||||
}
|
||||
|
||||
func NewWorksmobileSyncService(tenantService TenantService, userRepo repository.UserRepository, outboxRepo repository.WorksmobileOutboxRepository, client WorksmobileDirectoryClient) *worksmobileSyncService {
|
||||
@@ -163,6 +174,13 @@ func NewWorksmobileSyncService(tenantService TenantService, userRepo repository.
|
||||
}
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) SetIdentityMirror(source WorksmobileIdentityMirror) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.identityMirror = source
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) GetTenantOverview(ctx context.Context, tenantID string) (WorksmobileTenantOverview, error) {
|
||||
tenant, err := s.tenantService.GetTenant(ctx, tenantID)
|
||||
if err != nil {
|
||||
@@ -344,7 +362,7 @@ func (s *worksmobileSyncService) GetComparison(ctx context.Context, tenantID str
|
||||
tenantIDs = append(tenantIDs, tenant.ID)
|
||||
}
|
||||
}
|
||||
users, err := s.userRepo.FindByTenantIDs(ctx, tenantIDs)
|
||||
users, err := s.comparisonUsers(ctx, tenantIDs)
|
||||
if err != nil {
|
||||
return WorksmobileComparison{}, err
|
||||
}
|
||||
@@ -360,11 +378,96 @@ func (s *worksmobileSyncService) GetComparison(ctx context.Context, tenantID str
|
||||
recentJobs, _ := s.outboxRepo.ListRecent(ctx, 1000)
|
||||
|
||||
return WorksmobileComparison{
|
||||
Users: compareWorksmobileUsers(users, remoteUsers, includeMatched, tenantByID, worksmobileUserJobSummaries(recentJobs)),
|
||||
Users: compareWorksmobileUsersWithRemoteGroups(users, remoteUsers, includeMatched, tenantByID, remoteGroups, worksmobileUserJobSummaries(recentJobs)),
|
||||
Groups: compareWorksmobileGroups(append([]domain.Tenant{*root}, tenants...), remoteGroups, includeMatched),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) comparisonUsers(ctx context.Context, tenantIDs []string) ([]domain.User, error) {
|
||||
if s.identityMirror != nil {
|
||||
status, err := s.identityMirror.GetIdentityCacheStatus(ctx)
|
||||
if err == nil &&
|
||||
status.RedisReady &&
|
||||
status.Status == "ready" &&
|
||||
status.MirrorVersion == worksmobileIdentityMirrorVersion {
|
||||
identities, err := s.identityMirror.ListIdentityMirrors(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return worksmobileUsersFromIdentityMirror(identities, tenantIDs), nil
|
||||
}
|
||||
}
|
||||
return s.userRepo.FindByTenantIDs(ctx, tenantIDs)
|
||||
}
|
||||
|
||||
func worksmobileUsersFromIdentityMirror(identities []KratosIdentity, tenantIDs []string) []domain.User {
|
||||
allowed := make(map[string]bool, len(tenantIDs))
|
||||
for _, tenantID := range tenantIDs {
|
||||
allowed[strings.TrimSpace(tenantID)] = true
|
||||
}
|
||||
users := make([]domain.User, 0, len(identities))
|
||||
for _, identity := range identities {
|
||||
tenantID := traitString(identity.Traits, "tenant_id")
|
||||
if tenantID == "" || !allowed[tenantID] {
|
||||
continue
|
||||
}
|
||||
user := worksmobileUserFromIdentity(identity)
|
||||
users = append(users, user)
|
||||
}
|
||||
return users
|
||||
}
|
||||
|
||||
func worksmobileUserFromIdentity(identity KratosIdentity) domain.User {
|
||||
metadata := domain.JSONMap{}
|
||||
for key, value := range identity.Traits {
|
||||
metadata[key] = value
|
||||
}
|
||||
tenantID := traitString(identity.Traits, "tenant_id")
|
||||
status := domain.UserStatusActive
|
||||
if identity.State == "inactive" {
|
||||
status = domain.UserStatusArchived
|
||||
}
|
||||
if traitStatus := traitString(identity.Traits, "status"); traitStatus != "" {
|
||||
status = domain.NormalizeUserStatus(traitStatus)
|
||||
}
|
||||
user := domain.User{
|
||||
ID: strings.TrimSpace(identity.ID),
|
||||
Email: traitString(identity.Traits, "email"),
|
||||
Name: traitString(identity.Traits, "name"),
|
||||
Phone: traitString(identity.Traits, "phone_number"),
|
||||
Role: domain.NormalizeRole(traitString(identity.Traits, "role")),
|
||||
AffiliationType: traitString(identity.Traits, "affiliationType"),
|
||||
Department: traitString(identity.Traits, "department"),
|
||||
Grade: traitString(identity.Traits, "grade"),
|
||||
Position: traitString(identity.Traits, "position"),
|
||||
JobTitle: traitString(identity.Traits, "jobTitle"),
|
||||
Metadata: metadata,
|
||||
Status: status,
|
||||
CreatedAt: identity.CreatedAt,
|
||||
UpdatedAt: identity.UpdatedAt,
|
||||
}
|
||||
if tenantID != "" {
|
||||
user.TenantID = &tenantID
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
func traitString(traits map[string]any, key string) string {
|
||||
if traits == nil {
|
||||
return ""
|
||||
}
|
||||
value, ok := traits[key]
|
||||
if !ok || value == nil {
|
||||
return ""
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(typed)
|
||||
default:
|
||||
return strings.TrimSpace(fmt.Sprint(typed))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) EnqueueBackfillDryRun(ctx context.Context, tenantID string) (WorksmobileBackfillDryRun, error) {
|
||||
root, err := s.hanmacRoot(ctx, tenantID)
|
||||
if err != nil {
|
||||
@@ -545,35 +648,32 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
|
||||
return nil, err
|
||||
}
|
||||
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
||||
if _, ok := tenantByID[tenant.ID]; !ok {
|
||||
return nil, errors.New("target user tenant is excluded from Worksmobile sync")
|
||||
}
|
||||
_, tenantInScope := tenantByID[tenant.ID]
|
||||
if domain.IsWorksDeprovisionUserStatus(user.Status) {
|
||||
return s.enqueueUserDelete(ctx, *user, "user:delete:"+user.ID, root.ID)
|
||||
}
|
||||
if !domain.IsWorksProvisionedUserStatus(user.Status) {
|
||||
return nil, errors.New("target user status is excluded from Worksmobile sync")
|
||||
}
|
||||
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
|
||||
*user,
|
||||
*tenant,
|
||||
tenantByID,
|
||||
root.Config,
|
||||
)
|
||||
if err != nil {
|
||||
err := errors.New("target user status is excluded from Worksmobile sync")
|
||||
if recordErr := s.recordRejectedUserSync(ctx, root.ID, *user, *tenant, err); recordErr != nil {
|
||||
return nil, errors.Join(err, recordErr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
initialPassword = strings.TrimSpace(initialPassword)
|
||||
if initialPassword != "" {
|
||||
payload.PasswordConfig = WorksmobilePasswordConfig{
|
||||
PasswordCreationType: "ADMIN",
|
||||
Password: initialPassword,
|
||||
}
|
||||
buildPayload := BuildWorksmobileUserPayloadForDomainTenants
|
||||
if !tenantInScope {
|
||||
buildPayload = BuildWorksmobileUserPayloadForScopedDomainTenants
|
||||
}
|
||||
payload, err := buildPayload(*user, *tenant, tenantByID, root.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.validateUserAliasLocalParts(ctx, root, *user, payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
action := WorksmobileUserStatusAction(user.Status)
|
||||
if action == domain.WorksmobileActionUpsert {
|
||||
payload.PasswordConfig = worksmobileAdminInitialPasswordConfig(initialPassword)
|
||||
}
|
||||
item := &domain.WorksmobileOutbox{
|
||||
ResourceType: domain.WorksmobileResourceUser,
|
||||
ResourceID: user.ID,
|
||||
@@ -594,10 +694,48 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) recordRejectedUserSync(ctx context.Context, rootID string, user domain.User, tenant domain.Tenant, reason error) error {
|
||||
payload := WorksmobileUserPayload{
|
||||
Email: strings.TrimSpace(user.Email),
|
||||
UserExternalKey: user.ID,
|
||||
UserName: WorksmobileUserName{LastName: strings.TrimSpace(user.Name)},
|
||||
CellPhone: domain.NormalizePhoneNumber(user.Phone),
|
||||
EmployeeNumber: metadataEmployeeNumber(user.Metadata),
|
||||
Locale: "ko_KR",
|
||||
Task: strings.TrimSpace(user.JobTitle),
|
||||
}
|
||||
outboxPayload := worksmobileUserOutboxPayload(rootID, payload, user.Status)
|
||||
outboxPayload["displayName"] = strings.TrimSpace(user.Name)
|
||||
outboxPayload["primaryLeafOrgName"] = strings.TrimSpace(tenant.Name)
|
||||
item := &domain.WorksmobileOutbox{
|
||||
ResourceType: domain.WorksmobileResourceUser,
|
||||
ResourceID: user.ID,
|
||||
Action: WorksmobileUserStatusAction(user.Status),
|
||||
DedupeKey: worksmobileUserSyncDedupeKey("rejected", user.ID),
|
||||
Payload: outboxPayload,
|
||||
Status: domain.WorksmobileOutboxStatusFailed,
|
||||
LastError: reason.Error(),
|
||||
}
|
||||
return s.outboxRepo.Create(ctx, item)
|
||||
}
|
||||
|
||||
func worksmobileUserSyncDedupeKey(action, userID string) string {
|
||||
return "user:" + strings.ToLower(action) + ":" + userID + ":" + uuid.NewString()
|
||||
}
|
||||
|
||||
func worksmobileAdminInitialPasswordConfig(password string) WorksmobilePasswordConfig {
|
||||
password = strings.TrimSpace(password)
|
||||
if password == "" {
|
||||
password = GenerateWorksmobileInitialPassword()
|
||||
}
|
||||
changePasswordAtNextLogin := true
|
||||
return WorksmobilePasswordConfig{
|
||||
PasswordCreationType: "ADMIN",
|
||||
Password: password,
|
||||
ChangePasswordAtNextLogin: &changePasswordAtNextLogin,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) EnqueueUserPasswordReset(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) {
|
||||
root, err := s.hanmacRoot(ctx, tenantID)
|
||||
if err != nil {
|
||||
@@ -629,10 +767,11 @@ func (s *worksmobileSyncService) EnqueueUserPasswordReset(ctx context.Context, t
|
||||
return nil, err
|
||||
}
|
||||
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
||||
buildPayload := BuildWorksmobileUserPayloadForDomainTenants
|
||||
if _, ok := tenantByID[tenant.ID]; !ok {
|
||||
return nil, errors.New("target user tenant is excluded from Worksmobile sync")
|
||||
buildPayload = BuildWorksmobileUserPayloadForScopedDomainTenants
|
||||
}
|
||||
payload, err := BuildWorksmobileUserPayloadForDomainTenants(*user, *tenant, tenantByID, root.Config)
|
||||
payload, err := buildPayload(*user, *tenant, tenantByID, root.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -848,6 +987,14 @@ func (s *worksmobileSyncService) EnqueueTenantDeleteIfInScope(ctx context.Contex
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) EnqueueUserUpsertIfInScope(ctx context.Context, user domain.User) error {
|
||||
return s.enqueueUserUpsertIfInScope(ctx, user, false)
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) EnqueueUserUpdateIfInScope(ctx context.Context, user domain.User) error {
|
||||
return s.enqueueUserUpsertIfInScope(ctx, user, true)
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) enqueueUserUpsertIfInScope(ctx context.Context, user domain.User, updateOnly bool) error {
|
||||
if user.TenantID == nil || *user.TenantID == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -887,12 +1034,19 @@ func (s *worksmobileSyncService) EnqueueUserUpsertIfInScope(ctx context.Context,
|
||||
return err
|
||||
}
|
||||
action := WorksmobileUserStatusAction(user.Status)
|
||||
if action == domain.WorksmobileActionUpsert && !updateOnly {
|
||||
payload.PasswordConfig = worksmobileAdminInitialPasswordConfig("")
|
||||
}
|
||||
outboxPayload := worksmobileUserOutboxPayload(root.ID, payload, user.Status)
|
||||
if action == domain.WorksmobileActionUpsert && updateOnly {
|
||||
outboxPayload[worksmobileProvisioningModeKey] = worksmobileProvisioningUpdateOnly
|
||||
}
|
||||
return s.outboxRepo.Create(ctx, &domain.WorksmobileOutbox{
|
||||
ResourceType: domain.WorksmobileResourceUser,
|
||||
ResourceID: user.ID,
|
||||
Action: action,
|
||||
DedupeKey: worksmobileUserSyncDedupeKey(action, user.ID),
|
||||
Payload: worksmobileUserOutboxPayload(root.ID, payload, user.Status),
|
||||
Payload: outboxPayload,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1429,10 +1583,15 @@ func worksmobileUserJobSummaries(jobs []domain.WorksmobileOutbox) map[string]wor
|
||||
}
|
||||
|
||||
func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []WorksmobileRemoteUser, includeMatched bool, localTenants map[string]domain.Tenant, jobSummaries ...map[string]worksmobileUserJobSummary) []WorksmobileComparisonItem {
|
||||
return compareWorksmobileUsersWithRemoteGroups(localUsers, remoteUsers, includeMatched, localTenants, nil, jobSummaries...)
|
||||
}
|
||||
|
||||
func compareWorksmobileUsersWithRemoteGroups(localUsers []domain.User, remoteUsers []WorksmobileRemoteUser, includeMatched bool, localTenants map[string]domain.Tenant, remoteGroups []WorksmobileRemoteGroup, jobSummaries ...map[string]worksmobileUserJobSummary) []WorksmobileComparisonItem {
|
||||
jobSummaryByUserID := map[string]worksmobileUserJobSummary{}
|
||||
if len(jobSummaries) > 0 && jobSummaries[0] != nil {
|
||||
jobSummaryByUserID = jobSummaries[0]
|
||||
}
|
||||
remoteOrgUnitByExternalID := worksmobileRemoteOrgUnitByExternalID(remoteGroups)
|
||||
remoteByExternalID := map[string]WorksmobileRemoteUser{}
|
||||
remoteByEmail := map[string]WorksmobileRemoteUser{}
|
||||
for _, remote := range remoteUsers {
|
||||
@@ -1462,7 +1621,11 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
|
||||
if !matched {
|
||||
remote, matched = remoteByEmail[strings.ToLower(strings.TrimSpace(user.Email))]
|
||||
}
|
||||
needsUpdate := matched && worksmobileUserNeedsUpdate(user, remote, localTenants)
|
||||
updateReasons := []string(nil)
|
||||
if matched {
|
||||
updateReasons = worksmobileUserUpdateReasons(user, remote, localTenants, remoteOrgUnitByExternalID)
|
||||
}
|
||||
needsUpdate := len(updateReasons) > 0
|
||||
if matched && !includeMatched && !needsUpdate {
|
||||
matchedRemoteIDs[remote.ID] = true
|
||||
continue
|
||||
@@ -1491,6 +1654,7 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
|
||||
item.Status = "matched"
|
||||
if needsUpdate {
|
||||
item.Status = "needs_update"
|
||||
item.UpdateReasons = updateReasons
|
||||
}
|
||||
item.WorksmobileID = remote.ID
|
||||
item.ExternalKey = remote.ExternalID
|
||||
@@ -1571,6 +1735,18 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
|
||||
return result
|
||||
}
|
||||
|
||||
func worksmobileRemoteOrgUnitByExternalID(remoteGroups []WorksmobileRemoteGroup) map[string]WorksmobileRemoteGroup {
|
||||
result := make(map[string]WorksmobileRemoteGroup, len(remoteGroups))
|
||||
for _, remote := range remoteGroups {
|
||||
externalID := strings.TrimSpace(remote.ExternalID)
|
||||
if externalID == "" {
|
||||
continue
|
||||
}
|
||||
result[externalID] = remote
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func worksmobileRemoteAccountStatus(remote WorksmobileRemoteUser) string {
|
||||
return normalizeWorksmobileAccountStatus(
|
||||
remote.AccountStatus,
|
||||
@@ -1582,29 +1758,40 @@ func worksmobileRemoteAccountStatus(remote WorksmobileRemoteUser) string {
|
||||
)
|
||||
}
|
||||
|
||||
func worksmobileUserNeedsUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant) bool {
|
||||
func worksmobileUserNeedsUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) bool {
|
||||
return len(worksmobileUserUpdateReasons(user, remote, localTenants, remoteOrgUnitByExternalID)) > 0
|
||||
}
|
||||
|
||||
func worksmobileUserUpdateReasons(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) []string {
|
||||
reasons := []string{}
|
||||
if strings.TrimSpace(remote.ExternalID) != strings.TrimSpace(user.ID) {
|
||||
return true
|
||||
reasons = append(reasons, "external_key")
|
||||
}
|
||||
if strings.TrimSpace(remote.DisplayName) != strings.TrimSpace(user.Name) {
|
||||
return true
|
||||
reasons = append(reasons, "name")
|
||||
}
|
||||
if strings.ToLower(strings.TrimSpace(remote.Email)) != strings.ToLower(strings.TrimSpace(user.Email)) {
|
||||
return true
|
||||
reasons = append(reasons, "email")
|
||||
}
|
||||
if worksmobileUserPhoneNeedsUpdate(user, remote) {
|
||||
return true
|
||||
reasons = append(reasons, "phone")
|
||||
}
|
||||
if worksmobileUserEmployeeNumberNeedsUpdate(user, remote) {
|
||||
return true
|
||||
reasons = append(reasons, "employee_number")
|
||||
}
|
||||
return false
|
||||
if worksmobileUserOrganizationsNeedUpdate(user, remote, localTenants, remoteOrgUnitByExternalID) {
|
||||
reasons = append(reasons, "organization")
|
||||
}
|
||||
if worksmobileUserManagerNeedsUpdate(user, remote) {
|
||||
reasons = append(reasons, "manager")
|
||||
}
|
||||
return reasons
|
||||
}
|
||||
|
||||
func worksmobileUserPhoneNeedsUpdate(user domain.User, remote WorksmobileRemoteUser) bool {
|
||||
localPhone := normalizeWorksmobilePhoneForCompare(user.Phone)
|
||||
remotePhone := normalizeWorksmobilePhoneForCompare(remote.CellPhone)
|
||||
if localPhone == "" && remotePhone == "" {
|
||||
if localPhone == "" {
|
||||
return false
|
||||
}
|
||||
if localPhone != remotePhone {
|
||||
@@ -1636,11 +1823,11 @@ func worksmobileUserEmployeeNumberNeedsUpdate(user domain.User, remote Worksmobi
|
||||
return localEmployeeNumber != remoteEmployeeNumber
|
||||
}
|
||||
|
||||
func worksmobileUserOrganizationsNeedUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant) bool {
|
||||
if len(remote.Organizations) == 0 || user.TenantID == nil || localTenants == nil {
|
||||
func worksmobileUserOrganizationsNeedUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) bool {
|
||||
if localTenants == nil {
|
||||
return false
|
||||
}
|
||||
tenantID := strings.TrimSpace(*user.TenantID)
|
||||
tenantID := worksmobileUserComparisonTenantID(user, localTenants)
|
||||
if tenantID == "" {
|
||||
return false
|
||||
}
|
||||
@@ -1648,11 +1835,34 @@ func worksmobileUserOrganizationsNeedUpdate(user domain.User, remote Worksmobile
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
expected, err := BuildWorksmobileUserPayloadForDomainTenants(user, tenant, localTenants, worksmobileComparisonRootConfig(localTenants))
|
||||
if err != nil {
|
||||
expectedOrganizations, _, err := buildWorksmobileUserOrganizations(user, tenant, localTenants, worksmobileComparisonRootConfig(localTenants))
|
||||
if err != nil || len(expectedOrganizations) == 0 {
|
||||
return worksmobileUserPrimaryOrganizationNeedsUpdate(user, remote, localTenants, remoteOrgUnitByExternalID)
|
||||
}
|
||||
remoteOrganizations := remote.Organizations
|
||||
if len(remoteOrganizations) == 0 {
|
||||
remoteOrganizations = worksmobileRemoteUserLegacyOrganizations(remote, remoteOrgUnitByExternalID)
|
||||
} else {
|
||||
remoteOrganizations = worksmobileRemoteUserOrganizationsForCompare(remote, remoteOrgUnitByExternalID)
|
||||
}
|
||||
return !worksmobileUserOrganizationsEqual(expectedOrganizations, remoteOrganizations)
|
||||
}
|
||||
|
||||
func worksmobileUserPrimaryOrganizationNeedsUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) bool {
|
||||
tenantID := worksmobileUserComparisonPrimaryTenantID(user)
|
||||
if tenantID == "" {
|
||||
return false
|
||||
}
|
||||
return !worksmobileUserOrganizationsEqual(expected.Organizations, remote.Organizations)
|
||||
tenant, ok := localTenants[tenantID]
|
||||
if !ok || isWorksmobileDomainRootTenant(tenant) {
|
||||
return false
|
||||
}
|
||||
expectedPrimary := "externalKey:" + tenantID
|
||||
if remoteOrgUnit, ok := remoteOrgUnitByExternalID[tenantID]; ok && strings.TrimSpace(remoteOrgUnit.ID) != "" {
|
||||
expectedPrimary = strings.TrimSpace(remoteOrgUnit.ID)
|
||||
}
|
||||
remotePrimaryOrgUnits := worksmobileRemotePrimaryOrgUnitIDs(remote)
|
||||
return !worksmobileOrgUnitIDContains(remotePrimaryOrgUnits, expectedPrimary)
|
||||
}
|
||||
|
||||
func worksmobileComparisonRootConfig(localTenants map[string]domain.Tenant) domain.JSONMap {
|
||||
@@ -1664,9 +1874,131 @@ func worksmobileComparisonRootConfig(localTenants map[string]domain.Tenant) doma
|
||||
return nil
|
||||
}
|
||||
|
||||
func worksmobileUserComparisonPrimaryTenantID(user domain.User) string {
|
||||
for _, appointment := range worksmobileAppointmentsFromMetadata(user.Metadata) {
|
||||
if appointment.IsPrimary && strings.TrimSpace(appointment.TenantID) != "" {
|
||||
return strings.TrimSpace(appointment.TenantID)
|
||||
}
|
||||
}
|
||||
if user.TenantID == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(*user.TenantID)
|
||||
}
|
||||
|
||||
func worksmobileUserComparisonTenantID(user domain.User, localTenants map[string]domain.Tenant) string {
|
||||
if user.TenantID != nil {
|
||||
tenantID := strings.TrimSpace(*user.TenantID)
|
||||
if _, ok := localTenants[tenantID]; ok {
|
||||
return tenantID
|
||||
}
|
||||
}
|
||||
for _, appointment := range worksmobileAppointmentsFromMetadata(user.Metadata) {
|
||||
tenantID := strings.TrimSpace(appointment.TenantID)
|
||||
if tenantID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := localTenants[tenantID]; ok {
|
||||
return tenantID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func worksmobileRemoteUserLegacyOrganizations(remote WorksmobileRemoteUser, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) []WorksmobileUserOrganization {
|
||||
if strings.TrimSpace(remote.PrimaryOrgUnitID) == "" {
|
||||
return nil
|
||||
}
|
||||
return []WorksmobileUserOrganization{
|
||||
{
|
||||
DomainID: remote.DomainID,
|
||||
Email: strings.TrimSpace(remote.Email),
|
||||
Primary: true,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{
|
||||
{
|
||||
OrgUnitID: worksmobileCanonicalRemoteOrgUnitID(strings.TrimSpace(remote.PrimaryOrgUnitID), remoteOrgUnitByExternalID),
|
||||
Primary: true,
|
||||
PositionID: strings.TrimSpace(remote.PrimaryOrgUnitPositionID),
|
||||
IsManager: remote.PrimaryOrgUnitIsManager,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func worksmobileRemoteUserOrganizationsForCompare(remote WorksmobileRemoteUser, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) []WorksmobileUserOrganization {
|
||||
if len(remote.Organizations) == 0 {
|
||||
return nil
|
||||
}
|
||||
primaryOrgUnitID := strings.TrimSpace(remote.PrimaryOrgUnitID)
|
||||
result := make([]WorksmobileUserOrganization, len(remote.Organizations))
|
||||
for i, organization := range remote.Organizations {
|
||||
result[i] = organization
|
||||
if result[i].DomainID == 0 {
|
||||
result[i].DomainID = remote.DomainID
|
||||
}
|
||||
result[i].OrgUnits = make([]WorksmobileUserOrgUnit, len(organization.OrgUnits))
|
||||
copy(result[i].OrgUnits, organization.OrgUnits)
|
||||
for j, orgUnit := range result[i].OrgUnits {
|
||||
result[i].OrgUnits[j].OrgUnitID = worksmobileCanonicalRemoteOrgUnitID(orgUnit.OrgUnitID, remoteOrgUnitByExternalID)
|
||||
}
|
||||
if primaryOrgUnitID == "" {
|
||||
continue
|
||||
}
|
||||
for j, orgUnit := range result[i].OrgUnits {
|
||||
if strings.TrimSpace(orgUnit.OrgUnitID) != worksmobileCanonicalRemoteOrgUnitID(primaryOrgUnitID, remoteOrgUnitByExternalID) {
|
||||
continue
|
||||
}
|
||||
result[i].Primary = true
|
||||
result[i].OrgUnits[j].Primary = true
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func worksmobileCanonicalRemoteOrgUnitID(orgUnitID string, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) string {
|
||||
orgUnitID = strings.TrimSpace(orgUnitID)
|
||||
if orgUnitID == "" || strings.HasPrefix(orgUnitID, "externalKey:") {
|
||||
return orgUnitID
|
||||
}
|
||||
for externalID, remoteOrgUnit := range remoteOrgUnitByExternalID {
|
||||
if strings.TrimSpace(remoteOrgUnit.ID) == orgUnitID && strings.TrimSpace(externalID) != "" {
|
||||
return "externalKey:" + strings.TrimSpace(externalID)
|
||||
}
|
||||
}
|
||||
return orgUnitID
|
||||
}
|
||||
|
||||
func worksmobileRemotePrimaryOrgUnitIDs(remote WorksmobileRemoteUser) []string {
|
||||
result := make([]string, 0, 1)
|
||||
for _, organization := range remote.Organizations {
|
||||
if !organization.Primary {
|
||||
continue
|
||||
}
|
||||
for _, orgUnit := range organization.OrgUnits {
|
||||
if orgUnit.Primary {
|
||||
result = append(result, orgUnit.OrgUnitID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(result) == 0 && strings.TrimSpace(remote.PrimaryOrgUnitID) != "" {
|
||||
result = append(result, remote.PrimaryOrgUnitID)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func worksmobileOrgUnitIDContains(values []string, expected string) bool {
|
||||
expected = strings.TrimSpace(expected)
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) == expected {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type worksmobileComparableOrgUnit struct {
|
||||
organizationPrimary bool
|
||||
organizationEmail string
|
||||
unitPrimary bool
|
||||
positionID string
|
||||
comparePosition bool
|
||||
@@ -1688,9 +2020,6 @@ func worksmobileUserOrganizationsEqual(expected []WorksmobileUserOrganization, r
|
||||
if expectedUnit.organizationPrimary != remoteUnit.organizationPrimary {
|
||||
return false
|
||||
}
|
||||
if strings.ToLower(expectedUnit.organizationEmail) != strings.ToLower(remoteUnit.organizationEmail) {
|
||||
return false
|
||||
}
|
||||
if expectedUnit.unitPrimary != remoteUnit.unitPrimary {
|
||||
return false
|
||||
}
|
||||
@@ -1704,6 +2033,23 @@ func worksmobileUserOrganizationsEqual(expected []WorksmobileUserOrganization, r
|
||||
return true
|
||||
}
|
||||
|
||||
func worksmobilePrimaryOrgUnitCompareKey(organizations []WorksmobileUserOrganization) string {
|
||||
for _, organization := range organizations {
|
||||
if !organization.Primary {
|
||||
continue
|
||||
}
|
||||
for _, orgUnit := range organization.OrgUnits {
|
||||
if !orgUnit.Primary {
|
||||
continue
|
||||
}
|
||||
if key := worksmobileComparableOrgUnitKey(organization.DomainID, orgUnit.OrgUnitID); key != "" {
|
||||
return key
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func flattenExpectedWorksmobileUserOrganizations(organizations []WorksmobileUserOrganization) map[string]worksmobileComparableOrgUnit {
|
||||
result := map[string]worksmobileComparableOrgUnit{}
|
||||
for _, organization := range organizations {
|
||||
@@ -1714,7 +2060,6 @@ func flattenExpectedWorksmobileUserOrganizations(organizations []WorksmobileUser
|
||||
}
|
||||
result[key] = worksmobileComparableOrgUnit{
|
||||
organizationPrimary: organization.Primary,
|
||||
organizationEmail: strings.TrimSpace(organization.Email),
|
||||
unitPrimary: orgUnit.Primary,
|
||||
positionID: strings.TrimSpace(orgUnit.PositionID),
|
||||
comparePosition: strings.TrimSpace(orgUnit.PositionID) != "",
|
||||
@@ -1736,7 +2081,6 @@ func flattenRemoteWorksmobileUserOrganizations(organizations []WorksmobileUserOr
|
||||
}
|
||||
result[key] = worksmobileComparableOrgUnit{
|
||||
organizationPrimary: organization.Primary,
|
||||
organizationEmail: strings.TrimSpace(organization.Email),
|
||||
unitPrimary: orgUnit.Primary,
|
||||
positionID: strings.TrimSpace(orgUnit.PositionID),
|
||||
manager: orgUnit.IsManager,
|
||||
@@ -1766,22 +2110,43 @@ func worksmobileUserManagerNeedsUpdate(user domain.User, remote WorksmobileRemot
|
||||
if len(localManagers) == 0 {
|
||||
return false
|
||||
}
|
||||
remoteManagers := remote.OrgUnitManagers
|
||||
if len(remoteManagers) == 0 && remote.PrimaryOrgUnitID != "" {
|
||||
remoteManagers = map[string]*bool{remote.PrimaryOrgUnitID: remote.PrimaryOrgUnitIsManager}
|
||||
}
|
||||
for remoteOrgUnitID, remoteManager := range remoteManagers {
|
||||
if remoteManager == nil {
|
||||
continue
|
||||
remoteManagers := worksmobileRemoteOrgUnitManagerMap(remote)
|
||||
for localOrgUnitID, localManager := range localManagers {
|
||||
remoteManager := false
|
||||
if value, ok := remoteManagers[localOrgUnitID]; ok && value != nil {
|
||||
remoteManager = *value
|
||||
}
|
||||
localManager, ok := localManagers[worksmobileOrgUnitLocalExternalKey(remoteOrgUnitID)]
|
||||
if ok && localManager != *remoteManager {
|
||||
if localManager != remoteManager {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func worksmobileRemoteOrgUnitManagerMap(remote WorksmobileRemoteUser) map[string]*bool {
|
||||
result := map[string]*bool{}
|
||||
for orgUnitID, isManager := range remote.OrgUnitManagers {
|
||||
normalized := worksmobileOrgUnitLocalExternalKey(orgUnitID)
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
result[normalized] = isManager
|
||||
}
|
||||
for _, organization := range remote.Organizations {
|
||||
for _, orgUnit := range organization.OrgUnits {
|
||||
normalized := worksmobileOrgUnitLocalExternalKey(orgUnit.OrgUnitID)
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
result[normalized] = orgUnit.IsManager
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(remote.PrimaryOrgUnitID) != "" {
|
||||
result[worksmobileOrgUnitLocalExternalKey(remote.PrimaryOrgUnitID)] = remote.PrimaryOrgUnitIsManager
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func worksmobileUserExplicitOrgUnitManagers(user domain.User) map[string]bool {
|
||||
managers := map[string]bool{}
|
||||
for _, appointment := range worksmobileAppointmentsFromMetadata(user.Metadata) {
|
||||
|
||||
@@ -160,9 +160,11 @@ func TestWorksmobileSyncServiceEnqueuesUserCredentialBatchID(t *testing.T) {
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "ADMIN", request.PasswordConfig.PasswordCreationType)
|
||||
require.Equal(t, "InputPass1!", request.PasswordConfig.Password)
|
||||
require.NotNil(t, request.PasswordConfig.ChangePasswordAtNextLogin)
|
||||
require.True(t, *request.PasswordConfig.ChangePasswordAtNextLogin)
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceDoesNotAutoGenerateInitialPassword(t *testing.T) {
|
||||
func TestWorksmobileSyncServiceAutoGeneratesAdminInitialPassword(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "root-tenant"
|
||||
tenantID := "saman-tenant"
|
||||
@@ -198,10 +200,14 @@ func TestWorksmobileSyncServiceDoesNotAutoGenerateInitialPassword(t *testing.T)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, item)
|
||||
require.NotContains(t, outboxRepo.created[0].Payload, "initialPassword")
|
||||
initialPassword := stringValue(outboxRepo.created[0].Payload["initialPassword"])
|
||||
require.NotEmpty(t, initialPassword)
|
||||
request, ok := outboxRepo.created[0].Payload["request"].(WorksmobileUserPayload)
|
||||
require.True(t, ok)
|
||||
require.Empty(t, request.PasswordConfig.Password)
|
||||
require.Equal(t, "ADMIN", request.PasswordConfig.PasswordCreationType)
|
||||
require.Equal(t, initialPassword, request.PasswordConfig.Password)
|
||||
require.NotNil(t, request.PasswordConfig.ChangePasswordAtNextLogin)
|
||||
require.True(t, *request.PasswordConfig.ChangePasswordAtNextLogin)
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceCreatesDistinctUserSyncHistoryJobs(t *testing.T) {
|
||||
@@ -286,6 +292,48 @@ func TestWorksmobileSyncServiceCreatesDistinctAutomaticUserSyncHistoryJobs(t *te
|
||||
require.NotEqual(t, outboxRepo.created[0].DedupeKey, outboxRepo.created[1].DedupeKey)
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceUserUpdateIsUpdateOnlyWithoutInitialPassword(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "root-tenant"
|
||||
tenantID := "saman-tenant"
|
||||
root := domain.Tenant{
|
||||
ID: rootID,
|
||||
Slug: HanmacFamilyTenantSlug,
|
||||
Name: "Hanmac Family",
|
||||
}
|
||||
tenant := domain.Tenant{
|
||||
ID: tenantID,
|
||||
Slug: "saman",
|
||||
Name: "Saman",
|
||||
Type: domain.TenantTypeCompany,
|
||||
ParentID: &rootID,
|
||||
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||
}
|
||||
target := domain.User{
|
||||
ID: "target-user",
|
||||
Email: "target@samaneng.com",
|
||||
Name: "Target",
|
||||
Status: domain.UserStatusActive,
|
||||
TenantID: &tenantID,
|
||||
}
|
||||
outboxRepo := &fakeWorksmobileOutboxRepo{}
|
||||
service := NewWorksmobileSyncService(
|
||||
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, tenantID: tenant}, list: []domain.Tenant{root, tenant}},
|
||||
&fakeWorksmobileUserRepo{byID: map[string]domain.User{target.ID: target}, byTenant: []domain.User{target}},
|
||||
outboxRepo,
|
||||
nil,
|
||||
)
|
||||
|
||||
require.NoError(t, service.EnqueueUserUpdateIfInScope(context.Background(), target))
|
||||
|
||||
require.Len(t, outboxRepo.created, 1)
|
||||
require.Equal(t, "update_only", outboxRepo.created[0].Payload["provisioningMode"])
|
||||
require.NotContains(t, outboxRepo.created[0].Payload, "initialPassword")
|
||||
request, ok := outboxRepo.created[0].Payload["request"].(WorksmobileUserPayload)
|
||||
require.True(t, ok)
|
||||
require.True(t, request.PasswordConfig.IsZero())
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceEnqueuesUserPasswordResetCredentialBatch(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "root-tenant"
|
||||
@@ -333,6 +381,49 @@ func TestWorksmobileSyncServiceEnqueuesUserPasswordResetCredentialBatch(t *testi
|
||||
require.NotEmpty(t, outboxRepo.created[0].Payload["initialPassword"])
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServicePasswordResetAllowsExcludedPrimaryTenant(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "root-tenant"
|
||||
excludedOrgID := "excluded-org"
|
||||
root := domain.Tenant{
|
||||
ID: rootID,
|
||||
Slug: HanmacFamilyTenantSlug,
|
||||
Name: "Hanmac Family",
|
||||
}
|
||||
excludedOrg := domain.Tenant{
|
||||
ID: excludedOrgID,
|
||||
Slug: "excluded-team",
|
||||
Name: "Excluded Team",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
ParentID: &rootID,
|
||||
Config: domain.JSONMap{"worksmobileExcluded": true},
|
||||
}
|
||||
target := domain.User{
|
||||
ID: "target-user",
|
||||
Email: "target@samaneng.com",
|
||||
Name: "Target",
|
||||
Status: domain.UserStatusActive,
|
||||
TenantID: &excludedOrgID,
|
||||
}
|
||||
outboxRepo := &fakeWorksmobileOutboxRepo{}
|
||||
service := NewWorksmobileSyncService(
|
||||
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, excludedOrgID: excludedOrg}, list: []domain.Tenant{root, excludedOrg}},
|
||||
&fakeWorksmobileUserRepo{byID: map[string]domain.User{target.ID: target}, byTenant: []domain.User{target}},
|
||||
outboxRepo,
|
||||
nil,
|
||||
)
|
||||
|
||||
item, err := service.EnqueueUserPasswordReset(context.Background(), rootID, target.ID, "reset-batch-1")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, item)
|
||||
require.Len(t, outboxRepo.created, 1)
|
||||
require.Equal(t, domain.WorksmobileActionPasswordReset, outboxRepo.created[0].Action)
|
||||
require.Equal(t, "target@samaneng.com", outboxRepo.created[0].Payload["loginEmail"])
|
||||
require.Equal(t, "Target", outboxRepo.created[0].Payload["displayName"])
|
||||
require.Empty(t, outboxRepo.created[0].Payload["primaryLeafOrgName"])
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceFiltersInitialPasswordsByCredentialBatchID(t *testing.T) {
|
||||
rootID := "root-tenant"
|
||||
root := domain.Tenant{
|
||||
@@ -1650,6 +1741,69 @@ func TestWorksmobileSyncServiceKeepsCompanyUsersInComparisonScope(t *testing.T)
|
||||
require.ElementsMatch(t, []string{companyID, userGroupID}, userRepo.requestedTenantIDs)
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceGetComparisonUsesIdentityMirrorWhenReady(t *testing.T) {
|
||||
rootID := "root-tenant"
|
||||
companyID := "company-tenant"
|
||||
root := domain.Tenant{
|
||||
ID: rootID,
|
||||
Slug: HanmacFamilyTenantSlug,
|
||||
Name: "한맥가족",
|
||||
}
|
||||
company := domain.Tenant{
|
||||
ID: companyID,
|
||||
Name: "계열사",
|
||||
Type: domain.TenantTypeCompany,
|
||||
ParentID: &rootID,
|
||||
}
|
||||
userRepo := &fakeWorksmobileUserRepo{byTenant: []domain.User{{
|
||||
ID: "local-only-user",
|
||||
Email: "local-only@example.com",
|
||||
Name: "Local Only",
|
||||
TenantID: &companyID,
|
||||
Status: domain.UserStatusActive,
|
||||
}}}
|
||||
service := NewWorksmobileSyncService(
|
||||
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, companyID: company}, list: []domain.Tenant{root, company}},
|
||||
userRepo,
|
||||
&fakeWorksmobileOutboxRepo{},
|
||||
&fakeWorksmobileDirectoryClient{users: []WorksmobileRemoteUser{{
|
||||
ID: "works-user",
|
||||
ExternalID: "mirror-user",
|
||||
Email: "mirror@example.com",
|
||||
DisplayName: "Mirror User",
|
||||
PrimaryOrgUnitID: "externalKey:" + companyID,
|
||||
}}},
|
||||
)
|
||||
service.SetIdentityMirror(&fakeWorksmobileIdentityMirror{
|
||||
status: domain.IdentityCacheStatus{
|
||||
Status: "ready",
|
||||
RedisReady: true,
|
||||
MirrorVersion: "kratos-full-pagination-v1",
|
||||
ObservedCount: 1,
|
||||
},
|
||||
identities: []KratosIdentity{{
|
||||
ID: "mirror-user",
|
||||
State: "active",
|
||||
Traits: map[string]any{
|
||||
"email": "mirror@example.com",
|
||||
"name": "Mirror User",
|
||||
"tenant_id": companyID,
|
||||
"role": domain.RoleUser,
|
||||
"affiliationType": "internal",
|
||||
},
|
||||
}},
|
||||
})
|
||||
|
||||
comparison, err := service.GetComparison(context.Background(), rootID, true)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, userRepo.requestedTenantIDs)
|
||||
require.Len(t, comparison.Users, 1)
|
||||
require.Equal(t, "matched", comparison.Users[0].Status)
|
||||
require.Equal(t, "mirror-user", comparison.Users[0].BaronID)
|
||||
require.Equal(t, "mirror-user", comparison.Users[0].ExternalKey)
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceSkipsArchivedUsersInComparison(t *testing.T) {
|
||||
rootID := "root-tenant"
|
||||
companyID := "company-tenant"
|
||||
@@ -1898,6 +2052,7 @@ func TestWorksmobileSyncServiceRejectsExcludedOrgUnitSync(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceSkipsExcludedTenantAndUserEventSync(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "root-tenant"
|
||||
excludedCompanyID := "excluded-company"
|
||||
excludedOrgID := "excluded-org"
|
||||
@@ -1944,12 +2099,26 @@ func TestWorksmobileSyncServiceSkipsExcludedTenantAndUserEventSync(t *testing.T)
|
||||
require.NoError(t, service.EnqueueUserUpsertIfInScope(context.Background(), user))
|
||||
item, err := service.EnqueueUserSync(context.Background(), rootID, user.ID, "", "")
|
||||
|
||||
require.Nil(t, item)
|
||||
require.ErrorContains(t, err, "excluded from Worksmobile sync")
|
||||
require.Empty(t, outboxRepo.created)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, item)
|
||||
require.Len(t, outboxRepo.created, 1)
|
||||
require.Equal(t, domain.WorksmobileResourceUser, outboxRepo.created[0].ResourceType)
|
||||
require.Equal(t, user.ID, outboxRepo.created[0].ResourceID)
|
||||
require.Equal(t, domain.WorksmobileActionUpsert, outboxRepo.created[0].Action)
|
||||
require.Empty(t, outboxRepo.created[0].Status)
|
||||
require.Empty(t, outboxRepo.created[0].LastError)
|
||||
require.Equal(t, rootID, outboxRepo.created[0].Payload["tenantRootId"])
|
||||
require.Equal(t, user.Email, outboxRepo.created[0].Payload["loginEmail"])
|
||||
require.Equal(t, user.Name, outboxRepo.created[0].Payload["displayName"])
|
||||
require.Empty(t, outboxRepo.created[0].Payload["primaryLeafOrgName"])
|
||||
request, ok := outboxRepo.created[0].Payload["request"].(WorksmobileUserPayload)
|
||||
require.True(t, ok)
|
||||
require.Empty(t, request.Organizations)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersIgnoresManagerChange(t *testing.T) {
|
||||
func TestCompareWorksmobileUsersMarksManagerChangeNeedsUpdate(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "tenant-saman"
|
||||
tenantID := "tenant-leaf"
|
||||
user := domain.User{
|
||||
ID: "user-manager",
|
||||
@@ -1977,20 +2146,37 @@ func TestCompareWorksmobileUsersIgnoresManagerChange(t *testing.T) {
|
||||
DisplayName: user.Name,
|
||||
PrimaryOrgUnitID: "externalKey:" + tenantID,
|
||||
PrimaryOrgUnitIsManager: &remoteManager,
|
||||
Organizations: []WorksmobileUserOrganization{
|
||||
{
|
||||
DomainID: 1001,
|
||||
Email: user.Email,
|
||||
Primary: true,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{
|
||||
{
|
||||
OrgUnitID: "externalKey:" + tenantID,
|
||||
Primary: true,
|
||||
IsManager: &remoteManager,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
tenantID: {ID: tenantID, Name: "Leaf", Type: domain.TenantTypeOrganization},
|
||||
rootID: {ID: rootID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
|
||||
tenantID: {ID: tenantID, Name: "Leaf", Type: domain.TenantTypeOrganization, ParentID: &rootID},
|
||||
},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "matched", items[0].Status)
|
||||
require.Equal(t, "needs_update", items[0].Status)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersIgnoresSecondaryManagerChange(t *testing.T) {
|
||||
primaryTenantID := "tenant-company"
|
||||
secondaryTenantID := "tenant-gpdtdc-leaf"
|
||||
func TestCompareWorksmobileUsersMarksSecondaryManagerChangeNeedsUpdate(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "tenant-saman"
|
||||
primaryTenantID := "tenant-primary"
|
||||
secondaryTenantID := "tenant-secondary"
|
||||
user := domain.User{
|
||||
ID: "user-secondary-manager",
|
||||
Email: "secondary-manager@samaneng.com",
|
||||
@@ -2026,19 +2212,39 @@ func TestCompareWorksmobileUsersIgnoresSecondaryManagerChange(t *testing.T) {
|
||||
"externalKey:" + primaryTenantID: &remotePrimaryManager,
|
||||
"externalKey:" + secondaryTenantID: &remoteSecondaryManager,
|
||||
},
|
||||
Organizations: []WorksmobileUserOrganization{
|
||||
{
|
||||
DomainID: 1001,
|
||||
Email: user.Email,
|
||||
Primary: true,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{
|
||||
{
|
||||
OrgUnitID: "externalKey:" + primaryTenantID,
|
||||
Primary: true,
|
||||
IsManager: &remotePrimaryManager,
|
||||
},
|
||||
{
|
||||
OrgUnitID: "externalKey:" + secondaryTenantID,
|
||||
Primary: false,
|
||||
IsManager: &remoteSecondaryManager,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
primaryTenantID: {ID: primaryTenantID, Name: "Company", Type: domain.TenantTypeCompany},
|
||||
secondaryTenantID: {ID: secondaryTenantID, Name: "GPDTDC Leaf", Type: domain.TenantTypeOrganization},
|
||||
rootID: {ID: rootID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
|
||||
primaryTenantID: {ID: primaryTenantID, Name: "Primary", Type: domain.TenantTypeOrganization, ParentID: &rootID},
|
||||
secondaryTenantID: {ID: secondaryTenantID, Name: "Secondary", Type: domain.TenantTypeOrganization, ParentID: &rootID},
|
||||
},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "matched", items[0].Status)
|
||||
require.Equal(t, "needs_update", items[0].Status)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersIgnoresMissingSecondaryOrganization(t *testing.T) {
|
||||
func TestCompareWorksmobileUsersMarksMissingSecondaryOrganizationNeedsUpdate(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
||||
rootID := "tenant-root"
|
||||
@@ -2097,10 +2303,336 @@ func TestCompareWorksmobileUsersIgnoresMissingSecondaryOrganization(t *testing.T
|
||||
},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "needs_update", items[0].Status)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersIgnoresOrganizationEmailWhenMembershipMatches(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "tenant-root"
|
||||
companyID := "tenant-saman"
|
||||
tenantID := "tenant-leaf"
|
||||
user := domain.User{
|
||||
ID: "user-org-email",
|
||||
Email: "org-email@samaneng.com",
|
||||
Name: "Org Email User",
|
||||
TenantID: &tenantID,
|
||||
Status: domain.UserStatusActive,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{"tenantId": tenantID},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
items := compareWorksmobileUsers(
|
||||
[]domain.User{user},
|
||||
[]WorksmobileRemoteUser{{
|
||||
ID: "works-user-org-email",
|
||||
ExternalID: user.ID,
|
||||
Email: user.Email,
|
||||
DisplayName: user.Name,
|
||||
Organizations: []WorksmobileUserOrganization{
|
||||
{
|
||||
DomainID: 1001,
|
||||
Primary: true,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{
|
||||
{
|
||||
OrgUnitID: "externalKey:" + tenantID,
|
||||
Primary: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
|
||||
companyID: {ID: companyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
|
||||
tenantID: {ID: tenantID, Slug: "leaf", Name: "Leaf", Type: domain.TenantTypeOrganization, ParentID: &companyID},
|
||||
},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "matched", items[0].Status)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersUsesRemoteUserDomainWhenOrganizationDomainIsMissing(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "tenant-root"
|
||||
companyID := "tenant-saman"
|
||||
tenantID := "tenant-leaf"
|
||||
user := domain.User{
|
||||
ID: "user-org-domain",
|
||||
Email: "org-domain@samaneng.com",
|
||||
Name: "Org Domain User",
|
||||
TenantID: &tenantID,
|
||||
Status: domain.UserStatusActive,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{"tenantId": tenantID},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
items := compareWorksmobileUsers(
|
||||
[]domain.User{user},
|
||||
[]WorksmobileRemoteUser{{
|
||||
ID: "works-user-org-domain",
|
||||
ExternalID: user.ID,
|
||||
Email: user.Email,
|
||||
DisplayName: user.Name,
|
||||
DomainID: 1001,
|
||||
Organizations: []WorksmobileUserOrganization{
|
||||
{
|
||||
Primary: true,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{
|
||||
{
|
||||
OrgUnitID: "externalKey:" + tenantID,
|
||||
Primary: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
|
||||
companyID: {ID: companyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
|
||||
tenantID: {ID: tenantID, Slug: "leaf", Name: "Leaf", Type: domain.TenantTypeOrganization, ParentID: &companyID},
|
||||
},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "matched", items[0].Status)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersUsesRemotePrimaryOrgUnitWhenOrganizationPrimaryFlagsAreMissing(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "tenant-root"
|
||||
companyID := "tenant-saman"
|
||||
tenantID := "tenant-leaf"
|
||||
user := domain.User{
|
||||
ID: "user-org-primary",
|
||||
Email: "org-primary@samaneng.com",
|
||||
Name: "Org Primary User",
|
||||
TenantID: &tenantID,
|
||||
Status: domain.UserStatusActive,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{"tenantId": tenantID},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
items := compareWorksmobileUsersWithRemoteGroups(
|
||||
[]domain.User{user},
|
||||
[]WorksmobileRemoteUser{{
|
||||
ID: "works-user-org-primary",
|
||||
ExternalID: user.ID,
|
||||
Email: user.Email,
|
||||
DisplayName: user.Name,
|
||||
DomainID: 1001,
|
||||
PrimaryOrgUnitID: "works-leaf",
|
||||
Organizations: []WorksmobileUserOrganization{
|
||||
{
|
||||
DomainID: 1001,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{
|
||||
{
|
||||
OrgUnitID: "works-leaf",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
|
||||
companyID: {ID: companyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
|
||||
tenantID: {ID: tenantID, Slug: "leaf", Name: "Leaf", Type: domain.TenantTypeOrganization, ParentID: &companyID},
|
||||
},
|
||||
[]WorksmobileRemoteGroup{{
|
||||
ID: "works-leaf",
|
||||
ExternalID: tenantID,
|
||||
}},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "matched", items[0].Status)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersMatchesRemoteOrganizationExternalKey(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "tenant-root"
|
||||
companyID := "tenant-saman"
|
||||
tenantID := "tenant-leaf"
|
||||
user := domain.User{
|
||||
ID: "user-org-external-key",
|
||||
Email: "org-external-key@samaneng.com",
|
||||
Name: "Org External Key User",
|
||||
TenantID: &tenantID,
|
||||
Status: domain.UserStatusActive,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{"tenantId": tenantID},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
items := compareWorksmobileUsersWithRemoteGroups(
|
||||
[]domain.User{user},
|
||||
[]WorksmobileRemoteUser{{
|
||||
ID: "works-user-org-external-key",
|
||||
ExternalID: user.ID,
|
||||
Email: user.Email,
|
||||
DisplayName: user.Name,
|
||||
DomainID: 1001,
|
||||
PrimaryOrgUnitID: "works-leaf",
|
||||
Organizations: []WorksmobileUserOrganization{
|
||||
{
|
||||
DomainID: 1001,
|
||||
Primary: true,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{
|
||||
{
|
||||
OrgUnitID: "externalKey:" + tenantID,
|
||||
Primary: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
|
||||
companyID: {ID: companyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
|
||||
tenantID: {ID: tenantID, Slug: "leaf", Name: "Leaf", Type: domain.TenantTypeOrganization, ParentID: &companyID},
|
||||
},
|
||||
[]WorksmobileRemoteGroup{{
|
||||
ID: "works-leaf",
|
||||
ExternalID: tenantID,
|
||||
}},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "matched", items[0].Status)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersMarksMissingPrimaryOrganizationNeedsUpdate(t *testing.T) {
|
||||
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
||||
rootID := "tenant-root"
|
||||
gpdtdcID := "tenant-gpdtdc"
|
||||
peopleGrowthID := "tenant-people-growth"
|
||||
user := domain.User{
|
||||
ID: "user-people-growth",
|
||||
Email: "people-growth@baroncs.co.kr",
|
||||
Name: "People Growth User",
|
||||
TenantID: &peopleGrowthID,
|
||||
Status: domain.UserStatusActive,
|
||||
}
|
||||
|
||||
items := compareWorksmobileUsers(
|
||||
[]domain.User{user},
|
||||
[]WorksmobileRemoteUser{{
|
||||
ID: "works-user-people-growth",
|
||||
ExternalID: user.ID,
|
||||
Email: user.Email,
|
||||
DisplayName: user.Name,
|
||||
DomainID: 1003,
|
||||
PrimaryOrgUnitID: "externalKey:another-team",
|
||||
Organizations: []WorksmobileUserOrganization{
|
||||
{
|
||||
DomainID: 1003,
|
||||
Email: user.Email,
|
||||
Primary: true,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{
|
||||
{
|
||||
OrgUnitID: "externalKey:another-team",
|
||||
Primary: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
|
||||
gpdtdcID: {ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID},
|
||||
peopleGrowthID: {ID: peopleGrowthID, Slug: "people-growth", Name: "인재성장", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID},
|
||||
},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "needs_update", items[0].Status)
|
||||
require.Equal(t, peopleGrowthID, items[0].BaronPrimaryOrgID)
|
||||
require.Equal(t, "externalKey:another-team", items[0].WorksmobilePrimaryOrgID)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersMatchesPrimaryOrganizationByWorksmobileResourceID(t *testing.T) {
|
||||
peopleGrowthID := "tenant-people-growth"
|
||||
user := domain.User{
|
||||
ID: "user-people-growth",
|
||||
Email: "people-growth@baroncs.co.kr",
|
||||
Name: "People Growth User",
|
||||
TenantID: &peopleGrowthID,
|
||||
Status: domain.UserStatusActive,
|
||||
}
|
||||
|
||||
items := compareWorksmobileUsersWithRemoteGroups(
|
||||
[]domain.User{user},
|
||||
[]WorksmobileRemoteUser{{
|
||||
ID: "works-user-people-growth",
|
||||
ExternalID: user.ID,
|
||||
Email: user.Email,
|
||||
DisplayName: user.Name,
|
||||
PrimaryOrgUnitID: "works-current-people-growth",
|
||||
}},
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
peopleGrowthID: {ID: peopleGrowthID, Slug: "people-growth", Name: "인재성장", Type: domain.TenantTypeOrganization},
|
||||
},
|
||||
[]WorksmobileRemoteGroup{{
|
||||
ID: "works-current-people-growth",
|
||||
ExternalID: peopleGrowthID,
|
||||
}},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "matched", items[0].Status)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersMarksStalePrimaryOrganizationResourceIDNeedsUpdate(t *testing.T) {
|
||||
peopleGrowthID := "tenant-people-growth"
|
||||
user := domain.User{
|
||||
ID: "user-people-growth",
|
||||
Email: "people-growth@baroncs.co.kr",
|
||||
Name: "People Growth User",
|
||||
TenantID: &peopleGrowthID,
|
||||
Status: domain.UserStatusActive,
|
||||
}
|
||||
|
||||
items := compareWorksmobileUsersWithRemoteGroups(
|
||||
[]domain.User{user},
|
||||
[]WorksmobileRemoteUser{{
|
||||
ID: "works-user-people-growth",
|
||||
ExternalID: user.ID,
|
||||
Email: user.Email,
|
||||
DisplayName: user.Name,
|
||||
PrimaryOrgUnitID: "works-deleted-people-growth",
|
||||
}},
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
peopleGrowthID: {ID: peopleGrowthID, Slug: "people-growth", Name: "인재성장", Type: domain.TenantTypeOrganization},
|
||||
},
|
||||
[]WorksmobileRemoteGroup{{
|
||||
ID: "works-current-people-growth",
|
||||
ExternalID: peopleGrowthID,
|
||||
}},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "needs_update", items[0].Status)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersMarksPhoneAndEmployeeNumberChangesNeedsUpdate(t *testing.T) {
|
||||
tenantID := "tenant-saman"
|
||||
user := domain.User{
|
||||
@@ -2167,6 +2699,63 @@ func TestCompareWorksmobileUsersMarksMalformedRemoteKoreanPhoneNeedsUpdate(t *te
|
||||
require.Equal(t, "needs_update", items[0].Status)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersIgnoresRemotePhoneWhenBaronPhoneIsEmpty(t *testing.T) {
|
||||
tenantID := "tenant-halla"
|
||||
user := domain.User{
|
||||
ID: "edb8e4f6-3dfd-44d4-a8aa-87332f8b2b38",
|
||||
Email: "cyhan4@hallasanup.com",
|
||||
Name: "네이버웍스관리자",
|
||||
TenantID: &tenantID,
|
||||
Status: domain.UserStatusActive,
|
||||
}
|
||||
items := compareWorksmobileUsers(
|
||||
[]domain.User{user},
|
||||
[]WorksmobileRemoteUser{{
|
||||
ID: "fe9449d1-1671-44e4-1848-033779dddbaf",
|
||||
ExternalID: user.ID,
|
||||
Email: user.Email,
|
||||
DisplayName: user.Name,
|
||||
CellPhone: "+82 01041585840",
|
||||
}},
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
tenantID: {ID: tenantID, Name: "한라산업개발", Type: domain.TenantTypeCompany},
|
||||
},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "matched", items[0].Status)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersTreatsSpacedKoreanCountryCodePhoneAsMatched(t *testing.T) {
|
||||
tenantID := "tenant-saman"
|
||||
user := domain.User{
|
||||
ID: "user-phone-spaced",
|
||||
Email: "phone-spaced@samaneng.com",
|
||||
Name: "Phone Spaced User",
|
||||
Phone: "+821041585840",
|
||||
TenantID: &tenantID,
|
||||
Status: domain.UserStatusActive,
|
||||
}
|
||||
items := compareWorksmobileUsers(
|
||||
[]domain.User{user},
|
||||
[]WorksmobileRemoteUser{{
|
||||
ID: "works-user-phone-spaced",
|
||||
ExternalID: user.ID,
|
||||
Email: user.Email,
|
||||
DisplayName: user.Name,
|
||||
CellPhone: "+82 1041585840",
|
||||
}},
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
tenantID: {ID: tenantID, Name: "삼안", Type: domain.TenantTypeCompany},
|
||||
},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "matched", items[0].Status)
|
||||
}
|
||||
|
||||
type fakeWorksmobileTenantService struct {
|
||||
tenants map[string]domain.Tenant
|
||||
list []domain.Tenant
|
||||
@@ -2229,6 +2818,19 @@ type fakeWorksmobileUserRepo struct {
|
||||
requestedTenantIDs []string
|
||||
}
|
||||
|
||||
type fakeWorksmobileIdentityMirror struct {
|
||||
status domain.IdentityCacheStatus
|
||||
identities []KratosIdentity
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileIdentityMirror) GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error) {
|
||||
return f.status, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileIdentityMirror) ListIdentityMirrors(ctx context.Context) ([]KratosIdentity, error) {
|
||||
return f.identities, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileUserRepo) Create(ctx context.Context, user *domain.User) error { return nil }
|
||||
func (f *fakeWorksmobileUserRepo) Update(ctx context.Context, user *domain.User) error { return nil }
|
||||
func (f *fakeWorksmobileUserRepo) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { dateTimeInputToUnixSeconds } from "../src/features/clients/rpClaimDateTime";
|
||||
import {
|
||||
type Consent,
|
||||
installDevApiMock,
|
||||
@@ -7,7 +8,6 @@ import {
|
||||
} from "./helpers/devfront-fixtures";
|
||||
import { captureEvidence } from "./helpers/evidence";
|
||||
import { installDevFrontStaticRoutes } from "./helpers/static-devfront";
|
||||
import { dateTimeInputToUnixSeconds } from "../src/features/clients/rpClaimDateTime";
|
||||
|
||||
test.describe("DevFront consents", () => {
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
|
||||
@@ -12,7 +12,6 @@ services:
|
||||
- IDP_PROVIDER=ory
|
||||
- OATHKEEPER_API_URL=http://oathkeeper:4456
|
||||
- PROFILE_CACHE_TTL="${PROFILE_CACHE_TTL:-30m}"
|
||||
- ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=${ORGFRONT_ORGCHART_CACHE_TTL_SECONDS:-3600}
|
||||
- SEED_TENANT_CSV_PATH=/app/seed-tenant.csv
|
||||
ports:
|
||||
- "${BACKEND_PORT:-3000}:3000"
|
||||
|
||||
@@ -381,7 +381,6 @@ services:
|
||||
- NAVER_SENDER_PHONE_NUMBER=${NAVER_SENDER_PHONE_NUMBER}
|
||||
- USERFRONT_URL=${USERFRONT_URL}
|
||||
- REDIS_ADDR=${REDIS_ADDR}
|
||||
- ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=${ORGFRONT_ORGCHART_CACHE_TTL_SECONDS:-3600}
|
||||
- IDP_PROVIDER=${IDP_PROVIDER:-ory}
|
||||
- KRATOS_ADMIN_URL=${KRATOS_ADMIN_URL:-http://kratos:4434}
|
||||
- HYDRA_ADMIN_URL=${HYDRA_ADMIN_URL:-http://hydra:4445}
|
||||
|
||||
49
docs/adminfront-flicker-trace-analysis-2026-06-15.md
Normal file
49
docs/adminfront-flicker-trace-analysis-2026-06-15.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# adminfront 깜빡임 trace 분석
|
||||
|
||||
작성일: 2026-06-15
|
||||
|
||||
## 입력 자료
|
||||
|
||||
- `adminfront/Trace-20260615T113806.json.gz`
|
||||
|
||||
## 관찰 결과
|
||||
|
||||
- trace 구간은 약 4.18초이다.
|
||||
- DevTools screenshot은 250장 포함되어 있다.
|
||||
- 화면 전체 shell이 사라지는 현상은 아니고, 본문 영역이 반복적으로 repaint되는 형태이다.
|
||||
- `Animation` 이벤트에서 `enter` 애니메이션이 147회 반복 시작되었다.
|
||||
- 반복 대상은 다음 nodeName으로 확인되었다.
|
||||
- `DIV class='space-y-4 animate-in fade-in duration-500'`
|
||||
- 같은 nodeName의 `Paint` 이벤트도 147회 발생했고, span은 약 4.1초였다.
|
||||
|
||||
## 원인
|
||||
|
||||
장기 유지되는 admin page/tab container에 `animate-in fade-in duration-500` 진입 애니메이션이 붙어 있었다.
|
||||
|
||||
이 클래스가 query/refetch, tab content 갱신, 렌더 상태 변화와 결합되면서 본문 영역이 반복 진입 애니메이션을 수행했고, 사용자는 이를 간헐적인 깜빡임으로 보게 된다.
|
||||
|
||||
## 수정
|
||||
|
||||
다음 장기 컨테이너에서 페이지 레벨 진입 애니메이션을 제거했다.
|
||||
|
||||
- `TenantWorksmobilePage` tab panels
|
||||
- `GlobalOverviewPage` root container
|
||||
- `DataIntegrityPage` tab panels
|
||||
- `TenantDetailPage` nested outlet wrapper
|
||||
|
||||
Dialog, dropdown, toast처럼 짧게 열리고 닫히는 transient UI의 state-based animation은 유지한다.
|
||||
|
||||
## 회귀 방지
|
||||
|
||||
- Worksmobile tab panel에 trace 원인 클래스가 다시 들어오지 않도록 테스트를 추가했다.
|
||||
- adminfront 전체 `.tsx`에서 `animate-in fade-in duration-500` 페이지 레벨 패턴이 재도입되지 않도록 정책 테스트를 추가했다.
|
||||
|
||||
## 검증
|
||||
|
||||
```bash
|
||||
pnpm --dir adminfront exec vitest run src/features/coverage/adminPageAnimationPolicy.test.ts --bail 1
|
||||
pnpm --dir adminfront exec vitest run src/features/tenants/routes/TenantWorksmobilePage.test.ts --bail 1
|
||||
pnpm --dir adminfront exec vitest run src/features/overview/GlobalOverviewPage.test.tsx --bail 1
|
||||
pnpm --dir adminfront exec vitest run src/features/integrity/DataIntegrityPage.test.tsx --bail 1
|
||||
pnpm --dir adminfront exec vitest run src/features/tenants/routes/TenantDetailPage.worksmobile.test.tsx --bail 1
|
||||
```
|
||||
@@ -88,7 +88,7 @@
|
||||
테스트:
|
||||
|
||||
- `GOCACHE=/tmp/baron-sso-go-cache go test ./internal/service -run 'TestWorksmobileSyncServiceSkipsSoftDeletedUsersInComparison' -count=1`
|
||||
- `GOCACHE=/tmp/baron-sso-go-cache go test ./internal/handler ./internal/repository -run 'Test.*User|Test.*Projection|Test.*SoftDeleted|Test.*ListUsers' -count=1`
|
||||
- `GOCACHE=/tmp/baron-sso-go-cache go test ./internal/handler ./internal/repository -run 'Test.*User|Test.*SoftDeleted|Test.*ListUsers' -count=1`
|
||||
- `BASE_URL=http://127.0.0.1:5173 npm --prefix adminfront test -- worksmobile.spec.ts --project=chromium`
|
||||
|
||||
결과:
|
||||
|
||||
@@ -170,12 +170,35 @@ Baron Kratos identity를 Worksmobile user로 보냅니다.
|
||||
- `passwordConfig.password`: 구성원 생성 시 숫자, 영문, 기호를 모두 포함한 16자리 난수 초기 비밀번호를 생성합니다.
|
||||
- `task`: Baron `jobTitle`을 우선 사용
|
||||
- `organizations`
|
||||
- 원직: 대표 tenant 또는 `additionalAppointments` 중 primary로 선택된 tenant
|
||||
- 겸직: `metadata.additionalAppointments` 또는 Keto `joinedTenants`
|
||||
- 원직: Worksmobile 연동 제외 테넌트를 제외한 뒤 `additionalAppointments`에 가장 먼저 등록된 tenant
|
||||
- 겸직: `metadata.additionalAppointments` 또는 Keto `joinedTenants` 중 Worksmobile 연동 제외가 아닌 tenant
|
||||
- `orgUnits[].orgUnitId`: `externalKey:{tenant.ID}`
|
||||
- `levelId`, `positionId`, `userTypeId`: 이번 scope에서는 External Key mapping을 사용하지 않고 사용자 정보 업데이트 필드로 최대한 커버
|
||||
- `isManager`: `additionalAppointments[].isOwner == true` 또는 Keto owners/admins relation을 기준으로 변환
|
||||
|
||||
### 구성원 소속 변경 정책
|
||||
|
||||
Worksmobile 연동 화면의 변경 범위는 다음과 같이 분리합니다.
|
||||
|
||||
- 조직/그룹 탭은 Worksmobile `orgunits`/`groups` 리소스 자체의 생성, 수정, 삭제, 이동을 관리합니다.
|
||||
- 구성원 탭은 Worksmobile `users` 리소스의 조직 소속(`organizations[].orgUnits[]`) 변경을 관리합니다.
|
||||
- 구성원 소속 변경 중 Worksmobile에 대상 org unit이 없거나 부모 구조가 최신이 아니면, 먼저 조직/그룹 탭의 조직 sync로 구조 변경을 반영한 뒤 구성원 sync를 수행합니다.
|
||||
- `worksmobileExcluded=true`인 테넌트와 그 하위 조직은 조직 sync 및 구성원 소속 sync의 대상에서 제외합니다.
|
||||
|
||||
개인 사용자 상세 변경은 대표 조직 1개만 수정하는 동작으로 제한하지 않습니다. Baron의 `metadata.additionalAppointments`에 들어 있는 모든 소속 조직을 기준으로 Worksmobile payload의 `organizations[].orgUnits[]`를 구성합니다. 다만 Worksmobile 연동 제외 테넌트, Worksmobile domain ID를 해석할 수 없는 테넌트, 조직 연동 설정이 켜져 있지 않은 겸직 도메인은 payload에서 제외하고 비교 결과에는 skipped reason 또는 warning을 남깁니다.
|
||||
|
||||
WORKS Developers 문서 확인 결과, 구성원 추가/수정 payload는 `organizations[]`와 하위 `orgUnits[]` 배열을 제공하고 `orgUnits`는 최대 30개까지 허용합니다. 또한 대표 도메인과 대표 조직은 각각 하나가 필요합니다. Directory API의 조직 연동 설명에는 구성원 회사 겸직을 설정하려면 겸직 도메인도 조직 연동 사용 설정을 켜야 한다는 제약이 있습니다.
|
||||
|
||||
따라서 구현 원칙은 다음과 같습니다.
|
||||
|
||||
- 동일 Worksmobile domain 안에서는 Worksmobile 연동 제외가 아닌 조직 소속을 모두 동기화합니다.
|
||||
- 여러 Worksmobile domain을 넘는 겸직은 해당 domain의 조직 연동 설정이 켜져 있고 Baron에서 domain ID를 해석할 수 있을 때만 동기화합니다.
|
||||
- Worksmobile primary는 Baron 대표 테넌트 플래그나 `additionalAppointments[].isPrimary=true`를 기준으로 삼지 않습니다. Worksmobile 연동 제외 테넌트를 제거한 뒤 `additionalAppointments`에 가장 먼저 등록된 소속을 최초 반영 소속이자 primary로 둡니다.
|
||||
- `representative`, `isPrimary`, `primary` 같은 Baron 대표 소속 플래그는 Baron 내부 대표 소속 정책에만 사용하고 Worksmobile 동기화 판단 필드로 사용하지 않습니다.
|
||||
- 조직장 여부(`isManager`)는 Worksmobile 동기화 대상입니다. 비교 로직은 remote와 Baron의 조직장 여부 차이를 `needs_update`로 판단해야 합니다.
|
||||
- 비교 로직도 대표 조직 1개가 아니라 `organizations[].orgUnits[]` 전체 set을 기준으로 `org_unit_added`, `org_unit_removed`, `org_unit_moved`, `org_unit_primary_changed` 같은 diff reason을 산출합니다.
|
||||
- Worksmobile 공식 문서 근거: https://developers.worksmobile.com/kr/docs/user-create, https://developers.worksmobile.com/kr/docs/directory
|
||||
|
||||
초기 비밀번호는 Worksmobile user upsert outbox payload에 `loginEmail`, `initialPassword` 형태로 함께 보관하고, adminfront의 한맥가족 Worksmobile 관리 화면에서 `email,initialPassword,status,lastError` CSV로 다운로드할 수 있게 합니다. 생성 성공/실패 판정은 outbox 작업 상태(`processed`, `failed`)와 함께 확인할 수 있으며, 운영상 평문 초기 비밀번호가 포함되므로 다운로드 권한은 `hanmac-family` tenant manage 권한으로 제한하고 보존 기간 정책을 별도 확정해야 합니다.
|
||||
|
||||
현재 backend `CreateUser`와 `UpdateUser`는 adminfront가 보내는 top-level `additionalAppointments` 및 `metadata.additionalAppointments`를 수용합니다. 한맥가족 단건 생성에서 대표 `tenantSlug` 없이 appointment만 오는 경우에는 first/primary appointment tenant를 대표 tenant로 해석해 Ory/Keto 관계, 허용된 Backend read model, Worksmobile enqueue가 누락되지 않게 합니다.
|
||||
|
||||
35
docs/worksmobile-phone-outbound-policy-2026-06-15.md
Normal file
35
docs/worksmobile-phone-outbound-policy-2026-06-15.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# WORKS 전화번호 송신 포맷 정책
|
||||
|
||||
작성일: 2026-06-15
|
||||
|
||||
## 목적
|
||||
|
||||
Baron 내부 전화번호 표준과 WORKS API 송신 포맷을 분리한다.
|
||||
|
||||
## 정책
|
||||
|
||||
- Baron 내부 저장 및 비교 표준은 공백 없는 E.164 형태를 유지한다.
|
||||
- 예: `+821041585840`
|
||||
- WORKS 계정 생성 및 업데이트 요청으로 전화번호를 보낼 때는 국가번호와 국내 번호 사이에 공백을 둔다.
|
||||
- 예: `+82 01041585840`
|
||||
- WORKS 송신용 국내 번호는 `0`으로 시작해야 한다.
|
||||
- 내부값 `+821041585840`은 WORKS 송신 시 `+82 01041585840`으로 변환한다.
|
||||
- WORKS 응답 비교는 기존처럼 공백 포함/미포함 형식을 같은 전화번호로 정규화해서 비교한다.
|
||||
|
||||
## 적용 범위
|
||||
|
||||
- WORKS Directory 사용자 생성 요청
|
||||
- WORKS Directory 사용자 업데이트 요청
|
||||
- WORKS SCIM 사용자 생성 요청
|
||||
|
||||
## 비적용 범위
|
||||
|
||||
- Baron DB 저장값
|
||||
- Kratos traits 저장값
|
||||
- Adminfront 비교 화면의 내부 기준 표시값
|
||||
|
||||
## 구현 기준
|
||||
|
||||
- 내부 payload 생성 단계에서는 기존 E.164 정규화 값을 유지한다.
|
||||
- 실제 WORKS API 송신 직전 outbound formatter에서만 `+82 0...` 형식으로 변환한다.
|
||||
- 한국 번호가 아닌 값은 기존 정규화 결과를 유지한다.
|
||||
@@ -303,6 +303,7 @@ schema_incompatible = "Fields not in target schema may be lost:"
|
||||
schema_missing = "Missing required fields for target tenant:"
|
||||
status_placeholder = "Select status"
|
||||
permission_placeholder = "Select permission"
|
||||
update_partial_error = "Failed to update {{count}} users."
|
||||
update_success = "User info updated successfully."
|
||||
|
||||
[msg.admin.users.create]
|
||||
@@ -2839,20 +2840,8 @@ description = "Checks whether user_login_ids.user_id points to a missing or soft
|
||||
[msg.admin.integrity.check.orphan_user_tenant_memberships]
|
||||
description = "Checks whether users.tenant_id points to a missing or soft-deleted tenant."
|
||||
|
||||
[msg.admin.user_projection]
|
||||
action_error = "Projection operation failed."
|
||||
action_success = "Refreshed the projection for {{count}} users."
|
||||
forbidden_description = "This screen is only available to super_admin users."
|
||||
load_error = "Failed to load projection status."
|
||||
reset_confirm = "Rebuild user projection from the Kratos source of truth?"
|
||||
subtitle = "Review and sync the Kratos user read model."
|
||||
|
||||
[msg.admin.user_projection.forbidden]
|
||||
description = "This screen is only available to super_admin users."
|
||||
|
||||
[ui.admin.integrity]
|
||||
tab_checks = "Integrity Checks"
|
||||
tab_user_projection = "User Projection"
|
||||
fetch_error = "Unable to load the final integrity check result."
|
||||
kicker = "System"
|
||||
loading = "Loading data integrity report..."
|
||||
@@ -2934,33 +2923,6 @@ rotate_secret = "Rotate secret"
|
||||
rotate_secret_done = "Secret rotated"
|
||||
save_scopes = "Save scopes"
|
||||
|
||||
[ui.admin.user_projection]
|
||||
loading = "Loading user projection data..."
|
||||
subtitle = "Review and sync the Kratos user read model."
|
||||
title = "User Projection Management"
|
||||
|
||||
[ui.admin.user_projection.actions]
|
||||
reconcile = "Re-sync"
|
||||
reset = "Reset and rebuild"
|
||||
|
||||
[ui.admin.user_projection.card]
|
||||
description = "Current user read model state referenced by backend DB statistics."
|
||||
title = "Kratos users projection"
|
||||
|
||||
[ui.admin.user_projection.forbidden]
|
||||
title = "Access denied"
|
||||
|
||||
[ui.admin.user_projection.status]
|
||||
failed = "failed"
|
||||
not_ready = "not ready"
|
||||
ready = "ready"
|
||||
|
||||
[ui.admin.user_projection.summary]
|
||||
last_synced = "Last synced"
|
||||
projected_users = "Projected users"
|
||||
status = "Status"
|
||||
updated_at = "Updated at"
|
||||
|
||||
[ui.admin.auth_guard]
|
||||
subtitle = "Verify admin privileges and ReBAC relationships against the policy engine."
|
||||
title = "Auth Guard"
|
||||
|
||||
@@ -851,6 +851,7 @@ schema_incompatible = "대상 테넌트 스키마에 없는 필드는 유실될
|
||||
schema_missing = "대상 테넌트의 필수 필드가 누락되어 있습니다:"
|
||||
status_placeholder = "상태 선택"
|
||||
permission_placeholder = "권한 선택"
|
||||
update_partial_error = "{{count}}명의 사용자 정보 수정에 실패했습니다."
|
||||
update_success = "사용자 정보가 일괄 업데이트되었습니다."
|
||||
|
||||
[msg.admin.users.create]
|
||||
@@ -3284,23 +3285,11 @@ description = "사용자와 로그인 ID 참조의 고아 레코드를 확인합
|
||||
[msg.admin.integrity]
|
||||
subtitle = "정합성 상태를 확인하고 데이터 모델 전반의 검증 결과를 살펴봅니다."
|
||||
|
||||
[msg.admin.user_projection]
|
||||
action_error = "사용자 동기화 작업에 실패했습니다."
|
||||
action_success = "{{count}}명 기준으로 사용자 동기화를 갱신했습니다."
|
||||
forbidden_description = "이 화면은 super_admin 권한으로만 접근할 수 있습니다."
|
||||
load_error = "사용자 동기화 상태를 불러오지 못했습니다."
|
||||
reset_confirm = "사용자 동기화를 Kratos 기준으로 다시 구축하시겠습니까?"
|
||||
subtitle = "Kratos 사용자 read model을 확인하고 동기화 상태를 갱신합니다."
|
||||
|
||||
[msg.admin.user_projection.forbidden]
|
||||
description = "이 화면은 super_admin 권한으로만 접근할 수 있습니다."
|
||||
|
||||
[msg.admin.integrity]
|
||||
subtitle = "정합성 상태를 확인하고 데이터 모델 전반의 검증 결과를 살펴봅니다."
|
||||
|
||||
[ui.admin.integrity]
|
||||
tab_checks = "정합성 검사"
|
||||
tab_user_projection = "사용자 동기화"
|
||||
fetch_error = "정합성 최종 검증 결과를 불러오지 못했습니다."
|
||||
kicker = "시스템"
|
||||
loading = "불러오는 중"
|
||||
@@ -3382,32 +3371,6 @@ rotate_secret = "Secret 재발급"
|
||||
rotate_secret_done = "Secret 재발급 완료"
|
||||
save_scopes = "권한 저장"
|
||||
|
||||
[ui.admin.user_projection]
|
||||
loading = "불러오는 중"
|
||||
title = "사용자 동기화 관리"
|
||||
|
||||
[ui.admin.user_projection.actions]
|
||||
reconcile = "재동기화"
|
||||
reset = "초기화 후 재구축"
|
||||
|
||||
[ui.admin.user_projection.card]
|
||||
description = "Backend DB 통계가 참조하는 사용자 read model 상태입니다."
|
||||
title = "Kratos 사용자 동기화"
|
||||
|
||||
[ui.admin.user_projection.forbidden]
|
||||
title = "접근 권한이 없습니다"
|
||||
|
||||
[ui.admin.user_projection.status]
|
||||
failed = "실패"
|
||||
not_ready = "준비되지 않음"
|
||||
ready = "준비됨"
|
||||
|
||||
[ui.admin.user_projection.summary]
|
||||
last_synced = "마지막 동기화"
|
||||
projected_users = "동기화 사용자"
|
||||
status = "상태"
|
||||
updated_at = "상태 갱신"
|
||||
|
||||
[ui.admin.auth_guard]
|
||||
subtitle = "관리자 권한과 ReBAC 관계를 실제 정책 엔진 기준으로 확인합니다."
|
||||
title = "인증 가드"
|
||||
|
||||
@@ -687,6 +687,7 @@ schema_incompatible = ""
|
||||
schema_missing = ""
|
||||
status_placeholder = ""
|
||||
permission_placeholder = ""
|
||||
update_partial_error = ""
|
||||
update_success = ""
|
||||
|
||||
[msg.admin.users.create]
|
||||
@@ -3134,7 +3135,6 @@ description = ""
|
||||
|
||||
[ui.admin.integrity]
|
||||
tab_checks = ""
|
||||
tab_user_projection = ""
|
||||
subtitle = ""
|
||||
|
||||
[ui.admin.tenants.profile]
|
||||
@@ -3156,7 +3156,6 @@ kicker = ""
|
||||
loading = ""
|
||||
subtitle = ""
|
||||
tab_checks = ""
|
||||
tab_user_projection = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.tenants.profile]
|
||||
@@ -3168,17 +3167,6 @@ worksmobile_sync = ""
|
||||
[msg.admin.integrity.section.user_integrity]
|
||||
description = ""
|
||||
|
||||
[msg.admin.user_projection]
|
||||
action_error = ""
|
||||
action_success = ""
|
||||
forbidden_description = ""
|
||||
load_error = ""
|
||||
reset_confirm = ""
|
||||
subtitle = ""
|
||||
|
||||
[msg.admin.user_projection.forbidden]
|
||||
description = ""
|
||||
|
||||
[ui.admin.integrity]
|
||||
fetch_error = ""
|
||||
kicker = ""
|
||||
@@ -3260,32 +3248,6 @@ rotate_secret = ""
|
||||
rotate_secret_done = ""
|
||||
save_scopes = ""
|
||||
|
||||
[ui.admin.user_projection]
|
||||
loading = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.user_projection.actions]
|
||||
reconcile = ""
|
||||
reset = ""
|
||||
|
||||
[ui.admin.user_projection.card]
|
||||
description = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.user_projection.forbidden]
|
||||
title = ""
|
||||
|
||||
[ui.admin.user_projection.status]
|
||||
failed = ""
|
||||
not_ready = ""
|
||||
ready = ""
|
||||
|
||||
[ui.admin.user_projection.summary]
|
||||
last_synced = ""
|
||||
projected_users = ""
|
||||
status = ""
|
||||
updated_at = ""
|
||||
|
||||
[ui.admin.auth_guard]
|
||||
subtitle = ""
|
||||
title = ""
|
||||
|
||||
@@ -618,6 +618,120 @@ describe("org chart layout", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps appointment memberships by tenant UUID when the stored slug is stale", () => {
|
||||
const root = tenantNode("gpdtdc", "COMPANY", "GPDTDC", "gpdtdc");
|
||||
const division = {
|
||||
...tenantNode("division", "ORGANIZATION", "총괄기획실", "gpd"),
|
||||
parentId: "gpdtdc",
|
||||
};
|
||||
const migratedLeaf = {
|
||||
...tenantNode(
|
||||
"migrated-leaf",
|
||||
"ORGANIZATION",
|
||||
"통합시스템",
|
||||
"intigrated-system",
|
||||
),
|
||||
parentId: "division",
|
||||
};
|
||||
const rootNode = {
|
||||
...root,
|
||||
children: [{ ...division, children: [migratedLeaf] }],
|
||||
};
|
||||
|
||||
const usersMap = buildUsersMap(
|
||||
[
|
||||
{
|
||||
...member("migrated-user"),
|
||||
companyCode: undefined,
|
||||
tenantSlug: "gpdtdc",
|
||||
metadata: {
|
||||
additionalAppointments: [
|
||||
{
|
||||
tenantId: "migrated-leaf",
|
||||
tenantName: "기술기획",
|
||||
tenantSlug: "tech-planning",
|
||||
},
|
||||
],
|
||||
},
|
||||
joinedTenants: undefined,
|
||||
},
|
||||
],
|
||||
[rootNode],
|
||||
{ activeOnly: true },
|
||||
);
|
||||
|
||||
expect(usersMap.get("gpdtdc")).toBeUndefined();
|
||||
expect(usersMap.get("tech-planning")).toBeUndefined();
|
||||
expect(usersMap.get("intigrated-system")?.map((user) => user.id)).toEqual([
|
||||
"migrated-user",
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps primary and joined memberships by tenant UUID when stored slugs are stale", () => {
|
||||
const root = tenantNode("root-company", "COMPANY", "Root", "root-company");
|
||||
const primaryLeaf = {
|
||||
...tenantNode("primary-leaf", "ORGANIZATION", "Primary", "primary-new"),
|
||||
parentId: "root-company",
|
||||
};
|
||||
const joinedLeaf = {
|
||||
...tenantNode("joined-leaf", "ORGANIZATION", "Joined", "joined-new"),
|
||||
parentId: "root-company",
|
||||
};
|
||||
const rootNode = {
|
||||
...root,
|
||||
children: [primaryLeaf, joinedLeaf],
|
||||
};
|
||||
|
||||
const usersMap = buildUsersMap(
|
||||
[
|
||||
{
|
||||
...member("primary-user"),
|
||||
companyCode: undefined,
|
||||
tenantSlug: undefined,
|
||||
tenant: {
|
||||
id: "primary-leaf",
|
||||
type: "ORGANIZATION",
|
||||
name: "Primary",
|
||||
slug: "primary-old",
|
||||
description: "",
|
||||
status: "active",
|
||||
createdAt: "2026-05-11T00:00:00.000Z",
|
||||
updatedAt: "2026-05-11T00:00:00.000Z",
|
||||
},
|
||||
joinedTenants: undefined,
|
||||
},
|
||||
{
|
||||
...member("joined-user"),
|
||||
companyCode: undefined,
|
||||
tenantSlug: undefined,
|
||||
joinedTenants: [
|
||||
{
|
||||
id: "joined-leaf",
|
||||
type: "ORGANIZATION",
|
||||
name: "Joined",
|
||||
slug: "joined-old",
|
||||
description: "",
|
||||
status: "active",
|
||||
createdAt: "2026-05-11T00:00:00.000Z",
|
||||
updatedAt: "2026-05-11T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[rootNode],
|
||||
{ activeOnly: true },
|
||||
);
|
||||
|
||||
expect(usersMap.get("primary-new")?.map((user) => user.id)).toEqual([
|
||||
"primary-user",
|
||||
]);
|
||||
expect(usersMap.get("joined-new")?.map((user) => user.id)).toEqual([
|
||||
"joined-user",
|
||||
]);
|
||||
expect(usersMap.get("primary-old")).toBeUndefined();
|
||||
expect(usersMap.get("joined-old")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not fall back to a visible parent for hidden leaf memberships", () => {
|
||||
const gpdtdc = tenantNode("gpdtdc", "COMPANY", "GPDTDC", "gpdtdc");
|
||||
const internalLeaf = {
|
||||
|
||||
@@ -1270,14 +1270,27 @@ function getUserOrgAppointmentRefs(user: UserSummary): UserOrgAppointmentRef[] {
|
||||
}
|
||||
|
||||
function addTenantSlugCandidate(
|
||||
slugs: Set<string>,
|
||||
tenantIds: Set<string>,
|
||||
tenantIndexes: TenantIndexes,
|
||||
slug: string,
|
||||
) {
|
||||
const normalizedSlug = normalizeOrgSlug(slug);
|
||||
if (!normalizedSlug) return;
|
||||
if (!tenantIndexes.bySlug.has(normalizedSlug)) return;
|
||||
slugs.add(normalizedSlug);
|
||||
const tenant = tenantIndexes.bySlug.get(normalizedSlug);
|
||||
if (!tenant) return;
|
||||
tenantIds.add(tenant.id);
|
||||
}
|
||||
|
||||
function addTenantIdCandidate(
|
||||
tenantIds: Set<string>,
|
||||
tenantIndexes: TenantIndexes,
|
||||
id: unknown,
|
||||
) {
|
||||
if (typeof id !== "string") return;
|
||||
const normalizedId = id.trim();
|
||||
if (!normalizedId) return;
|
||||
if (!tenantIndexes.byId.has(normalizedId)) return;
|
||||
tenantIds.add(normalizedId);
|
||||
}
|
||||
|
||||
function isDescendantTenant(
|
||||
@@ -1298,19 +1311,19 @@ function isDescendantTenant(
|
||||
return false;
|
||||
}
|
||||
|
||||
function getLeafMembershipSlugs(
|
||||
slugs: Set<string>,
|
||||
function getLeafMembershipIds(
|
||||
tenantIds: Set<string>,
|
||||
tenantIndexes: TenantIndexes,
|
||||
) {
|
||||
const memberships = Array.from(slugs);
|
||||
const memberships = Array.from(tenantIds);
|
||||
|
||||
return memberships.filter((slug) => {
|
||||
const tenant = tenantIndexes.bySlug.get(slug);
|
||||
return memberships.filter((id) => {
|
||||
const tenant = tenantIndexes.byId.get(id);
|
||||
if (!tenant) return true;
|
||||
|
||||
return !memberships.some((otherSlug) => {
|
||||
if (otherSlug === slug) return false;
|
||||
const otherTenant = tenantIndexes.bySlug.get(otherSlug);
|
||||
return !memberships.some((otherId) => {
|
||||
if (otherId === id) return false;
|
||||
const otherTenant = tenantIndexes.byId.get(otherId);
|
||||
if (!otherTenant) return false;
|
||||
return isDescendantTenant(otherTenant, tenant, tenantIndexes.byId);
|
||||
});
|
||||
@@ -1332,7 +1345,7 @@ export function buildUsersMap(
|
||||
if (options.activeOnly && user.status !== "active") continue;
|
||||
if (!isVisibleOrgChartUser(user)) continue;
|
||||
|
||||
const slugs = new Set<string>();
|
||||
const tenantIds = new Set<string>();
|
||||
const primarySlug = normalizeOrgSlug(user.tenantSlug);
|
||||
const legacyCompanySlug = normalizeOrgSlug(user.companyCode);
|
||||
if (
|
||||
@@ -1344,7 +1357,7 @@ export function buildUsersMap(
|
||||
name: primarySlug,
|
||||
})
|
||||
) {
|
||||
addTenantSlugCandidate(slugs, membershipTenantIndexes, primarySlug);
|
||||
addTenantSlugCandidate(tenantIds, membershipTenantIndexes, primarySlug);
|
||||
}
|
||||
if (
|
||||
legacyCompanySlug &&
|
||||
@@ -1355,24 +1368,51 @@ export function buildUsersMap(
|
||||
name: legacyCompanySlug,
|
||||
})
|
||||
) {
|
||||
addTenantSlugCandidate(slugs, membershipTenantIndexes, legacyCompanySlug);
|
||||
addTenantSlugCandidate(
|
||||
tenantIds,
|
||||
membershipTenantIndexes,
|
||||
legacyCompanySlug,
|
||||
);
|
||||
}
|
||||
if (user.tenant?.slug && !isSystemGlobalTenant(user.tenant)) {
|
||||
addTenantSlugCandidate(slugs, membershipTenantIndexes, user.tenant.slug);
|
||||
addTenantIdCandidate(tenantIds, membershipTenantIndexes, user.tenant.id);
|
||||
addTenantSlugCandidate(
|
||||
tenantIds,
|
||||
membershipTenantIndexes,
|
||||
user.tenant.slug,
|
||||
);
|
||||
}
|
||||
for (const joinedTenant of user.joinedTenants || []) {
|
||||
if (joinedTenant.slug && !isSystemGlobalTenant(joinedTenant)) {
|
||||
addTenantIdCandidate(
|
||||
tenantIds,
|
||||
membershipTenantIndexes,
|
||||
joinedTenant.id,
|
||||
);
|
||||
addTenantSlugCandidate(
|
||||
slugs,
|
||||
tenantIds,
|
||||
membershipTenantIndexes,
|
||||
joinedTenant.slug,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const appointment of getUserOrgAppointmentRefs(user)) {
|
||||
const hasTenantIdCandidate =
|
||||
appointment.tenantId &&
|
||||
membershipTenantIndexes.byId.has(appointment.tenantId);
|
||||
|
||||
if (hasTenantIdCandidate) {
|
||||
addTenantIdCandidate(
|
||||
tenantIds,
|
||||
membershipTenantIndexes,
|
||||
appointment.tenantId,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (appointment.tenantSlug) {
|
||||
addTenantSlugCandidate(
|
||||
slugs,
|
||||
tenantIds,
|
||||
membershipTenantIndexes,
|
||||
appointment.tenantSlug,
|
||||
);
|
||||
@@ -1383,12 +1423,14 @@ export function buildUsersMap(
|
||||
? membershipTenantIndexes.byId.get(appointment.tenantId)
|
||||
: undefined;
|
||||
if (tenantById) {
|
||||
addTenantSlugCandidate(slugs, membershipTenantIndexes, tenantById.slug);
|
||||
addTenantIdCandidate(tenantIds, membershipTenantIndexes, tenantById.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const slug of getLeafMembershipSlugs(slugs, membershipTenantIndexes)) {
|
||||
if (!visibleTenantIndexes.bySlug.has(slug)) continue;
|
||||
for (const id of getLeafMembershipIds(tenantIds, membershipTenantIndexes)) {
|
||||
const visibleTenant = visibleTenantIndexes.byId.get(id);
|
||||
if (!visibleTenant) continue;
|
||||
const slug = visibleTenant.slug.toLowerCase();
|
||||
const list = map.get(slug) || [];
|
||||
if (!list.some((existing) => existing.id === user.id)) list.push(user);
|
||||
map.set(slug, list);
|
||||
@@ -1444,6 +1486,9 @@ export function TenantOrgChartPage() {
|
||||
queryKey: ["orgchart-snapshot", { cache: "redis" }],
|
||||
queryFn: () => fetchOrgChartSnapshot(),
|
||||
enabled: !shareToken,
|
||||
staleTime: 0,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const { rootNodes, usersMap, sharedWith } = React.useMemo(() => {
|
||||
|
||||
@@ -194,6 +194,24 @@ describe("OrgPickerEmbedPage orgchart data source", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("forces authenticated picker snapshots to refetch instead of trusting the in-memory query cache", async () => {
|
||||
adminApiMocks.fetchOrgChartSnapshot.mockResolvedValue(snapshot);
|
||||
|
||||
const rendered = renderPicker("/embed/picker?select=both");
|
||||
|
||||
await waitForExpect(() => {
|
||||
expect(adminApiMocks.fetchOrgChartSnapshot).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const query = rendered.queryClient.getQueryCache().find({
|
||||
queryKey: ["org-picker-orgchart", "authenticated", undefined],
|
||||
});
|
||||
|
||||
expect(query?.options.staleTime).toBe(0);
|
||||
expect(query?.options.refetchOnMount).toBe("always");
|
||||
expect(query?.options.refetchOnWindowFocus).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the public orgchart snapshot when a share token is present", async () => {
|
||||
adminApiMocks.fetchOrgChartSnapshot.mockResolvedValue(snapshot);
|
||||
adminApiMocks.fetchPublicOrgChart.mockResolvedValue(snapshot);
|
||||
|
||||
@@ -384,6 +384,9 @@ export function OrgPickerEmbedPage() {
|
||||
],
|
||||
queryFn: () =>
|
||||
shareToken ? fetchPublicOrgChart(shareToken) : fetchOrgChartSnapshot(),
|
||||
staleTime: 0,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -20,8 +20,6 @@ do
|
||||
assert_contains "$workflow" 'WORKS_ADMIN_API_BASE_URL=${{ vars.WORKS_ADMIN_API_BASE_URL }}'
|
||||
assert_contains "$workflow" 'WORKS_ADMIN_OAUTH_TOKEN_URL=${{ vars.WORKS_ADMIN_OAUTH_TOKEN_URL }}'
|
||||
assert_contains "$workflow" 'BACKEND_PUBLIC_URL=${{ vars.BACKEND_URL }}'
|
||||
assert_contains "$workflow" 'ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=${{ vars.ORGFRONT_ORGCHART_CACHE_TTL_SECONDS }}'
|
||||
assert_contains "$workflow" "ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=3600"
|
||||
done
|
||||
|
||||
assert_contains ".gitea/workflows/staging_release.yml" "scp adminfront/seed-tenant.csv"
|
||||
@@ -33,7 +31,5 @@ assert_contains "docker/staging_pull_compose.template.yaml" "SEED_TENANT_CSV_PAT
|
||||
assert_contains "docker/staging_pull_compose.template.yaml" "./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro"
|
||||
assert_contains "docker/staging_pull_compose.template.yaml" 'WORKS_ADMIN_API_BASE_URL=${WORKS_ADMIN_API_BASE_URL}'
|
||||
assert_contains "docker/staging_pull_compose.template.yaml" 'WORKS_ADMIN_OAUTH_TOKEN_URL=${WORKS_ADMIN_OAUTH_TOKEN_URL}'
|
||||
assert_contains "docker/docker-compose.staging.template.yaml" "ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=\${ORGFRONT_ORGCHART_CACHE_TTL_SECONDS:-3600}"
|
||||
assert_contains "docker/staging_pull_compose.template.yaml" "ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=\${ORGFRONT_ORGCHART_CACHE_TTL_SECONDS:-3600}"
|
||||
|
||||
echo "staging workflow env checks passed"
|
||||
|
||||
@@ -34,13 +34,53 @@ class AuthProxyService {
|
||||
}
|
||||
|
||||
static Exception _error(String key, String fallback, {String? detail}) {
|
||||
return Exception(
|
||||
tr(
|
||||
key,
|
||||
fallback: fallback,
|
||||
params: detail != null ? {'error': detail} : null,
|
||||
),
|
||||
);
|
||||
final params = detail != null ? {'error': detail} : null;
|
||||
var message = tr(key, fallback: fallback, params: params);
|
||||
if (message == key && fallback.isNotEmpty) {
|
||||
message = _interpolateFallback(fallback, params);
|
||||
}
|
||||
return Exception(message);
|
||||
}
|
||||
|
||||
static String _interpolateFallback(
|
||||
String fallback,
|
||||
Map<String, String>? params,
|
||||
) {
|
||||
var message = fallback;
|
||||
params?.forEach((key, value) {
|
||||
message = message.replaceAll('{{$key}}', value);
|
||||
});
|
||||
return message;
|
||||
}
|
||||
|
||||
static String _responseErrorDetail(http.Response response) {
|
||||
final body = response.body.trim();
|
||||
if (body.isEmpty) {
|
||||
return 'HTTP ${response.statusCode}';
|
||||
}
|
||||
try {
|
||||
final decoded = jsonDecode(body);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
final value = decoded['error'] ?? decoded['message'];
|
||||
if (value is String && value.trim().isNotEmpty) {
|
||||
return _formatUserPolicyError(value);
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Fall back to the raw response body.
|
||||
}
|
||||
return _formatUserPolicyError(body);
|
||||
}
|
||||
|
||||
static String _formatUserPolicyError(String message) {
|
||||
final normalized = message.toLowerCase();
|
||||
if (normalized.contains(
|
||||
'internal email domain cannot be assigned to personal tenant',
|
||||
) ||
|
||||
normalized.contains('내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다')) {
|
||||
return '내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다. 대표소속을 회사 또는 조직 소속으로 지정해 주세요.';
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
static http.Client _createClient({bool withCredentials = false}) {
|
||||
@@ -735,8 +775,8 @@ class AuthProxyService {
|
||||
if (response.statusCode != 200) {
|
||||
throw _error(
|
||||
'err.userfront.auth_proxy.user_create',
|
||||
'Failed to create the user: {{error}}',
|
||||
detail: response.body,
|
||||
'사용자 생성 실패: {{error}}',
|
||||
detail: _responseErrorDetail(response),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -844,8 +884,8 @@ class AuthProxyService {
|
||||
if (response.statusCode != 200) {
|
||||
throw _error(
|
||||
'err.userfront.auth_proxy.user_update',
|
||||
'Failed to update the user: {{error}}',
|
||||
detail: response.body,
|
||||
'사용자 수정 실패: {{error}}',
|
||||
detail: _responseErrorDetail(response),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,18 +242,6 @@ const Map<String, String> koStrings = {
|
||||
"msg.admin.tenants.sub.empty": "하위 테넌트가 없습니다.",
|
||||
"msg.admin.tenants.sub.subtitle": "현재 테넌트 하위에 생성된 조직입니다.",
|
||||
"msg.admin.tenants.subtitle": "현재 등록된 테넌트를 확인하고 상태를 관리합니다.",
|
||||
"msg.admin.user_projection.action_error": "사용자 동기화 작업에 실패했습니다.",
|
||||
"msg.admin.user_projection.action_success":
|
||||
"{{count}}명 기준으로 사용자 동기화를 갱신했습니다.",
|
||||
"msg.admin.user_projection.forbidden.description":
|
||||
"이 화면은 super_admin 권한으로만 접근할 수 있습니다.",
|
||||
"msg.admin.user_projection.forbidden_description":
|
||||
"이 화면은 super_admin 권한으로만 접근할 수 있습니다.",
|
||||
"msg.admin.user_projection.load_error": "사용자 동기화 상태를 불러오지 못했습니다.",
|
||||
"msg.admin.user_projection.reset_confirm":
|
||||
"사용자 동기화를 Kratos 기준으로 다시 구축하시겠습니까?",
|
||||
"msg.admin.user_projection.subtitle":
|
||||
"Kratos 사용자 read model을 확인하고 동기화 상태를 갱신합니다.",
|
||||
"msg.admin.users.bulk.delete_confirm": "선택한 {{count}}명의 사용자를 정말로 삭제하시겠습니까?",
|
||||
"msg.admin.users.bulk.delete_success": "{{count}}명의 사용자가 삭제되었습니다.",
|
||||
"msg.admin.users.bulk.description": "CSV 파일을 통해 사용자를 일괄 등록하거나 관리합니다.",
|
||||
@@ -1229,21 +1217,6 @@ const Map<String, String> koStrings = {
|
||||
"ui.admin.tenants.worksmobile.sync_user": "구성원 Sync",
|
||||
"ui.admin.tenants.worksmobile.title": "Worksmobile 연동",
|
||||
"ui.admin.title": "Admin Control",
|
||||
"ui.admin.user_projection.actions.reconcile": "재동기화",
|
||||
"ui.admin.user_projection.actions.reset": "초기화 후 재구축",
|
||||
"ui.admin.user_projection.card.description":
|
||||
"Backend DB 통계가 참조하는 사용자 read model 상태입니다.",
|
||||
"ui.admin.user_projection.card.title": "Kratos 사용자 동기화",
|
||||
"ui.admin.user_projection.forbidden.title": "접근 권한이 없습니다",
|
||||
"ui.admin.user_projection.loading": "불러오는 중",
|
||||
"ui.admin.user_projection.status.failed": "실패",
|
||||
"ui.admin.user_projection.status.not_ready": "준비되지 않음",
|
||||
"ui.admin.user_projection.status.ready": "준비됨",
|
||||
"ui.admin.user_projection.summary.last_synced": "마지막 동기화",
|
||||
"ui.admin.user_projection.summary.projected_users": "동기화 사용자",
|
||||
"ui.admin.user_projection.summary.status": "상태",
|
||||
"ui.admin.user_projection.summary.updated_at": "상태 갱신",
|
||||
"ui.admin.user_projection.title": "사용자 동기화 관리",
|
||||
"ui.admin.users.bulk.acknowledge_warning": "경고를 확인했으며 계속 진행합니다.",
|
||||
"ui.admin.users.bulk.create_missing_tenant": "신규 생성",
|
||||
"ui.admin.users.bulk.do_move": "이동 실행",
|
||||
@@ -2583,18 +2556,6 @@ const Map<String, String> enStrings = {
|
||||
"Review and manage child tenants linked under this tenant.",
|
||||
"msg.admin.tenants.subtitle":
|
||||
"Review registered tenants and manage their current status.",
|
||||
"msg.admin.user_projection.action_error": "Projection operation failed.",
|
||||
"msg.admin.user_projection.action_success":
|
||||
"Refreshed the projection for {{count}} users.",
|
||||
"msg.admin.user_projection.forbidden.description":
|
||||
"This screen is only available to super_admin users.",
|
||||
"msg.admin.user_projection.forbidden_description":
|
||||
"This screen is only available to super_admin users.",
|
||||
"msg.admin.user_projection.load_error": "Failed to load projection status.",
|
||||
"msg.admin.user_projection.reset_confirm":
|
||||
"Rebuild user projection from the Kratos source of truth?",
|
||||
"msg.admin.user_projection.subtitle":
|
||||
"Review and sync the Kratos user read model.",
|
||||
"msg.admin.users.bulk.delete_confirm":
|
||||
"Are you sure you want to delete the selected {{count}} users?",
|
||||
"msg.admin.users.bulk.delete_success": "{{count}} users have been deleted.",
|
||||
@@ -3718,23 +3679,6 @@ const Map<String, String> enStrings = {
|
||||
"ui.admin.tenants.worksmobile.sync_user": "User Sync",
|
||||
"ui.admin.tenants.worksmobile.title": "Worksmobile Integration",
|
||||
"ui.admin.title": "Admin Control",
|
||||
"ui.admin.user_projection.actions.reconcile": "Re-sync",
|
||||
"ui.admin.user_projection.actions.reset": "Reset and rebuild",
|
||||
"ui.admin.user_projection.card.description":
|
||||
"Current user read model state referenced by backend DB statistics.",
|
||||
"ui.admin.user_projection.card.title": "Kratos users projection",
|
||||
"ui.admin.user_projection.forbidden.title": "Access denied",
|
||||
"ui.admin.user_projection.loading": "Loading user projection data...",
|
||||
"ui.admin.user_projection.status.failed": "failed",
|
||||
"ui.admin.user_projection.status.not_ready": "not ready",
|
||||
"ui.admin.user_projection.status.ready": "ready",
|
||||
"ui.admin.user_projection.subtitle":
|
||||
"Review and sync the Kratos user read model.",
|
||||
"ui.admin.user_projection.summary.last_synced": "Last synced",
|
||||
"ui.admin.user_projection.summary.projected_users": "Projected users",
|
||||
"ui.admin.user_projection.summary.status": "Status",
|
||||
"ui.admin.user_projection.summary.updated_at": "Updated at",
|
||||
"ui.admin.user_projection.title": "User Projection Management",
|
||||
"ui.admin.users.bulk.acknowledge_warning":
|
||||
"I acknowledge the warning and will proceed.",
|
||||
"ui.admin.users.bulk.create_missing_tenant": "Create new",
|
||||
|
||||
@@ -203,6 +203,49 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test('createUser는 내부 도메인 personal 정책 오류를 한국어 안내로 표시한다', () async {
|
||||
client.enqueueJson({
|
||||
'error':
|
||||
'internal email domain cannot be assigned to personal tenant: user@hanmaceng.co.kr',
|
||||
}, statusCode: 400);
|
||||
|
||||
await expectLater(
|
||||
AuthProxyService.createUser(
|
||||
loginId: 'user@hanmaceng.co.kr',
|
||||
adminPassword: 'admin-pass',
|
||||
email: 'user@hanmaceng.co.kr',
|
||||
),
|
||||
throwsA(
|
||||
isA<Exception>().having(
|
||||
(error) => error.toString(),
|
||||
'message',
|
||||
contains('대표소속을 회사 또는 조직 소속으로 지정해 주세요'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('updateUserDetails는 내부 도메인 personal 정책 오류를 한국어 안내로 표시한다', () async {
|
||||
client.enqueueJson({
|
||||
'error': '내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다: user@brsw.kr',
|
||||
}, statusCode: 400);
|
||||
|
||||
await expectLater(
|
||||
AuthProxyService.updateUserDetails(
|
||||
adminPassword: 'admin-pass',
|
||||
loginId: 'user@brsw.kr',
|
||||
email: 'user@brsw.kr',
|
||||
),
|
||||
throwsA(
|
||||
isA<Exception>().having(
|
||||
(error) => error.toString(),
|
||||
'message',
|
||||
contains('대표소속을 회사 또는 조직 소속으로 지정해 주세요'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('sendLog는 민감 정보를 제거한 client log를 전송한다', () async {
|
||||
client.enqueueJson({'ok': true});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user