diff --git a/.gitea/workflows/staging_code_pull.yml b/.gitea/workflows/staging_code_pull.yml index 6b971386..f648ada4 100644 --- a/.gitea/workflows/staging_code_pull.yml +++ b/.gitea/workflows/staging_code_pull.yml @@ -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 diff --git a/.gitea/workflows/staging_release.yml b/.gitea/workflows/staging_release.yml index d15d1338..fa1c9eba 100644 --- a/.gitea/workflows/staging_release.yml +++ b/.gitea/workflows/staging_release.yml @@ -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 diff --git a/adminfront/Trace-20260615T113806.json.gz b/adminfront/Trace-20260615T113806.json.gz new file mode 100644 index 00000000..e15ba30b Binary files /dev/null and b/adminfront/Trace-20260615T113806.json.gz differ diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx index 339a14d7..8d72cf88 100644 --- a/adminfront/src/app/routes.tsx +++ b/adminfront/src/app/routes.tsx @@ -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: }, { path: "api-keys/new", element: }, - { path: "system/ory-ssot", element: }, + { path: "system/ory-ssot", element: }, { path: "system/data-integrity", element: }, ], }, diff --git a/adminfront/src/components/common/LocaleRefreshBoundary.test.tsx b/adminfront/src/components/common/LocaleRefreshBoundary.test.tsx index 24e945d8..c1ab9f60 100644 --- a/adminfront/src/components/common/LocaleRefreshBoundary.test.tsx +++ b/adminfront/src/components/common/LocaleRefreshBoundary.test.tsx @@ -31,4 +31,27 @@ describe("LocaleRefreshBoundary", () => { expect(screen.getByText("2")).toBeInTheDocument(); }); + + it("ignores storage events unrelated to locale changes", async () => { + render( + + + , + ); + + 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(); + }); }); diff --git a/adminfront/src/components/common/LocaleRefreshBoundary.tsx b/adminfront/src/components/common/LocaleRefreshBoundary.tsx index 64cc3841..370bed50 100644 --- a/adminfront/src/components/common/LocaleRefreshBoundary.tsx +++ b/adminfront/src/components/common/LocaleRefreshBoundary.tsx @@ -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); }; }, []); diff --git a/adminfront/src/features/api-keys/ApiKeyListPage.test.tsx b/adminfront/src/features/api-keys/ApiKeyListPage.test.tsx index 43d43135..14b12389 100644 --- a/adminfront/src/features/api-keys/ApiKeyListPage.test.tsx +++ b/adminfront/src/features/api-keys/ApiKeyListPage.test.tsx @@ -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(); diff --git a/adminfront/src/features/coverage/adminPageAnimationPolicy.test.ts b/adminfront/src/features/coverage/adminPageAnimationPolicy.test.ts new file mode 100644 index 00000000..6cf89235 --- /dev/null +++ b/adminfront/src/features/coverage/adminPageAnimationPolicy.test.ts @@ -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( + [], + ); + }); +}); diff --git a/adminfront/src/features/integrity/DataIntegrityPage.test.tsx b/adminfront/src/features/integrity/DataIntegrityPage.test.tsx index 49de2a55..0405ce6f 100644 --- a/adminfront/src/features/integrity/DataIntegrityPage.test.tsx +++ b/adminfront/src/features/integrity/DataIntegrityPage.test.tsx @@ -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(); diff --git a/adminfront/src/features/integrity/DataIntegrityPage.tsx b/adminfront/src/features/integrity/DataIntegrityPage.tsx index 0159aea0..bb75cb55 100644 --- a/adminfront/src/features/integrity/DataIntegrityPage.tsx +++ b/adminfront/src/features/integrity/DataIntegrityPage.tsx @@ -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([]); const [recheckStatus, setRecheckStatus] = useState< "idle" | "running" | "success" | "error" @@ -373,243 +361,210 @@ function DataIntegrityContent() {

- {activeTab === "integrity" ? ( -
- + {recheckMessage ? ( + - - {isManualRechecking - ? t("ui.admin.integrity.recheck.running", "검사 중") - : t("ui.admin.integrity.recheck.run", "다시 검사")} - - {recheckMessage ? ( - - {recheckMessage} - - ) : null} -
- ) : null} + {recheckMessage} + + ) : null} + -
- - -
- - {activeTab === "integrity" ? ( -
- {isError ? ( -
- {(error as Error)?.message || - t( - "msg.admin.integrity.report.load_error", - "정합성 리포트를 불러오지 못했습니다.", - )} -
- ) : null} - -
-
-
-

- {t( - "ui.admin.integrity.read_model.title", - "Read model integrity", - )} -

-

- {t( - "msg.admin.integrity.read_model.description", - "Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 확인합니다.", - )} -

-
- {data ? ( - - {statusLabel(data.status)} - - ) : null} -
- - {isLoading ? ( -
- {t("ui.admin.integrity.loading", "불러오는 중")} -
- ) : ( -
-
-
- {t("ui.admin.integrity.summary.total_checks", "검사 항목")} -
-
- {data?.summary.totalChecks ?? 0} -
-
-
-
- {t("ui.admin.integrity.summary.passed", "정상")} -
-
- {data?.summary.passed ?? 0} -
-
-
-
- {t("ui.admin.integrity.summary.failures", "실패 건수")} -
-
- {data?.summary.failures ?? 0} -
-
-
-
- {t("ui.admin.integrity.summary.checked_at", "검사 시각")} -
-
- {formatDateTime(data?.checkedAt)} -
-
-
- )} +
+ {isError ? ( +
+ {(error as Error)?.message || + t( + "msg.admin.integrity.report.load_error", + "정합성 리포트를 불러오지 못했습니다.", + )}
+ ) : null} -
- {(data?.sections ?? []).map((section) => ( -
-
-
-

- {integritySectionLabel(section.key, section.label)} -

-

- {integritySectionDescription(section.key)} -

-
- - {statusLabel(section.status)} - -
-
- {section.checks.map((check) => ( -
-
- -
-
- {integrityCheckLabel(check.key, check.label)} -
-

- {integrityCheckDescription( - check.key, - check.description, - )} -

-
-
-
- - {statusLabel(check.status)} - - - {check.count} - -
-
- ))} -
-
- ))} +
+
+
+

+ {t( + "ui.admin.integrity.read_model.title", + "Read model integrity", + )} +

+

+ {t( + "msg.admin.integrity.read_model.description", + "Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 확인합니다.", + )} +

+
+ {data ? ( + + {statusLabel(data.status)} + + ) : null}
-
-
-
-

- {t( - "ui.admin.integrity.orphan_login_ids.title", - "유령 로그인 ID 정리", - )} -

-

- {t( - "msg.admin.integrity.orphan_login_ids.description", - "삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다.", - )} -

-
- + {isLoading ? ( +
+ {t("ui.admin.integrity.loading", "불러오는 중")}
- {orphanLoginIDsQuery.isError ? ( -
- {t( - "msg.admin.integrity.orphan_login_ids.load_error", - "유령 로그인 ID 대상을 불러오지 못했습니다.", - )} + ) : ( +
+
+
+ {t("ui.admin.integrity.summary.total_checks", "검사 항목")} +
+
+ {data?.summary.totalChecks ?? 0} +
- ) : null} - {deleteMutation.data ? ( -
- {t( - "msg.admin.integrity.orphan_login_ids.delete_success", - "{{count}}개의 유령 로그인 ID를 삭제했습니다.", - { count: deleteMutation.data.deletedCount }, - )} +
+
+ {t("ui.admin.integrity.summary.passed", "정상")} +
+
+ {data?.summary.passed ?? 0} +
- ) : null} - -
+
+
+ {t("ui.admin.integrity.summary.failures", "실패 건수")} +
+
+ {data?.summary.failures ?? 0} +
+
+
+
+ {t("ui.admin.integrity.summary.checked_at", "검사 시각")} +
+
+ {formatDateTime(data?.checkedAt)} +
+
+ + )} +
+ +
+ {(data?.sections ?? []).map((section) => ( +
+
+
+

+ {integritySectionLabel(section.key, section.label)} +

+

+ {integritySectionDescription(section.key)} +

+
+ + {statusLabel(section.status)} + +
+
+ {section.checks.map((check) => ( +
+
+ +
+
+ {integrityCheckLabel(check.key, check.label)} +
+

+ {integrityCheckDescription( + check.key, + check.description, + )} +

+
+
+
+ + {statusLabel(check.status)} + + + {check.count} + +
+
+ ))} +
+
+ ))}
- ) : ( -
- -
- )} + +
+
+
+

+ {t( + "ui.admin.integrity.orphan_login_ids.title", + "유령 로그인 ID 정리", + )} +

+

+ {t( + "msg.admin.integrity.orphan_login_ids.description", + "삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다.", + )} +

+
+ +
+ {orphanLoginIDsQuery.isError ? ( +
+ {t( + "msg.admin.integrity.orphan_login_ids.load_error", + "유령 로그인 ID 대상을 불러오지 못했습니다.", + )} +
+ ) : null} + {deleteMutation.data ? ( +
+ {t( + "msg.admin.integrity.orphan_login_ids.delete_success", + "{{count}}개의 유령 로그인 ID를 삭제했습니다.", + { count: deleteMutation.data.deletedCount }, + )} +
+ ) : null} + +
+
); } diff --git a/adminfront/src/features/projections/UserProjectionPage.test.tsx b/adminfront/src/features/ory-ssot/OrySSOTPage.test.tsx similarity index 57% rename from adminfront/src/features/projections/UserProjectionPage.test.tsx rename to adminfront/src/features/ory-ssot/OrySSOTPage.test.tsx index 064d199a..65503e53 100644 --- a/adminfront/src/features/projections/UserProjectionPage.test.tsx +++ b/adminfront/src/features/ory-ssot/OrySSOTPage.test.tsx @@ -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( - + , ); } -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); - }); }); diff --git a/adminfront/src/features/projections/UserProjectionPage.tsx b/adminfront/src/features/ory-ssot/OrySSOTPage.tsx similarity index 80% rename from adminfront/src/features/projections/UserProjectionPage.tsx rename to adminfront/src/features/ory-ssot/OrySSOTPage.tsx index 8e663c00..bc0eb48f 100644 --- a/adminfront/src/features/projections/UserProjectionPage.tsx +++ b/adminfront/src/features/ory-ssot/OrySSOTPage.tsx @@ -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 = ( -
-
-
- + return ( +
+
+
+
+ +
+
+

+ {t("ui.admin.ory_ssot.title", "Ory SSOT System")} +

+

+ {t( + "msg.admin.ory_ssot.subtitle", + "Review Kratos source-of-truth and Redis identity cache status separately.", + )} +

+
-
-

- {t("ui.admin.ory_ssot.title", "Ory SSOT System")} -

-

- {t( - "msg.admin.ory_ssot.subtitle", - "Review Kratos source-of-truth and Redis identity cache status separately.", - )} -

-
-
- -
- ); + + - const body = ( - <> {isError ? (
{(error as Error)?.message || @@ -220,27 +208,11 @@ export function UserProjectionContent({
) : null}
- - ); - - if (embedded) { - return ( -
- {header} - {body} -
- ); - } - - return ( -
- {header} - {body}
); } -export default function UserProjectionPage() { +export default function OrySSOTPage() { return ( } > - + ); } diff --git a/adminfront/src/features/overview/GlobalOverviewPage.tsx b/adminfront/src/features/overview/GlobalOverviewPage.tsx index 2b9095ef..b8f10730 100644 --- a/adminfront/src/features/overview/GlobalOverviewPage.tsx +++ b/adminfront/src/features/overview/GlobalOverviewPage.tsx @@ -506,7 +506,7 @@ function GlobalOverviewPage() { ); return ( -
+
diff --git a/adminfront/src/features/tenants/components/ParentTenantSelector.picker.test.tsx b/adminfront/src/features/tenants/components/ParentTenantSelector.picker.test.tsx index 51197cf4..d91d5de3 100644 --- a/adminfront/src/features/tenants/components/ParentTenantSelector.picker.test.tsx +++ b/adminfront/src/features/tenants/components/ParentTenantSelector.picker.test.tsx @@ -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( + , + ); + + 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(); diff --git a/adminfront/src/features/tenants/components/ParentTenantSelector.tsx b/adminfront/src/features/tenants/components/ParentTenantSelector.tsx index 3b8830b7..79f3e039 100644 --- a/adminfront/src/features/tenants/components/ParentTenantSelector.tsx +++ b/adminfront/src/features/tenants/components/ParentTenantSelector.tsx @@ -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" /> diff --git a/adminfront/src/features/tenants/routes/TenantCreatePage.tsx b/adminfront/src/features/tenants/routes/TenantCreatePage.tsx index 3725e7a6..ba1a3095 100644 --- a/adminfront/src/features/tenants/routes/TenantCreatePage.tsx +++ b/adminfront/src/features/tenants/routes/TenantCreatePage.tsx @@ -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", "다른 테넌트 선택", diff --git a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx index 11ef24d1..13011837 100644 --- a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx @@ -125,7 +125,7 @@ function TenantDetailPage() {
{/* Outlet for nested routes */} -
+
diff --git a/adminfront/src/features/tenants/routes/TenantWorksmobilePage.test.ts b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.test.ts index 851a9dcf..80bf9377 100644 --- a/adminfront/src/features/tenants/routes/TenantWorksmobilePage.test.ts +++ b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.test.ts @@ -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({ diff --git a/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx index cd86aada..b30e03e1 100644 --- a/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx +++ b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx @@ -520,7 +520,7 @@ export function TenantWorksmobilePage() {
{activeTab === "history" ? ( -
+
@@ -627,7 +627,7 @@ export function TenantWorksmobilePage() { ) : null} {activeTab === "users" ? ( -
+
+
(); + 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, +) { + 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, diff --git a/adminfront/src/features/users/UserCreatePage.tsx b/adminfront/src/features/users/UserCreatePage.tsx index e17785bd..7fc95c3b 100644 --- a/adminfront/src/features/users/UserCreatePage.tsx +++ b/adminfront/src/features/users/UserCreatePage.tsx @@ -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}`} > - - {appointment.tenantName || "테넌트 선택"} + + {appointment.tenantName || + t( + "ui.admin.users.create.form.pick_from_hanmac_family", + "한맥가족에서 선택", + )} {appointment.tenantSlug && ( diff --git a/adminfront/src/features/users/UserDetailPage.tsx b/adminfront/src/features/users/UserDetailPage.tsx index 1cf8a3e7..ee9221ca 100644 --- a/adminfront/src/features/users/UserDetailPage.tsx +++ b/adminfront/src/features/users/UserDetailPage.tsx @@ -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; } diff --git a/adminfront/src/features/users/UserListPage.render.test.tsx b/adminfront/src/features/users/UserListPage.render.test.tsx index 94a5c589..5300fafd 100644 --- a/adminfront/src/features/users/UserListPage.render.test.tsx +++ b/adminfront/src/features/users/UserListPage.render.test.tsx @@ -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(); diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx index 3708ea9f..4c13072e 100644 --- a/adminfront/src/features/users/UserListPage.tsx +++ b/adminfront/src/features/users/UserListPage.tsx @@ -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={ <> diff --git a/adminfront/src/features/users/components/UserBulkUploadModal.tsx b/adminfront/src/features/users/components/UserBulkUploadModal.tsx index 8fc5f534..0d5e24e6 100644 --- a/adminfront/src/features/users/components/UserBulkUploadModal.tsx +++ b/adminfront/src/features/users/components/UserBulkUploadModal.tsx @@ -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 && (
- {r.message} + {formatUserPolicyMessage(r.message)}
)}
diff --git a/adminfront/src/features/users/orgChartPicker.test.ts b/adminfront/src/features/users/orgChartPicker.test.ts index 382b6296..b0a696ae 100644 --- a/adminfront/src/features/users/orgChartPicker.test.ts +++ b/adminfront/src/features/users/orgChartPicker.test.ts @@ -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( diff --git a/adminfront/src/features/users/orgChartPicker.ts b/adminfront/src/features/users/orgChartPicker.ts index 28764a68..ffa9ac30 100644 --- a/adminfront/src/features/users/orgChartPicker.ts +++ b/adminfront/src/features/users/orgChartPicker.ts @@ -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); diff --git a/adminfront/src/features/users/userPolicyMessages.ts b/adminfront/src/features/users/userPolicyMessages.ts new file mode 100644 index 00000000..87d80ee8 --- /dev/null +++ b/adminfront/src/features/users/userPolicyMessages.ts @@ -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; +} diff --git a/adminfront/src/lib/adminApi.test.ts b/adminfront/src/lib/adminApi.test.ts index def6ad92..567e0664 100644 --- a/adminfront/src/lib/adminApi.test.ts +++ b/adminfront/src/lib/adminApi.test.ts @@ -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"); }); diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index 3d57a696..93808ffe 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -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( - "/v1/admin/projections/users", - ); - return data; -} - export async function fetchOrySSOTSystemStatus() { const { data } = await apiClient.get("/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( + "/v1/admin/users/bulk", + payload, + ); return data; } diff --git a/adminfront/src/lib/auth.ts b/adminfront/src/lib/auth.ts index 24eb1a7d..52050c92 100644 --- a/adminfront/src/lib/auth.ts +++ b/adminfront/src/lib/auth.ts @@ -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 }), diff --git a/adminfront/src/lib/authConfig.test.ts b/adminfront/src/lib/authConfig.test.ts index 08365860..1cef8d39 100644 --- a/adminfront/src/lib/authConfig.test.ts +++ b/adminfront/src/lib/authConfig.test.ts @@ -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({ diff --git a/adminfront/src/lib/authConfig.ts b/adminfront/src/lib/authConfig.ts index aa597950..fc78a519 100644 --- a/adminfront/src/lib/authConfig.ts +++ b/adminfront/src/lib/authConfig.ts @@ -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, diff --git a/adminfront/src/locales/en.toml b/adminfront/src/locales/en.toml index 4dad1404..416f9cf3 100644 --- a/adminfront/src/locales/en.toml +++ b/adminfront/src/locales/en.toml @@ -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] diff --git a/adminfront/src/locales/ko.toml b/adminfront/src/locales/ko.toml index eaa3c692..0e9662cb 100644 --- a/adminfront/src/locales/ko.toml +++ b/adminfront/src/locales/ko.toml @@ -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 = "테이블에 표시할 컬럼을 선택합니다." diff --git a/adminfront/src/locales/template.toml b/adminfront/src/locales/template.toml index b5d5eec4..7d1953de 100644 --- a/adminfront/src/locales/template.toml +++ b/adminfront/src/locales/template.toml @@ -335,6 +335,7 @@ move_description = "" move_error = "" move_success = "" parsed_count = "" +update_partial_error = "" update_success = "" [msg.admin.users.create] diff --git a/adminfront/src/test/i18nMock.ts b/adminfront/src/test/i18nMock.ts index b6954015..52da054a 100644 --- a/adminfront/src/test/i18nMock.ts +++ b/adminfront/src/test/i18nMock.ts @@ -77,7 +77,8 @@ const translations: Record<"ko" | "en", Record> = { "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> = { "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)).", diff --git a/adminfront/tests/bulk_actions.spec.ts b/adminfront/tests/bulk_actions.spec.ts index 61b56006..0310f145 100644 --- a/adminfront/tests/bulk_actions.spec.ts +++ b/adminfront/tests/bulk_actions.spec.ts @@ -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, }) => { diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts index db3cf406..d03ec80b 100644 --- a/adminfront/tests/tenants.spec.ts +++ b/adminfront/tests/tenants.spec.ts @@ -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); diff --git a/adminfront/tests/users.spec.ts b/adminfront/tests/users.spec.ts index f4097c72..c0f59917 100644 --- a/adminfront/tests/users.spec.ts +++ b/adminfront/tests/users.spec.ts @@ -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, }) => { diff --git a/adminfront/tests/users_bulk.spec.ts b/adminfront/tests/users_bulk.spec.ts index 07c27a55..6c87103b 100644 --- a/adminfront/tests/users_bulk.spec.ts +++ b/adminfront/tests/users_bulk.spec.ts @@ -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, }) => { diff --git a/backend/cmd/adminctl/main.go b/backend/cmd/adminctl/main.go index 4fff9371..558ed470 100644 --- a/backend/cmd/adminctl/main.go +++ b/backend/cmd/adminctl/main.go @@ -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 == "" { + 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]") } diff --git a/backend/cmd/adminctl/main_test.go b/backend/cmd/adminctl/main_test.go index d980bc87..fc0521fb 100644 --- a/backend/cmd/adminctl/main_test.go +++ b/backend/cmd/adminctl/main_test.go @@ -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 } diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 8f7ba5dc..f4414ba1 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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) diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index e32bb187..a3f491a5 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -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{}, diff --git a/backend/internal/bootstrap/user_metadata_sanitize.go b/backend/internal/bootstrap/user_metadata_sanitize.go index 679c3c0a..fcd05e1e 100644 --- a/backend/internal/bootstrap/user_metadata_sanitize.go +++ b/backend/internal/bootstrap/user_metadata_sanitize.go @@ -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 +} diff --git a/backend/internal/bootstrap/user_metadata_sanitize_test.go b/backend/internal/bootstrap/user_metadata_sanitize_test.go index bf48fffa..d7b4d7a5 100644 --- a/backend/internal/bootstrap/user_metadata_sanitize_test.go +++ b/backend/internal/bootstrap/user_metadata_sanitize_test.go @@ -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 { diff --git a/backend/internal/domain/user_projection.go b/backend/internal/domain/user_projection.go deleted file mode 100644 index 48de0cba..00000000 --- a/backend/internal/domain/user_projection.go +++ /dev/null @@ -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"` -} diff --git a/backend/internal/handler/admin_handler.go b/backend/internal/handler/admin_handler.go index da14ef4f..6b50a59a 100644 --- a/backend/internal/handler/admin_handler.go +++ b/backend/internal/handler/admin_handler.go @@ -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 { diff --git a/backend/internal/handler/admin_handler_test.go b/backend/internal/handler/admin_handler_test.go index ddd7afd7..5ba8589c 100644 --- a/backend/internal/handler/admin_handler_test.go +++ b/backend/internal/handler/admin_handler_test.go @@ -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()) } diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index e92041d3..c0550c69 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -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) diff --git a/backend/internal/handler/hanmac_email_policy.go b/backend/internal/handler/hanmac_email_policy.go index e5a92023..e39243a6 100644 --- a/backend/internal/handler/hanmac_email_policy.go +++ b/backend/internal/handler/hanmac_email_policy.go @@ -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 diff --git a/backend/internal/handler/internal_domain_personal_policy.go b/backend/internal/handler/internal_domain_personal_policy.go new file mode 100644 index 00000000..ed95bac3 --- /dev/null +++ b/backend/internal/handler/internal_domain_personal_policy.go @@ -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 +} diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index 9c6c7d67..59d31317 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -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 == "" { + 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 { diff --git a/backend/internal/handler/tenant_handler_seed_delete_test.go b/backend/internal/handler/tenant_handler_seed_delete_test.go index b4c1eae4..4e4ae090 100644 --- a/backend/internal/handler/tenant_handler_seed_delete_test.go +++ b/backend/internal/handler/tenant_handler_seed_delete_test.go @@ -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) +} diff --git a/backend/internal/handler/tenant_handler_test.go b/backend/internal/handler/tenant_handler_test.go index 2119d099..9bdd5413 100644 --- a/backend/internal/handler/tenant_handler_test.go +++ b/backend/internal/handler/tenant_handler_test.go @@ -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) diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index fbee515b..af7f165b 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -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) } diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go index af4a76be..e5ed5d0f 100644 --- a/backend/internal/handler/user_handler_test.go +++ b/backend/internal/handler/user_handler_test.go @@ -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{ diff --git a/backend/internal/handler/user_projection_failure.go b/backend/internal/handler/user_projection_failure.go deleted file mode 100644 index 8fadca50..00000000 --- a/backend/internal/handler/user_projection_failure.go +++ /dev/null @@ -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) - } -} diff --git a/backend/internal/repository/main_test.go b/backend/internal/repository/main_test.go index 79f148e0..45e9631e 100644 --- a/backend/internal/repository/main_test.go +++ b/backend/internal/repository/main_test.go @@ -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) } diff --git a/backend/internal/repository/user_membership_maintenance.go b/backend/internal/repository/user_membership_maintenance.go index a22dfae9..ceadeece 100644 --- a/backend/internal/repository/user_membership_maintenance.go +++ b/backend/internal/repository/user_membership_maintenance.go @@ -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 } diff --git a/backend/internal/repository/user_membership_maintenance_test.go b/backend/internal/repository/user_membership_maintenance_test.go index 7cd6084a..0d92ca27 100644 --- a/backend/internal/repository/user_membership_maintenance_test.go +++ b/backend/internal/repository/user_membership_maintenance_test.go @@ -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) diff --git a/backend/internal/repository/user_projection_repository.go b/backend/internal/repository/user_projection_repository.go deleted file mode 100644 index e40085e8..00000000 --- a/backend/internal/repository/user_projection_repository.go +++ /dev/null @@ -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 -} diff --git a/backend/internal/repository/user_projection_repository_test.go b/backend/internal/repository/user_projection_repository_test.go deleted file mode 100644 index 8843a9c5..00000000 --- a/backend/internal/repository/user_projection_repository_test.go +++ /dev/null @@ -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) -} diff --git a/backend/internal/repository/user_repository.go b/backend/internal/repository/user_repository.go index e6323a45..5d4ebd86 100644 --- a/backend/internal/repository/user_repository.go +++ b/backend/internal/repository/user_repository.go @@ -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 { diff --git a/backend/internal/repository/user_repository_test.go b/backend/internal/repository/user_repository_test.go index 7d839c83..69b1e069 100644 --- a/backend/internal/repository/user_repository_test.go +++ b/backend/internal/repository/user_repository_test.go @@ -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) { diff --git a/backend/internal/service/redis_service.go b/backend/internal/service/redis_service.go index 311c99e7..666f65c6 100644 --- a/backend/internal/service/redis_service.go +++ b/backend/internal/service/redis_service.go @@ -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{ diff --git a/backend/internal/service/redis_service_test.go b/backend/internal/service/redis_service_test.go index 7a873f50..61693e0c 100644 --- a/backend/internal/service/redis_service_test.go +++ b/backend/internal/service/redis_service_test.go @@ -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()) diff --git a/backend/internal/service/user_group_service.go b/backend/internal/service/user_group_service.go index 6999c41f..826f0020 100644 --- a/backend/internal/service/user_group_service.go +++ b/backend/internal/service/user_group_service.go @@ -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) } } diff --git a/backend/internal/service/user_group_service_test.go b/backend/internal/service/user_group_service_test.go index 21ff735f..18d236cd 100644 --- a/backend/internal/service/user_group_service_test.go +++ b/backend/internal/service/user_group_service_test.go @@ -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 } diff --git a/backend/internal/service/user_projection_sync_service.go b/backend/internal/service/user_projection_sync_service.go deleted file mode 100644 index a2025ca1..00000000 --- a/backend/internal/service/user_projection_sync_service.go +++ /dev/null @@ -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 -} diff --git a/backend/internal/service/user_projection_sync_service_test.go b/backend/internal/service/user_projection_sync_service_test.go deleted file mode 100644 index 127b0752..00000000 --- a/backend/internal/service/user_projection_sync_service_test.go +++ /dev/null @@ -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) -} diff --git a/backend/internal/service/worksmobile_client.go b/backend/internal/service/worksmobile_client.go index 3909fc56..faac2fc0 100644 --- a/backend/internal/service/worksmobile_client.go +++ b/backend/internal/service/worksmobile_client.go @@ -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 { diff --git a/backend/internal/service/worksmobile_client_test.go b/backend/internal/service/worksmobile_client_test.go index d2518fc2..ef08f9ae 100644 --- a/backend/internal/service/worksmobile_client_test.go +++ b/backend/internal/service/worksmobile_client_test.go @@ -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 diff --git a/backend/internal/service/worksmobile_mapper.go b/backend/internal/service/worksmobile_mapper.go index 2f4be79b..be642bc9 100644 --- a/backend/internal/service/worksmobile_mapper.go +++ b/backend/internal/service/worksmobile_mapper.go @@ -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 diff --git a/backend/internal/service/worksmobile_mapper_test.go b/backend/internal/service/worksmobile_mapper_test.go index 07b8f859..61305376 100644 --- a/backend/internal/service/worksmobile_mapper_test.go +++ b/backend/internal/service/worksmobile_mapper_test.go @@ -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) { diff --git a/backend/internal/service/worksmobile_relay_worker.go b/backend/internal/service/worksmobile_relay_worker.go index e206428c..e160a8fb 100644 --- a/backend/internal/service/worksmobile_relay_worker.go +++ b/backend/internal/service/worksmobile_relay_worker.go @@ -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 { diff --git a/backend/internal/service/worksmobile_sync_service.go b/backend/internal/service/worksmobile_sync_service.go index 7c6ee414..1b2515d9 100644 --- a/backend/internal/service/worksmobile_sync_service.go +++ b/backend/internal/service/worksmobile_sync_service.go @@ -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) { diff --git a/backend/internal/service/worksmobile_sync_service_test.go b/backend/internal/service/worksmobile_sync_service_test.go index 32661eb6..187e29d1 100644 --- a/backend/internal/service/worksmobile_sync_service_test.go +++ b/backend/internal/service/worksmobile_sync_service_test.go @@ -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) { diff --git a/devfront/tests/devfront-consents.spec.ts b/devfront/tests/devfront-consents.spec.ts index c5e415fa..a9da5725 100644 --- a/devfront/tests/devfront-consents.spec.ts +++ b/devfront/tests/devfront-consents.spec.ts @@ -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) => { diff --git a/docker/docker-compose.staging.template.yaml b/docker/docker-compose.staging.template.yaml index 07b4bfca..550aa4af 100644 --- a/docker/docker-compose.staging.template.yaml +++ b/docker/docker-compose.staging.template.yaml @@ -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" diff --git a/docker/staging_pull_compose.template.yaml b/docker/staging_pull_compose.template.yaml index 9537768d..31ca94e2 100644 --- a/docker/staging_pull_compose.template.yaml +++ b/docker/staging_pull_compose.template.yaml @@ -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} diff --git a/docs/adminfront-flicker-trace-analysis-2026-06-15.md b/docs/adminfront-flicker-trace-analysis-2026-06-15.md new file mode 100644 index 00000000..036a7d56 --- /dev/null +++ b/docs/adminfront-flicker-trace-analysis-2026-06-15.md @@ -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 +``` diff --git a/docs/works-only-user-recovery-2026-06-09.md b/docs/works-only-user-recovery-2026-06-09.md index 194626b3..e123ce9a 100644 --- a/docs/works-only-user-recovery-2026-06-09.md +++ b/docs/works-only-user-recovery-2026-06-09.md @@ -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` 결과: diff --git a/docs/worksmobile-directory-sync-technical-review.md b/docs/worksmobile-directory-sync-technical-review.md index 78feb742..29e29afe 100644 --- a/docs/worksmobile-directory-sync-technical-review.md +++ b/docs/worksmobile-directory-sync-technical-review.md @@ -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가 누락되지 않게 합니다. diff --git a/docs/worksmobile-phone-outbound-policy-2026-06-15.md b/docs/worksmobile-phone-outbound-policy-2026-06-15.md new file mode 100644 index 00000000..5587690e --- /dev/null +++ b/docs/worksmobile-phone-outbound-policy-2026-06-15.md @@ -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...` 형식으로 변환한다. +- 한국 번호가 아닌 값은 기존 정규화 결과를 유지한다. diff --git a/locales/en.toml b/locales/en.toml index 4fad3b26..f6c70996 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -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" diff --git a/locales/ko.toml b/locales/ko.toml index 676f7743..c0235175 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -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 = "인증 가드" diff --git a/locales/template.toml b/locales/template.toml index 2d3b2c70..82910d10 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -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 = "" diff --git a/orgfront/src/features/orgchart/routes/OrgChartPage.test.tsx b/orgfront/src/features/orgchart/routes/OrgChartPage.test.tsx index 09a2b4b7..58fe2886 100644 --- a/orgfront/src/features/orgchart/routes/OrgChartPage.test.tsx +++ b/orgfront/src/features/orgchart/routes/OrgChartPage.test.tsx @@ -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 = { diff --git a/orgfront/src/features/orgchart/routes/OrgChartPage.tsx b/orgfront/src/features/orgchart/routes/OrgChartPage.tsx index 04956966..364b6a48 100644 --- a/orgfront/src/features/orgchart/routes/OrgChartPage.tsx +++ b/orgfront/src/features/orgchart/routes/OrgChartPage.tsx @@ -1270,14 +1270,27 @@ function getUserOrgAppointmentRefs(user: UserSummary): UserOrgAppointmentRef[] { } function addTenantSlugCandidate( - slugs: Set, + tenantIds: Set, 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, + 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, +function getLeafMembershipIds( + tenantIds: Set, 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(); + const tenantIds = new Set(); 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(() => { diff --git a/orgfront/src/features/orgchart/routes/OrgPickerPage.test.tsx b/orgfront/src/features/orgchart/routes/OrgPickerPage.test.tsx index 351bf0fb..f62a3e5a 100644 --- a/orgfront/src/features/orgchart/routes/OrgPickerPage.test.tsx +++ b/orgfront/src/features/orgchart/routes/OrgPickerPage.test.tsx @@ -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); diff --git a/orgfront/src/features/orgchart/routes/OrgPickerPage.tsx b/orgfront/src/features/orgchart/routes/OrgPickerPage.tsx index d0b122e6..48c609d1 100644 --- a/orgfront/src/features/orgchart/routes/OrgPickerPage.tsx +++ b/orgfront/src/features/orgchart/routes/OrgPickerPage.tsx @@ -384,6 +384,9 @@ export function OrgPickerEmbedPage() { ], queryFn: () => shareToken ? fetchPublicOrgChart(shareToken) : fetchOrgChartSnapshot(), + staleTime: 0, + refetchOnMount: "always", + refetchOnWindowFocus: true, }); React.useEffect(() => { diff --git a/scripts/test_staging_workflow_env.sh b/scripts/test_staging_workflow_env.sh index 1b93bc9e..1ee66d47 100644 --- a/scripts/test_staging_workflow_env.sh +++ b/scripts/test_staging_workflow_env.sh @@ -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" diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart index fa0bdef0..d146c795 100644 --- a/userfront/lib/core/services/auth_proxy_service.dart +++ b/userfront/lib/core/services/auth_proxy_service.dart @@ -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? 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) { + 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), ); } } diff --git a/userfront/lib/i18n_data.dart b/userfront/lib/i18n_data.dart index 223bc654..67bbf33b 100644 --- a/userfront/lib/i18n_data.dart +++ b/userfront/lib/i18n_data.dart @@ -242,18 +242,6 @@ const Map 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 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 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 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", diff --git a/userfront/test/auth_proxy_service_test.dart b/userfront/test/auth_proxy_service_test.dart index 9d46dba0..27875000 100644 --- a/userfront/test/auth_proxy_service_test.dart +++ b/userfront/test/auth_proxy_service_test.dart @@ -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().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().having( + (error) => error.toString(), + 'message', + contains('대표소속을 회사 또는 조직 소속으로 지정해 주세요'), + ), + ), + ); + }); + test('sendLog는 민감 정보를 제거한 client log를 전송한다', () async { client.enqueueJson({'ok': true});