forked from baron/baron-sso
네이버 계정 정합성 맞춤
This commit is contained in:
@@ -80,7 +80,6 @@ jobs:
|
|||||||
AUDIT_WORKER_COUNT=5
|
AUDIT_WORKER_COUNT=5
|
||||||
AUDIT_QUEUE_SIZE=2000
|
AUDIT_QUEUE_SIZE=2000
|
||||||
PROFILE_CACHE_TTL=${{ vars.PROFILE_CACHE_TTL }}
|
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_ACCESS_KEY=${{ vars.NAVER_CLOUD_ACCESS_KEY }}
|
||||||
NAVER_CLOUD_SECRET_KEY=${{ secrets.NAVER_CLOUD_SECRET_KEY }}
|
NAVER_CLOUD_SECRET_KEY=${{ secrets.NAVER_CLOUD_SECRET_KEY }}
|
||||||
NAVER_CLOUD_SERVICE_ID=${{ vars.NAVER_CLOUD_SERVICE_ID }}
|
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' }}
|
LOKI_URL=${{ vars.LOKI_URL || 'http://loki:3100/loki/api/v1/push' }}
|
||||||
EOF
|
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)
|
# 코드 업데이트 (Git)
|
||||||
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p '${DEPLOY_PATH}' && cd '${DEPLOY_PATH}' && \
|
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p '${DEPLOY_PATH}' && cd '${DEPLOY_PATH}' && \
|
||||||
if [ ! -d .git ]; then
|
if [ ! -d .git ]; then
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ jobs:
|
|||||||
AUDIT_WORKER_COUNT=5
|
AUDIT_WORKER_COUNT=5
|
||||||
AUDIT_QUEUE_SIZE=2000
|
AUDIT_QUEUE_SIZE=2000
|
||||||
PROFILE_CACHE_TTL=${{ vars.PROFILE_CACHE_TTL }}
|
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_ACCESS_KEY=${{ vars.NAVER_CLOUD_ACCESS_KEY }}
|
||||||
NAVER_CLOUD_SECRET_KEY=${{ secrets.NAVER_CLOUD_SECRET_KEY }}
|
NAVER_CLOUD_SECRET_KEY=${{ secrets.NAVER_CLOUD_SECRET_KEY }}
|
||||||
NAVER_CLOUD_SERVICE_ID=${{ vars.NAVER_CLOUD_SERVICE_ID }}
|
NAVER_CLOUD_SERVICE_ID=${{ vars.NAVER_CLOUD_SERVICE_ID }}
|
||||||
@@ -143,16 +142,11 @@ jobs:
|
|||||||
# OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
|
# OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
|
||||||
EOF
|
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="
|
required_dotenv_keys="
|
||||||
APP_ENV BACKEND_LOG_LEVEL CLIENT_LOG_DEBUG WORKS_ADMIN_API_BASE_URL WORKS_ADMIN_OAUTH_TOKEN_URL TZ IDP_PROVIDER
|
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
|
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
|
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
|
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
|
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
|
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
|
USERFRONT_URL ORGFRONT_URL BACKEND_PUBLIC_URL BACKEND_URL OATHKEEPER_PUBLIC_URL
|
||||||
|
|||||||
BIN
adminfront/Trace-20260615T113806.json.gz
Normal file
BIN
adminfront/Trace-20260615T113806.json.gz
Normal file
Binary file not shown.
@@ -9,8 +9,8 @@ import AuthGuard from "../features/auth/AuthGuard";
|
|||||||
import AuthPage from "../features/auth/AuthPage";
|
import AuthPage from "../features/auth/AuthPage";
|
||||||
import LoginPage from "../features/auth/LoginPage";
|
import LoginPage from "../features/auth/LoginPage";
|
||||||
import DataIntegrityPage from "../features/integrity/DataIntegrityPage";
|
import DataIntegrityPage from "../features/integrity/DataIntegrityPage";
|
||||||
|
import OrySSOTPage from "../features/ory-ssot/OrySSOTPage";
|
||||||
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
|
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
|
||||||
import UserProjectionPage from "../features/projections/UserProjectionPage";
|
|
||||||
import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab";
|
import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab";
|
||||||
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
|
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
|
||||||
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
|
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
|
||||||
@@ -67,7 +67,7 @@ export const adminRoutes: RouteObject[] = [
|
|||||||
},
|
},
|
||||||
{ path: "api-keys", element: <ApiKeyListPage /> },
|
{ path: "api-keys", element: <ApiKeyListPage /> },
|
||||||
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
|
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
|
||||||
{ path: "system/ory-ssot", element: <UserProjectionPage /> },
|
{ path: "system/ory-ssot", element: <OrySSOTPage /> },
|
||||||
{ path: "system/data-integrity", element: <DataIntegrityPage /> },
|
{ path: "system/data-integrity", element: <DataIntegrityPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -31,4 +31,27 @@ describe("LocaleRefreshBoundary", () => {
|
|||||||
|
|
||||||
expect(screen.getByText("2")).toBeInTheDocument();
|
expect(screen.getByText("2")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("ignores storage events unrelated to locale changes", async () => {
|
||||||
|
render(
|
||||||
|
<LocaleRefreshBoundary>
|
||||||
|
<RenderCounter />
|
||||||
|
</LocaleRefreshBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("1")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new StorageEvent("storage", {
|
||||||
|
key: "admin_session",
|
||||||
|
newValue: "token",
|
||||||
|
oldValue: null,
|
||||||
|
storageArea: window.localStorage,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("1")).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Fragment, type ReactNode, useEffect, useState } from "react";
|
import { Fragment, type ReactNode, useEffect, useState } from "react";
|
||||||
|
import { LOCALE_STORAGE_KEY } from "../../../../common/core/i18n";
|
||||||
|
|
||||||
type LocaleRefreshBoundaryProps = {
|
type LocaleRefreshBoundaryProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -12,12 +13,19 @@ function LocaleRefreshBoundary({ children }: LocaleRefreshBoundaryProps) {
|
|||||||
setLocaleVersion((current) => current + 1);
|
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("localechange", syncLocale);
|
||||||
window.addEventListener("storage", syncLocale);
|
window.addEventListener("storage", syncLocaleFromStorage);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("localechange", syncLocale);
|
window.removeEventListener("localechange", syncLocale);
|
||||||
window.removeEventListener("storage", syncLocale);
|
window.removeEventListener("storage", syncLocaleFromStorage);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ describe("ApiKeyListPage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("updates scopes without changing client_id", async () => {
|
it("updates scopes without changing client_id", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup({ delay: null });
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
expect(await screen.findByText("client-id-stable")).toBeInTheDocument();
|
expect(await screen.findByText("client-id-stable")).toBeInTheDocument();
|
||||||
@@ -88,7 +88,7 @@ describe("ApiKeyListPage", () => {
|
|||||||
scopes: expect.arrayContaining(["audit:read", "org-context:read"]),
|
scopes: expect.arrayContaining(["audit:read", "org-context:read"]),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
}, 15_000);
|
||||||
|
|
||||||
it("rotates only the secret and shows the one-time secret", async () => {
|
it("rotates only the secret and shows the one-time secret", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
function listSourceFiles(directory: string): string[] {
|
||||||
|
const entries = readdirSync(directory);
|
||||||
|
const files: string[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
const path = join(directory, entry);
|
||||||
|
const stat = statSync(path);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
files.push(...listSourceFiles(path));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (path.endsWith(".tsx")) {
|
||||||
|
files.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("admin page animation policy", () => {
|
||||||
|
it("does not use long enter fade animations on stable page containers", () => {
|
||||||
|
const sourceRoot = join(process.cwd(), "src");
|
||||||
|
const offenders = listSourceFiles(sourceRoot).filter((file) =>
|
||||||
|
readFileSync(file, "utf8").includes("animate-in fade-in duration-500"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(offenders.map((file) => file.replace(`${sourceRoot}/`, ""))).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,8 +6,6 @@ import {
|
|||||||
fetchDataIntegrityReport,
|
fetchDataIntegrityReport,
|
||||||
fetchMe,
|
fetchMe,
|
||||||
fetchOrphanUserLoginIDs,
|
fetchOrphanUserLoginIDs,
|
||||||
fetchOrySSOTSystemStatus,
|
|
||||||
flushIdentityCache,
|
|
||||||
} from "../../lib/adminApi";
|
} from "../../lib/adminApi";
|
||||||
import { expectNoAnonymousFormFields } from "../../test/formFieldDiagnostics";
|
import { expectNoAnonymousFormFields } from "../../test/formFieldDiagnostics";
|
||||||
import { createI18nMock } from "../../test/i18nMock";
|
import { createI18nMock } from "../../test/i18nMock";
|
||||||
@@ -63,21 +61,6 @@ vi.mock("../../lib/adminApi", () => ({
|
|||||||
],
|
],
|
||||||
total: 1,
|
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 () => ({
|
deleteOrphanUserLoginIDs: vi.fn(async () => ({
|
||||||
deletedCount: 1,
|
deletedCount: 1,
|
||||||
deleted: [
|
deleted: [
|
||||||
@@ -121,12 +104,6 @@ describe("DataIntegrityPage", () => {
|
|||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
expect(await screen.findByText("데이터 정합성 검증")).toBeInTheDocument();
|
expect(await screen.findByText("데이터 정합성 검증")).toBeInTheDocument();
|
||||||
expect(
|
|
||||||
screen.getByRole("tab", { name: "정합성 검사" }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.getByRole("tab", { name: "Ory SSOT 시스템" }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText(
|
await screen.findByText(
|
||||||
"정합성 상태를 확인하고 데이터 모델 전반의 검증 결과를 살펴봅니다.",
|
"정합성 상태를 확인하고 데이터 모델 전반의 검증 결과를 살펴봅니다.",
|
||||||
@@ -138,28 +115,6 @@ describe("DataIntegrityPage", () => {
|
|||||||
expect(fetchDataIntegrityReport).toHaveBeenCalledTimes(1);
|
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 () => {
|
it("shows orphan login ID targets and deletes selected rows", async () => {
|
||||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||||
const { container } = renderPage();
|
const { container } = renderPage();
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
} from "../../lib/adminApi";
|
} from "../../lib/adminApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
import { getAdminDateLocale } from "../../lib/locale";
|
import { getAdminDateLocale } from "../../lib/locale";
|
||||||
import { UserProjectionContent } from "../projections/UserProjectionPage";
|
|
||||||
|
|
||||||
function statusLabel(status: DataIntegrityStatus) {
|
function statusLabel(status: DataIntegrityStatus) {
|
||||||
switch (status) {
|
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({
|
function OrphanLoginIDTable({
|
||||||
items,
|
items,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
@@ -294,9 +285,6 @@ function OrphanLoginIDTable({
|
|||||||
|
|
||||||
function DataIntegrityContent() {
|
function DataIntegrityContent() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [activeTab, setActiveTab] = useState<"integrity" | "projection">(
|
|
||||||
"integrity",
|
|
||||||
);
|
|
||||||
const [selectedOrphanIds, setSelectedOrphanIds] = useState<string[]>([]);
|
const [selectedOrphanIds, setSelectedOrphanIds] = useState<string[]>([]);
|
||||||
const [recheckStatus, setRecheckStatus] = useState<
|
const [recheckStatus, setRecheckStatus] = useState<
|
||||||
"idle" | "running" | "success" | "error"
|
"idle" | "running" | "success" | "error"
|
||||||
@@ -373,243 +361,210 @@ function DataIntegrityContent() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{activeTab === "integrity" ? (
|
<div className="flex flex-col items-end gap-1">
|
||||||
<div className="flex flex-col items-end gap-1">
|
<Button
|
||||||
<Button
|
type="button"
|
||||||
type="button"
|
variant="outline"
|
||||||
variant="outline"
|
onClick={handleRecheck}
|
||||||
onClick={handleRecheck}
|
disabled={isLoading || isFetching || isManualRechecking}
|
||||||
disabled={isLoading || isFetching || isManualRechecking}
|
>
|
||||||
|
<Database size={16} />
|
||||||
|
{isManualRechecking
|
||||||
|
? t("ui.admin.integrity.recheck.running", "검사 중")
|
||||||
|
: t("ui.admin.integrity.recheck.run", "다시 검사")}
|
||||||
|
</Button>
|
||||||
|
{recheckMessage ? (
|
||||||
|
<output
|
||||||
|
aria-live="polite"
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
>
|
>
|
||||||
<Database size={16} />
|
{recheckMessage}
|
||||||
{isManualRechecking
|
</output>
|
||||||
? t("ui.admin.integrity.recheck.running", "검사 중")
|
) : null}
|
||||||
: t("ui.admin.integrity.recheck.run", "다시 검사")}
|
</div>
|
||||||
</Button>
|
|
||||||
{recheckMessage ? (
|
|
||||||
<output
|
|
||||||
aria-live="polite"
|
|
||||||
className="text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
{recheckMessage}
|
|
||||||
</output>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div
|
<div className="space-y-4 pb-6">
|
||||||
className="flex border-b border-border"
|
{isError ? (
|
||||||
role="tablist"
|
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||||
aria-label="데이터 정합성 탭"
|
{(error as Error)?.message ||
|
||||||
>
|
t(
|
||||||
<button
|
"msg.admin.integrity.report.load_error",
|
||||||
type="button"
|
"정합성 리포트를 불러오지 못했습니다.",
|
||||||
role="tab"
|
)}
|
||||||
aria-selected={activeTab === "integrity"}
|
|
||||||
className={pageTabClassName(activeTab === "integrity")}
|
|
||||||
onClick={() => setActiveTab("integrity")}
|
|
||||||
>
|
|
||||||
{t("ui.admin.integrity.tab_checks", "정합성 검사")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={activeTab === "projection"}
|
|
||||||
className={pageTabClassName(activeTab === "projection")}
|
|
||||||
onClick={() => setActiveTab("projection")}
|
|
||||||
>
|
|
||||||
{t("ui.admin.integrity.tab_ory_ssot", "Ory SSOT 시스템")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeTab === "integrity" ? (
|
|
||||||
<div className="space-y-4 pb-6 animate-in fade-in duration-500">
|
|
||||||
{isError ? (
|
|
||||||
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
|
||||||
{(error as Error)?.message ||
|
|
||||||
t(
|
|
||||||
"msg.admin.integrity.report.load_error",
|
|
||||||
"정합성 리포트를 불러오지 못했습니다.",
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<section className="rounded-lg border border-border bg-card p-5">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
|
||||||
{t(
|
|
||||||
"ui.admin.integrity.read_model.title",
|
|
||||||
"Read model integrity",
|
|
||||||
)}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t(
|
|
||||||
"msg.admin.integrity.read_model.description",
|
|
||||||
"Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 확인합니다.",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{data ? (
|
|
||||||
<Badge variant={statusBadgeVariant(data.status)}>
|
|
||||||
{statusLabel(data.status)}
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="py-8 text-sm text-muted-foreground">
|
|
||||||
{t("ui.admin.integrity.loading", "불러오는 중")}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm text-muted-foreground">
|
|
||||||
{t("ui.admin.integrity.summary.total_checks", "검사 항목")}
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
|
||||||
{data?.summary.totalChecks ?? 0}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm text-muted-foreground">
|
|
||||||
{t("ui.admin.integrity.summary.passed", "정상")}
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
|
||||||
{data?.summary.passed ?? 0}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm text-muted-foreground">
|
|
||||||
{t("ui.admin.integrity.summary.failures", "실패 건수")}
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
|
||||||
{data?.summary.failures ?? 0}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm text-muted-foreground">
|
|
||||||
{t("ui.admin.integrity.summary.checked_at", "검사 시각")}
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm">
|
|
||||||
{formatDateTime(data?.checkedAt)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="space-y-4">
|
<section className="rounded-lg border border-border bg-card p-5">
|
||||||
{(data?.sections ?? []).map((section) => (
|
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-4">
|
||||||
<section
|
<div>
|
||||||
key={section.key}
|
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||||
className="rounded-lg border border-border bg-card p-5"
|
{t(
|
||||||
>
|
"ui.admin.integrity.read_model.title",
|
||||||
<div className="mb-4 flex items-center justify-between gap-3">
|
"Read model integrity",
|
||||||
<div className="space-y-1">
|
)}
|
||||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
</h3>
|
||||||
{integritySectionLabel(section.key, section.label)}
|
<p className="text-sm text-muted-foreground">
|
||||||
</h3>
|
{t(
|
||||||
<p className="text-sm text-muted-foreground">
|
"msg.admin.integrity.read_model.description",
|
||||||
{integritySectionDescription(section.key)}
|
"Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 확인합니다.",
|
||||||
</p>
|
)}
|
||||||
</div>
|
</p>
|
||||||
<Badge variant={statusBadgeVariant(section.status)}>
|
</div>
|
||||||
{statusLabel(section.status)}
|
{data ? (
|
||||||
</Badge>
|
<Badge variant={statusBadgeVariant(data.status)}>
|
||||||
</div>
|
{statusLabel(data.status)}
|
||||||
<div className="divide-y divide-border">
|
</Badge>
|
||||||
{section.checks.map((check) => (
|
) : null}
|
||||||
<div
|
|
||||||
key={check.key}
|
|
||||||
className="grid gap-3 py-4 md:grid-cols-[1fr_auto]"
|
|
||||||
>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<CheckIcon check={check} />
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">
|
|
||||||
{integrityCheckLabel(check.key, check.label)}
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
|
||||||
{integrityCheckDescription(
|
|
||||||
check.key,
|
|
||||||
check.description,
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 md:justify-end">
|
|
||||||
<Badge variant={statusBadgeVariant(check.status)}>
|
|
||||||
{statusLabel(check.status)}
|
|
||||||
</Badge>
|
|
||||||
<span className="min-w-12 text-right text-lg font-semibold tabular-nums">
|
|
||||||
{check.count}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="rounded-lg border border-border bg-card p-5">
|
{isLoading ? (
|
||||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
<div className="py-8 text-sm text-muted-foreground">
|
||||||
<div>
|
{t("ui.admin.integrity.loading", "불러오는 중")}
|
||||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
|
||||||
{t(
|
|
||||||
"ui.admin.integrity.orphan_login_ids.title",
|
|
||||||
"유령 로그인 ID 정리",
|
|
||||||
)}
|
|
||||||
</h3>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
|
||||||
{t(
|
|
||||||
"msg.admin.integrity.orphan_login_ids.description",
|
|
||||||
"삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다.",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="destructive"
|
|
||||||
onClick={handleDeleteSelected}
|
|
||||||
disabled={
|
|
||||||
selectedOrphanIds.length === 0 || deleteMutation.isPending
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("ui.admin.integrity.orphan_login_ids.delete", "선택 삭제")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
{orphanLoginIDsQuery.isError ? (
|
) : (
|
||||||
<div className="mb-3 rounded border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
<dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{t(
|
<div>
|
||||||
"msg.admin.integrity.orphan_login_ids.load_error",
|
<dt className="text-sm text-muted-foreground">
|
||||||
"유령 로그인 ID 대상을 불러오지 못했습니다.",
|
{t("ui.admin.integrity.summary.total_checks", "검사 항목")}
|
||||||
)}
|
</dt>
|
||||||
|
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
||||||
|
{data?.summary.totalChecks ?? 0}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
<div>
|
||||||
{deleteMutation.data ? (
|
<dt className="text-sm text-muted-foreground">
|
||||||
<div className="mb-3 rounded border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200">
|
{t("ui.admin.integrity.summary.passed", "정상")}
|
||||||
{t(
|
</dt>
|
||||||
"msg.admin.integrity.orphan_login_ids.delete_success",
|
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
||||||
"{{count}}개의 유령 로그인 ID를 삭제했습니다.",
|
{data?.summary.passed ?? 0}
|
||||||
{ count: deleteMutation.data.deletedCount },
|
</dd>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
<div>
|
||||||
<OrphanLoginIDTable
|
<dt className="text-sm text-muted-foreground">
|
||||||
items={orphanItems}
|
{t("ui.admin.integrity.summary.failures", "실패 건수")}
|
||||||
selectedIds={selectedOrphanIds}
|
</dt>
|
||||||
onToggle={toggleOrphanID}
|
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
||||||
/>
|
{data?.summary.failures ?? 0}
|
||||||
</section>
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-muted-foreground">
|
||||||
|
{t("ui.admin.integrity.summary.checked_at", "검사 시각")}
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 text-sm">
|
||||||
|
{formatDateTime(data?.checkedAt)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{(data?.sections ?? []).map((section) => (
|
||||||
|
<section
|
||||||
|
key={section.key}
|
||||||
|
className="rounded-lg border border-border bg-card p-5"
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||||
|
{integritySectionLabel(section.key, section.label)}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{integritySectionDescription(section.key)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant={statusBadgeVariant(section.status)}>
|
||||||
|
{statusLabel(section.status)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{section.checks.map((check) => (
|
||||||
|
<div
|
||||||
|
key={check.key}
|
||||||
|
className="grid gap-3 py-4 md:grid-cols-[1fr_auto]"
|
||||||
|
>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<CheckIcon check={check} />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{integrityCheckLabel(check.key, check.label)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{integrityCheckDescription(
|
||||||
|
check.key,
|
||||||
|
check.description,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 md:justify-end">
|
||||||
|
<Badge variant={statusBadgeVariant(check.status)}>
|
||||||
|
{statusLabel(check.status)}
|
||||||
|
</Badge>
|
||||||
|
<span className="min-w-12 text-right text-lg font-semibold tabular-nums">
|
||||||
|
{check.count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="animate-in fade-in duration-500">
|
<section className="rounded-lg border border-border bg-card p-5">
|
||||||
<UserProjectionContent embedded />
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
</div>
|
<div>
|
||||||
)}
|
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||||
|
{t(
|
||||||
|
"ui.admin.integrity.orphan_login_ids.title",
|
||||||
|
"유령 로그인 ID 정리",
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"msg.admin.integrity.orphan_login_ids.description",
|
||||||
|
"삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDeleteSelected}
|
||||||
|
disabled={
|
||||||
|
selectedOrphanIds.length === 0 || deleteMutation.isPending
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("ui.admin.integrity.orphan_login_ids.delete", "선택 삭제")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{orphanLoginIDsQuery.isError ? (
|
||||||
|
<div className="mb-3 rounded border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{t(
|
||||||
|
"msg.admin.integrity.orphan_login_ids.load_error",
|
||||||
|
"유령 로그인 ID 대상을 불러오지 못했습니다.",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{deleteMutation.data ? (
|
||||||
|
<div className="mb-3 rounded border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200">
|
||||||
|
{t(
|
||||||
|
"msg.admin.integrity.orphan_login_ids.delete_success",
|
||||||
|
"{{count}}개의 유령 로그인 ID를 삭제했습니다.",
|
||||||
|
{ count: deleteMutation.data.deletedCount },
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<OrphanLoginIDTable
|
||||||
|
items={orphanItems}
|
||||||
|
selectedIds={selectedOrphanIds}
|
||||||
|
onToggle={toggleOrphanID}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
fetchMe,
|
||||||
fetchOrySSOTSystemStatus,
|
fetchOrySSOTSystemStatus,
|
||||||
flushIdentityCache,
|
flushIdentityCache,
|
||||||
} from "../../lib/adminApi";
|
} from "../../lib/adminApi";
|
||||||
import { createI18nMock } from "../../test/i18nMock";
|
import { createI18nMock } from "../../test/i18nMock";
|
||||||
import UserProjectionPage from "./UserProjectionPage";
|
import OrySSOTPage from "./OrySSOTPage";
|
||||||
|
|
||||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||||
|
|
||||||
@@ -19,9 +20,9 @@ vi.mock("../../lib/adminApi", () => ({
|
|||||||
status: "ready",
|
status: "ready",
|
||||||
redisReady: true,
|
redisReady: true,
|
||||||
observedCount: 151,
|
observedCount: 151,
|
||||||
|
keyCount: 153,
|
||||||
lastRefreshedAt: "2026-05-11T03:00:00Z",
|
lastRefreshedAt: "2026-05-11T03:00:00Z",
|
||||||
updatedAt: "2026-05-11T03:00:10Z",
|
updatedAt: "2026-05-11T03:00:10Z",
|
||||||
keyCount: 153,
|
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
flushIdentityCache: vi.fn(async () => ({
|
flushIdentityCache: vi.fn(async () => ({
|
||||||
@@ -41,12 +42,12 @@ function renderPage() {
|
|||||||
|
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<UserProjectionPage />
|
<OrySSOTPage />
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("UserProjectionPage", () => {
|
describe("OrySSOTPage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
currentRole = "super_admin";
|
currentRole = "super_admin";
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -54,37 +55,22 @@ describe("UserProjectionPage", () => {
|
|||||||
window.localStorage.setItem("locale", "ko");
|
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();
|
renderPage();
|
||||||
|
|
||||||
expect(await screen.findByText("Ory SSOT 시스템")).toBeInTheDocument();
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText(
|
(await screen.findAllByText("Ory SSOT 시스템")).length,
|
||||||
"Kratos 원장과 Redis identity cache 상태를 분리해서 확인합니다.",
|
).toBeGreaterThan(0);
|
||||||
),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(await screen.findByText("Redis identity cache")).toBeInTheDocument();
|
expect(await screen.findByText("Redis identity cache")).toBeInTheDocument();
|
||||||
expect(screen.getAllByText("준비됨").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("준비됨").length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText("관측 identity")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("151")).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/ }));
|
fireEvent.click(screen.getByRole("button", { name: /Redis cache flush/ }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(flushIdentityCache).toHaveBeenCalledTimes(1);
|
expect(flushIdentityCache).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
expect(fetchOrySSOTSystemStatus).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blocks non-super admins", async () => {
|
it("blocks non-super admins", async () => {
|
||||||
@@ -93,21 +79,7 @@ describe("UserProjectionPage", () => {
|
|||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
expect(await screen.findByText("접근 권한이 없습니다")).toBeInTheDocument();
|
expect(await screen.findByText("접근 권한이 없습니다")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("Ory SSOT 시스템")).not.toBeInTheDocument();
|
expect(fetchMe).toHaveBeenCalled();
|
||||||
expect(fetchOrySSOTSystemStatus).not.toHaveBeenCalled();
|
expect(fetchOrySSOTSystemStatus).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders localized labels in English", async () => {
|
|
||||||
window.localStorage.setItem("locale", "en");
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
expect(await screen.findByText("Ory SSOT System")).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
await screen.findByText(
|
|
||||||
"Review Kratos source-of-truth and Redis identity cache status separately.",
|
|
||||||
),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Redis cache flush")).toBeInTheDocument();
|
|
||||||
expect((await screen.findAllByText("ready")).length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -42,11 +42,7 @@ function StatusBadge({ ready, status }: { ready: boolean; status: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserProjectionContent({
|
function OrySSOTContent() {
|
||||||
embedded = false,
|
|
||||||
}: {
|
|
||||||
embedded?: boolean;
|
|
||||||
}) {
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data, isLoading, isError, error } = useQuery({
|
const { data, isLoading, isError, error } = useQuery({
|
||||||
queryKey: ["ory-ssot-system-status"],
|
queryKey: ["ory-ssot-system-status"],
|
||||||
@@ -74,47 +70,39 @@ export function UserProjectionContent({
|
|||||||
|
|
||||||
const identityCache = data?.identityCache;
|
const identityCache = data?.identityCache;
|
||||||
|
|
||||||
const header = (
|
return (
|
||||||
<header
|
<main className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||||
className={
|
<header className="flex flex-shrink-0 flex-wrap items-start justify-between gap-4 sticky top-[-2.5rem] z-20 -mt-4 bg-background/95 pb-2 pt-4 backdrop-blur">
|
||||||
embedded
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
? "flex flex-shrink-0 flex-wrap items-start justify-between gap-4"
|
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
|
||||||
: "flex flex-shrink-0 flex-wrap items-start justify-between gap-4 sticky top-[-2.5rem] z-20 -mt-4 bg-background/95 pb-2 pt-4 backdrop-blur"
|
<Database size={20} />
|
||||||
}
|
</div>
|
||||||
>
|
<div className="space-y-2">
|
||||||
<div className="flex min-w-0 items-start gap-3">
|
<h2 className="text-3xl font-semibold">
|
||||||
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
|
{t("ui.admin.ory_ssot.title", "Ory SSOT System")}
|
||||||
<Database size={20} />
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"msg.admin.ory_ssot.subtitle",
|
||||||
|
"Review Kratos source-of-truth and Redis identity cache status separately.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<Button
|
||||||
<h2 className="text-3xl font-semibold">
|
type="button"
|
||||||
{t("ui.admin.ory_ssot.title", "Ory SSOT System")}
|
variant="destructive"
|
||||||
</h2>
|
onClick={handleFlush}
|
||||||
<p className="text-sm text-muted-foreground">
|
disabled={flushMutation.isPending}
|
||||||
{t(
|
>
|
||||||
"msg.admin.ory_ssot.subtitle",
|
<Trash2 size={16} />
|
||||||
"Review Kratos source-of-truth and Redis identity cache status separately.",
|
{t(
|
||||||
)}
|
"ui.admin.ory_ssot.actions.flush_identity_cache",
|
||||||
</p>
|
"Redis cache flush",
|
||||||
</div>
|
)}
|
||||||
</div>
|
</Button>
|
||||||
<Button
|
</header>
|
||||||
type="button"
|
|
||||||
variant="destructive"
|
|
||||||
onClick={handleFlush}
|
|
||||||
disabled={flushMutation.isPending}
|
|
||||||
>
|
|
||||||
<Trash2 size={16} />
|
|
||||||
{t(
|
|
||||||
"ui.admin.ory_ssot.actions.flush_identity_cache",
|
|
||||||
"Redis cache flush",
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
|
|
||||||
const body = (
|
|
||||||
<>
|
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||||
{(error as Error)?.message ||
|
{(error as Error)?.message ||
|
||||||
@@ -220,27 +208,11 @@ export function UserProjectionContent({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (embedded) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4 pb-6">
|
|
||||||
{header}
|
|
||||||
{body}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
|
||||||
{header}
|
|
||||||
{body}
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UserProjectionPage() {
|
export default function OrySSOTPage() {
|
||||||
return (
|
return (
|
||||||
<RoleGuard
|
<RoleGuard
|
||||||
roles={["super_admin"]}
|
roles={["super_admin"]}
|
||||||
@@ -260,7 +232,7 @@ export default function UserProjectionPage() {
|
|||||||
</main>
|
</main>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<UserProjectionContent />
|
<OrySSOTContent />
|
||||||
</RoleGuard>
|
</RoleGuard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -506,7 +506,7 @@ function GlobalOverviewPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 animate-in fade-in duration-500">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div className="flex min-w-0 items-start gap-3">
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
|
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
|
||||||
|
|||||||
@@ -46,8 +46,10 @@ describe("ParentTenantSelector picker", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: /테넌트 선택/ }));
|
fireEvent.click(screen.getByRole("button", { name: /테넌트 선택/ }));
|
||||||
|
|
||||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
const pickerSrc = screen.getByTitle("테넌트 선택").getAttribute("src");
|
const pickerSrc = screen
|
||||||
expect(pickerSrc).toContain("/login");
|
.getByTestId("parent-tenant-picker-frame")
|
||||||
|
.getAttribute("src");
|
||||||
|
expect(pickerSrc).toContain("http://localhost:5175/login");
|
||||||
expect(decodeURIComponent(pickerSrc ?? "")).toContain("/embed/picker");
|
expect(decodeURIComponent(pickerSrc ?? "")).toContain("/embed/picker");
|
||||||
|
|
||||||
fireEvent(
|
fireEvent(
|
||||||
@@ -71,6 +73,30 @@ describe("ParentTenantSelector picker", () => {
|
|||||||
await waitFor(() => expect(onChange).toHaveBeenCalledWith("company-1"));
|
await waitFor(() => expect(onChange).toHaveBeenCalledWith("company-1"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("scopes the org-chart picker to the requested tenant root", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ParentTenantSelector
|
||||||
|
id="parentId"
|
||||||
|
label="상위 테넌트"
|
||||||
|
value=""
|
||||||
|
onChange={onChange}
|
||||||
|
tenants={tenants}
|
||||||
|
noneLabel="없음"
|
||||||
|
orgChartTenantId="group-1"
|
||||||
|
orgChartPickerLabel="한맥가족에서 선택"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "한맥가족에서 선택" }));
|
||||||
|
|
||||||
|
const pickerSrc = screen
|
||||||
|
.getByTestId("parent-tenant-picker-frame")
|
||||||
|
.getAttribute("src");
|
||||||
|
expect(decodeURIComponent(pickerSrc ?? "")).toContain("tenantId=group-1");
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps the current tenant out of picker message selections", async () => {
|
it("keeps the current tenant out of picker message selections", async () => {
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ type ParentTenantSelectorProps = {
|
|||||||
labelAction?: ReactNode;
|
labelAction?: ReactNode;
|
||||||
contextLabel?: string;
|
contextLabel?: string;
|
||||||
orgChartPickerLabel?: string;
|
orgChartPickerLabel?: string;
|
||||||
|
orgChartTenantId?: string;
|
||||||
localPickerLabel?: string;
|
localPickerLabel?: string;
|
||||||
localTenantFilter?: (tenant: TenantSummary) => boolean;
|
localTenantFilter?: (tenant: TenantSummary) => boolean;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
@@ -49,6 +50,7 @@ export function ParentTenantSelector({
|
|||||||
labelAction,
|
labelAction,
|
||||||
contextLabel,
|
contextLabel,
|
||||||
orgChartPickerLabel,
|
orgChartPickerLabel,
|
||||||
|
orgChartTenantId,
|
||||||
localPickerLabel,
|
localPickerLabel,
|
||||||
localTenantFilter,
|
localTenantFilter,
|
||||||
compact = false,
|
compact = false,
|
||||||
@@ -66,6 +68,9 @@ export function ParentTenantSelector({
|
|||||||
);
|
);
|
||||||
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
|
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
|
||||||
import.meta.env.ORGFRONT_URL,
|
import.meta.env.ORGFRONT_URL,
|
||||||
|
{
|
||||||
|
tenantId: orgChartTenantId,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -135,6 +140,7 @@ export function ParentTenantSelector({
|
|||||||
title={t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
|
title={t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
|
||||||
src={pickerUrl}
|
src={pickerUrl}
|
||||||
className="h-[600px] w-full rounded-md border"
|
className="h-[600px] w-full rounded-md border"
|
||||||
|
data-testid="parent-tenant-picker-frame"
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -61,6 +61,13 @@ function TenantCreatePage() {
|
|||||||
});
|
});
|
||||||
const tenants = parentQuery.data?.items ?? [];
|
const tenants = parentQuery.data?.items ?? [];
|
||||||
const selectedParentTenant = tenants.find((tenant) => tenant.id === parentId);
|
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(() => {
|
const canConfigureHanmacOrg = useMemo(() => {
|
||||||
if (!selectedParentTenant) return false;
|
if (!selectedParentTenant) return false;
|
||||||
if (selectedParentTenant.slug.toLowerCase() === "hanmac-family") {
|
if (selectedParentTenant.slug.toLowerCase() === "hanmac-family") {
|
||||||
@@ -206,6 +213,7 @@ function TenantCreatePage() {
|
|||||||
"ui.admin.tenants.create.form.pick_hanmac_parent",
|
"ui.admin.tenants.create.form.pick_hanmac_parent",
|
||||||
"한맥가족에서 선택",
|
"한맥가족에서 선택",
|
||||||
)}
|
)}
|
||||||
|
orgChartTenantId={hanmacFamilyTenantId}
|
||||||
localPickerLabel={t(
|
localPickerLabel={t(
|
||||||
"ui.admin.tenants.create.form.pick_other_parent",
|
"ui.admin.tenants.create.form.pick_other_parent",
|
||||||
"다른 테넌트 선택",
|
"다른 테넌트 선택",
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ function TenantDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Outlet for nested routes */}
|
{/* Outlet for nested routes */}
|
||||||
<div className="animate-in fade-in duration-500">
|
<div>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
buildWorksmobilePasswordManageUrl,
|
buildWorksmobilePasswordManageUrl,
|
||||||
@@ -28,6 +30,18 @@ import {
|
|||||||
} from "./worksmobileComparison";
|
} from "./worksmobileComparison";
|
||||||
|
|
||||||
describe("TenantWorksmobilePage comparison helpers", () => {
|
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", () => {
|
it("summarizes comparison rows by status", () => {
|
||||||
const summary = summarizeWorksmobileComparison([
|
const summary = summarizeWorksmobileComparison([
|
||||||
{ resourceType: "USER", status: "matched" },
|
{ 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", () => {
|
it("formats WORKS account name with level on one line", () => {
|
||||||
expect(
|
expect(
|
||||||
formatWorksmobilePersonName({
|
formatWorksmobilePersonName({
|
||||||
|
|||||||
@@ -520,7 +520,7 @@ export function TenantWorksmobilePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeTab === "history" ? (
|
{activeTab === "history" ? (
|
||||||
<div className="space-y-4 animate-in fade-in duration-500">
|
<div className="space-y-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between gap-3">
|
<CardHeader className="flex flex-row items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -627,7 +627,7 @@ export function TenantWorksmobilePage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{activeTab === "users" ? (
|
{activeTab === "users" ? (
|
||||||
<div className="space-y-4 animate-in fade-in duration-500">
|
<div className="space-y-4">
|
||||||
<ComparisonSummary
|
<ComparisonSummary
|
||||||
title={t(
|
title={t(
|
||||||
"ui.admin.tenants.worksmobile.compare",
|
"ui.admin.tenants.worksmobile.compare",
|
||||||
@@ -715,7 +715,7 @@ export function TenantWorksmobilePage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{activeTab === "groups" ? (
|
{activeTab === "groups" ? (
|
||||||
<div className="space-y-4 animate-in fade-in duration-500">
|
<div className="space-y-4">
|
||||||
<ComparisonSummary
|
<ComparisonSummary
|
||||||
title={t(
|
title={t(
|
||||||
"ui.admin.tenants.worksmobile.compare_groups",
|
"ui.admin.tenants.worksmobile.compare_groups",
|
||||||
|
|||||||
@@ -374,28 +374,39 @@ export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const details: string[] = [];
|
const details: string[] = [];
|
||||||
|
const renderedReasons = new Set<string>();
|
||||||
|
const addDetail = (reason: string, detail: string) => {
|
||||||
|
details.push(detail);
|
||||||
|
renderedReasons.add(reason);
|
||||||
|
};
|
||||||
const baronName = row.baronName?.trim();
|
const baronName = row.baronName?.trim();
|
||||||
const worksmobileName = row.worksmobileName?.trim();
|
const worksmobileName = row.worksmobileName?.trim();
|
||||||
if (baronName && worksmobileName && baronName !== worksmobileName) {
|
if (baronName && worksmobileName && baronName !== worksmobileName) {
|
||||||
details.push(`이름: ${worksmobileName} -> ${baronName}`);
|
addDetail("name", `이름: ${worksmobileName} -> ${baronName}`);
|
||||||
}
|
}
|
||||||
if (row.resourceType === "USER") {
|
if (row.resourceType === "USER") {
|
||||||
const expectedExternalKey = row.baronId?.trim() ?? "";
|
const expectedExternalKey = row.baronId?.trim() ?? "";
|
||||||
const actualExternalKey = row.externalKey?.trim() ?? "";
|
const actualExternalKey = row.externalKey?.trim() ?? "";
|
||||||
if (expectedExternalKey && expectedExternalKey !== actualExternalKey) {
|
if (expectedExternalKey && expectedExternalKey !== actualExternalKey) {
|
||||||
details.push(
|
addDetail(
|
||||||
|
"external_key",
|
||||||
`external_key: ${actualExternalKey || "없음"} -> ${expectedExternalKey}`,
|
`external_key: ${actualExternalKey || "없음"} -> ${expectedExternalKey}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const expectedEmail = row.baronEmail?.trim().toLowerCase() ?? "";
|
const expectedEmail = row.baronEmail?.trim().toLowerCase() ?? "";
|
||||||
const actualEmail = row.worksmobileEmail?.trim().toLowerCase() ?? "";
|
const actualEmail = row.worksmobileEmail?.trim().toLowerCase() ?? "";
|
||||||
if (expectedEmail && actualEmail && expectedEmail !== actualEmail) {
|
if (expectedEmail && actualEmail && expectedEmail !== actualEmail) {
|
||||||
details.push(`이메일: ${actualEmail} -> ${expectedEmail}`);
|
addDetail("email", `이메일: ${actualEmail} -> ${expectedEmail}`);
|
||||||
}
|
}
|
||||||
const expectedPhone = row.baronPhone?.trim() ?? "";
|
const expectedPhone = row.baronPhone?.trim() ?? "";
|
||||||
const actualPhone = row.worksmobilePhone?.trim() ?? "";
|
const actualPhone = row.worksmobilePhone?.trim() ?? "";
|
||||||
if (expectedPhone && actualPhone && expectedPhone !== actualPhone) {
|
if (
|
||||||
details.push(`전화번호: ${actualPhone} -> ${expectedPhone}`);
|
expectedPhone &&
|
||||||
|
actualPhone &&
|
||||||
|
normalizeWorksmobilePhoneForCompare(expectedPhone) !==
|
||||||
|
normalizeWorksmobilePhoneForCompare(actualPhone)
|
||||||
|
) {
|
||||||
|
addDetail("phone", `전화번호: ${actualPhone} -> ${expectedPhone}`);
|
||||||
}
|
}
|
||||||
const expectedEmployeeNumber = row.baronEmployeeNumber?.trim() ?? "";
|
const expectedEmployeeNumber = row.baronEmployeeNumber?.trim() ?? "";
|
||||||
const actualEmployeeNumber = row.worksmobileEmployeeNumber?.trim() ?? "";
|
const actualEmployeeNumber = row.worksmobileEmployeeNumber?.trim() ?? "";
|
||||||
@@ -404,10 +415,12 @@ export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
|
|||||||
actualEmployeeNumber &&
|
actualEmployeeNumber &&
|
||||||
expectedEmployeeNumber !== actualEmployeeNumber
|
expectedEmployeeNumber !== actualEmployeeNumber
|
||||||
) {
|
) {
|
||||||
details.push(
|
addDetail(
|
||||||
|
"employee_number",
|
||||||
`사번: ${actualEmployeeNumber} -> ${expectedEmployeeNumber}`,
|
`사번: ${actualEmployeeNumber} -> ${expectedEmployeeNumber}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
appendWorksmobileUpdateReasonFallbacks(details, row, renderedReasons);
|
||||||
return details;
|
return details;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,14 +440,86 @@ export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
|
|||||||
const actualParentKey =
|
const actualParentKey =
|
||||||
row.worksmobileParentId ?? row.worksmobileParentExternalKey ?? "";
|
row.worksmobileParentId ?? row.worksmobileParentExternalKey ?? "";
|
||||||
if (expectedParentKey !== actualParentKey) {
|
if (expectedParentKey !== actualParentKey) {
|
||||||
details.push(
|
addDetail(
|
||||||
|
"organization",
|
||||||
`상위: ${actualParent || "없음"} -> ${expectedParent || "없음"}`,
|
`상위: ${actualParent || "없음"} -> ${expectedParent || "없음"}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
appendWorksmobileUpdateReasonFallbacks(details, row, renderedReasons);
|
||||||
return details;
|
return details;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appendWorksmobileUpdateReasonFallbacks(
|
||||||
|
details: string[],
|
||||||
|
row: WorksmobileComparisonItem,
|
||||||
|
renderedReasons: Set<string>,
|
||||||
|
) {
|
||||||
|
for (const reason of row.updateReasons ?? []) {
|
||||||
|
const normalizedReason = reason.trim();
|
||||||
|
if (!normalizedReason || renderedReasons.has(normalizedReason)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const detail = formatWorksmobileUpdateReasonFallback(normalizedReason, row);
|
||||||
|
if (!detail) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
details.push(detail);
|
||||||
|
renderedReasons.add(normalizedReason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWorksmobileUpdateReasonFallback(
|
||||||
|
reason: string,
|
||||||
|
row: WorksmobileComparisonItem,
|
||||||
|
) {
|
||||||
|
switch (reason) {
|
||||||
|
case "name":
|
||||||
|
return "이름: Baron 사용자명을 WORKS에 반영해야 합니다.";
|
||||||
|
case "external_key":
|
||||||
|
return "external_key: Baron 사용자 ID를 WORKS 외부 키로 반영해야 합니다.";
|
||||||
|
case "email":
|
||||||
|
return "이메일: Baron 이메일을 WORKS에 반영해야 합니다.";
|
||||||
|
case "phone":
|
||||||
|
return "전화번호: Baron 전화번호를 WORKS에 반영해야 합니다.";
|
||||||
|
case "employee_number":
|
||||||
|
return "사번: Baron 사번을 WORKS에 반영해야 합니다.";
|
||||||
|
case "organization":
|
||||||
|
return row.resourceType === "GROUP"
|
||||||
|
? "조직: Baron 조직 정보를 WORKS에 반영해야 합니다."
|
||||||
|
: "조직: Baron 소속 정보를 WORKS에 반영해야 합니다.";
|
||||||
|
case "manager":
|
||||||
|
return "조직장: Baron 조직장 설정을 WORKS에 반영해야 합니다.";
|
||||||
|
default:
|
||||||
|
return `업데이트 사유: ${reason}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWorksmobilePhoneForCompare(value: string) {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const digits = trimmed.replace(/\D/g, "");
|
||||||
|
if (!digits) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (digits.startsWith("010")) {
|
||||||
|
return `+82${digits.slice(1)}`;
|
||||||
|
}
|
||||||
|
if (digits.startsWith("82")) {
|
||||||
|
let rest = digits.slice(2);
|
||||||
|
while (rest.startsWith("82")) {
|
||||||
|
rest = rest.slice(2);
|
||||||
|
}
|
||||||
|
if (rest.startsWith("0")) {
|
||||||
|
rest = rest.slice(1);
|
||||||
|
}
|
||||||
|
return `+82${rest}`;
|
||||||
|
}
|
||||||
|
return `+${digits}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildWorksmobilePasswordManageUrl({
|
export function buildWorksmobilePasswordManageUrl({
|
||||||
tenantId,
|
tenantId,
|
||||||
domainId,
|
domainId,
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import {
|
|||||||
type OrgChartTenantSelection,
|
type OrgChartTenantSelection,
|
||||||
parseOrgChartTenantSelection,
|
parseOrgChartTenantSelection,
|
||||||
} from "./orgChartPicker";
|
} from "./orgChartPicker";
|
||||||
|
import { formatUserPolicyMessage } from "./userPolicyMessages";
|
||||||
import type { UserSchemaField } from "./userSchemaFields";
|
import type { UserSchemaField } from "./userSchemaFields";
|
||||||
import { resolvePersonalTenant } from "./utils/personalTenant";
|
import { resolvePersonalTenant } from "./utils/personalTenant";
|
||||||
|
|
||||||
@@ -399,7 +400,7 @@ function UserCreatePage() {
|
|||||||
},
|
},
|
||||||
onError: (err: AxiosError<{ error?: string }>) => {
|
onError: (err: AxiosError<{ error?: string }>) => {
|
||||||
setError(
|
setError(
|
||||||
err.response?.data?.error ||
|
formatUserPolicyMessage(err.response?.data?.error) ||
|
||||||
t("msg.admin.users.create.error", "사용자 생성에 실패했습니다."),
|
t("msg.admin.users.create.error", "사용자 생성에 실패했습니다."),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -943,8 +944,12 @@ function UserCreatePage() {
|
|||||||
data-testid={`appointment-tenant-picker-${index}`}
|
data-testid={`appointment-tenant-picker-${index}`}
|
||||||
>
|
>
|
||||||
<Building2 className="mr-2 h-4 w-4 shrink-0" />
|
<Building2 className="mr-2 h-4 w-4 shrink-0" />
|
||||||
<span className="truncate">
|
<span className="pointer-events-none truncate">
|
||||||
{appointment.tenantName || "테넌트 선택"}
|
{appointment.tenantName ||
|
||||||
|
t(
|
||||||
|
"ui.admin.users.create.form.pick_from_hanmac_family",
|
||||||
|
"한맥가족에서 선택",
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
{appointment.tenantSlug && (
|
{appointment.tenantSlug && (
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ import {
|
|||||||
type OrgChartTenantSelection,
|
type OrgChartTenantSelection,
|
||||||
parseOrgChartTenantSelection,
|
parseOrgChartTenantSelection,
|
||||||
} from "./orgChartPicker";
|
} from "./orgChartPicker";
|
||||||
|
import { formatUserPolicyMessage } from "./userPolicyMessages";
|
||||||
import type { UserSchemaField } from "./userSchemaFields";
|
import type { UserSchemaField } from "./userSchemaFields";
|
||||||
import {
|
import {
|
||||||
normalizeUserStatusValue,
|
normalizeUserStatusValue,
|
||||||
@@ -1012,7 +1013,7 @@ function UserDetailPage() {
|
|||||||
},
|
},
|
||||||
onError: (err: AxiosError<{ error?: string }>) => {
|
onError: (err: AxiosError<{ error?: string }>) => {
|
||||||
toast.error(
|
toast.error(
|
||||||
err.response?.data?.error ||
|
formatUserPolicyMessage(err.response?.data?.error) ||
|
||||||
t("err.common.unknown", "오류가 발생했습니다."),
|
t("err.common.unknown", "오류가 발생했습니다."),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1078,6 +1079,7 @@ function UserDetailPage() {
|
|||||||
try {
|
try {
|
||||||
const tenant = await ensurePersonalTenant();
|
const tenant = await ensurePersonalTenant();
|
||||||
payload.tenantSlug = tenant.slug;
|
payload.tenantSlug = tenant.slug;
|
||||||
|
payload.isPrimaryTenant = true;
|
||||||
payload.department = undefined;
|
payload.department = undefined;
|
||||||
payload.grade = undefined;
|
payload.grade = undefined;
|
||||||
payload.position = undefined;
|
payload.position = undefined;
|
||||||
@@ -1111,6 +1113,7 @@ function UserDetailPage() {
|
|||||||
const primary = appointments.find((a) => a.isPrimary);
|
const primary = appointments.find((a) => a.isPrimary);
|
||||||
if (primary) {
|
if (primary) {
|
||||||
payload.tenantSlug = primary.tenantSlug;
|
payload.tenantSlug = primary.tenantSlug;
|
||||||
|
payload.isPrimaryTenant = true;
|
||||||
payload.primaryTenantId = primary.tenantId;
|
payload.primaryTenantId = primary.tenantId;
|
||||||
payload.primaryTenantName = primary.tenantName;
|
payload.primaryTenantName = primary.tenantName;
|
||||||
metadata.primaryTenantId = primary.tenantId;
|
metadata.primaryTenantId = primary.tenantId;
|
||||||
@@ -1133,6 +1136,7 @@ function UserDetailPage() {
|
|||||||
primaryTenantSlug: primary?.tenantSlug,
|
primaryTenantSlug: primary?.tenantSlug,
|
||||||
};
|
};
|
||||||
payload.tenantSlug = primary?.tenantSlug;
|
payload.tenantSlug = primary?.tenantSlug;
|
||||||
|
payload.isPrimaryTenant = primary ? true : undefined;
|
||||||
payload.primaryTenantId = primary?.tenantId;
|
payload.primaryTenantId = primary?.tenantId;
|
||||||
payload.primaryTenantName = primary?.tenantName;
|
payload.primaryTenantName = primary?.tenantName;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,6 +139,19 @@ describe("UserListPage search rendering", () => {
|
|||||||
expect(selectRenderCounter.count).toBe(renderCountBeforeTyping);
|
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 () => {
|
it("keeps rendered row controls below the full 200-user result set", async () => {
|
||||||
renderUserListPage();
|
renderUserListPage();
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ import {
|
|||||||
downloadUserTemplate,
|
downloadUserTemplate,
|
||||||
UserBulkUploadModal,
|
UserBulkUploadModal,
|
||||||
} from "./components/UserBulkUploadModal";
|
} from "./components/UserBulkUploadModal";
|
||||||
|
import { formatUserPolicyMessage } from "./userPolicyMessages";
|
||||||
import {
|
import {
|
||||||
normalizeUserStatusValue,
|
normalizeUserStatusValue,
|
||||||
type UserStatusValue,
|
type UserStatusValue,
|
||||||
@@ -652,7 +653,21 @@ function UserListPage() {
|
|||||||
|
|
||||||
const bulkUpdateMutation = useMutation({
|
const bulkUpdateMutation = useMutation({
|
||||||
mutationFn: bulkUpdateUsers,
|
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();
|
query.refetch();
|
||||||
setSelectedUserIds([]);
|
setSelectedUserIds([]);
|
||||||
setSelectedBulkStatus("");
|
setSelectedBulkStatus("");
|
||||||
@@ -725,7 +740,7 @@ function UserListPage() {
|
|||||||
}
|
}
|
||||||
description={t(
|
description={t(
|
||||||
"msg.admin.users.list.subtitle",
|
"msg.admin.users.list.subtitle",
|
||||||
"시스템 사용자를 조회하고 관리합니다.",
|
"Kratos identity mirror 기준으로 시스템 사용자를 조회하고 관리합니다.",
|
||||||
)}
|
)}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
type TenantImportPreviewRow,
|
type TenantImportPreviewRow,
|
||||||
} from "../../tenants/utils/tenantCsvImport";
|
} from "../../tenants/utils/tenantCsvImport";
|
||||||
import { isHanmacFamilyTenant, isHanmacFamilyUser } from "../orgChartPicker";
|
import { isHanmacFamilyTenant, isHanmacFamilyUser } from "../orgChartPicker";
|
||||||
|
import { formatUserPolicyMessage } from "../userPolicyMessages";
|
||||||
import { parseUserCSV } from "../utils/csvParser";
|
import { parseUserCSV } from "../utils/csvParser";
|
||||||
import { applyGeneralPlanningOfficePriority } from "../utils/generalPlanningOfficePriority";
|
import { applyGeneralPlanningOfficePriority } from "../utils/generalPlanningOfficePriority";
|
||||||
import {
|
import {
|
||||||
@@ -768,7 +769,7 @@ export function UserBulkUploadModal({
|
|||||||
)}
|
)}
|
||||||
{!r.success && (
|
{!r.success && (
|
||||||
<div className="text-xs text-destructive">
|
<div className="text-xs text-destructive">
|
||||||
{r.message}
|
{formatUserPolicyMessage(r.message)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -51,6 +51,16 @@ describe("orgChartPicker", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back to the orgfront development origin for authenticated picker URLs", () => {
|
||||||
|
expect(
|
||||||
|
buildAuthenticatedOrgChartTenantPickerUrl(undefined, {
|
||||||
|
tenantId: "hanmac-family-id",
|
||||||
|
}),
|
||||||
|
).toBe(
|
||||||
|
"http://localhost:5175/login?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dsingle%26select%3Dtenant%26width%3D400%26height%3D600%26tenantId%3Dhanmac-family-id%26includeInternal%3Dtrue",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("builds an authenticated multi picker URL for tenant member selection", () => {
|
it("builds an authenticated multi picker URL for tenant member selection", () => {
|
||||||
expect(
|
expect(
|
||||||
buildAuthenticatedOrgChartUserMultiPickerUrl(
|
buildAuthenticatedOrgChartUserMultiPickerUrl(
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ type OrgChartLoginOptions = {
|
|||||||
returnTo?: string;
|
returnTo?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DEFAULT_ORGFRONT_BASE_URL = "http://localhost:5175";
|
||||||
|
|
||||||
export const GPDTDC_GRADE_OPTIONS = [
|
export const GPDTDC_GRADE_OPTIONS = [
|
||||||
"연구원",
|
"연구원",
|
||||||
"선임",
|
"선임",
|
||||||
@@ -348,7 +350,8 @@ export function buildAuthenticatedOrgChartUrl(
|
|||||||
baseUrl?: string,
|
baseUrl?: string,
|
||||||
options: OrgChartLoginOptions = { includeInternal: true },
|
options: OrgChartLoginOptions = { includeInternal: true },
|
||||||
) {
|
) {
|
||||||
const normalizedBase = (baseUrl ?? "").replace(/\/+$/, "");
|
const normalizedBase =
|
||||||
|
baseUrl?.trim().replace(/\/+$/, "") || DEFAULT_ORGFRONT_BASE_URL;
|
||||||
let returnTo = options.returnTo?.trim() || "/chart";
|
let returnTo = options.returnTo?.trim() || "/chart";
|
||||||
if (options.includeInternal && returnTo.startsWith("/chart")) {
|
if (options.includeInternal && returnTo.startsWith("/chart")) {
|
||||||
const [path, query = ""] = returnTo.split("?", 2);
|
const [path, query = ""] = returnTo.split("?", 2);
|
||||||
|
|||||||
20
adminfront/src/features/users/userPolicyMessages.ts
Normal file
20
adminfront/src/features/users/userPolicyMessages.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
const INTERNAL_DOMAIN_PERSONAL_POLICY_PATTERNS = [
|
||||||
|
"internal email domain cannot be assigned to personal tenant",
|
||||||
|
"내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function formatUserPolicyMessage(message?: string | null) {
|
||||||
|
const raw = String(message ?? "").trim();
|
||||||
|
if (!raw) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const normalized = raw.toLowerCase();
|
||||||
|
if (
|
||||||
|
INTERNAL_DOMAIN_PERSONAL_POLICY_PATTERNS.some((pattern) =>
|
||||||
|
normalized.includes(pattern.toLowerCase()),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return "내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다. 대표소속을 회사 또는 조직 소속으로 지정해 주세요.";
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
@@ -36,11 +36,17 @@ describe("adminApi user tenant payloads", () => {
|
|||||||
const { updateUser } = await import("./adminApi");
|
const { updateUser } = await import("./adminApi");
|
||||||
apiClient.put.mockResolvedValue({ data: {} });
|
apiClient.put.mockResolvedValue({ data: {} });
|
||||||
|
|
||||||
await updateUser("user-id", { tenantSlug: "new-tenant" });
|
await updateUser("user-id", {
|
||||||
|
tenantSlug: "new-tenant",
|
||||||
|
isPrimaryTenant: true,
|
||||||
|
});
|
||||||
|
|
||||||
expect(apiClient.put).toHaveBeenCalledWith(
|
expect(apiClient.put).toHaveBeenCalledWith(
|
||||||
"/v1/admin/users/user-id",
|
"/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");
|
expect(apiClient.put.mock.calls[0][1]).not.toHaveProperty("companyCode");
|
||||||
});
|
});
|
||||||
@@ -61,6 +67,7 @@ describe("adminApi user tenant payloads", () => {
|
|||||||
await bulkUpdateUsers({
|
await bulkUpdateUsers({
|
||||||
userIds: ["user-id"],
|
userIds: ["user-id"],
|
||||||
tenantSlug: "new-tenant",
|
tenantSlug: "new-tenant",
|
||||||
|
isPrimaryTenant: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(apiClient.post.mock.calls[0][1].users[0]).toMatchObject({
|
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({
|
expect(apiClient.put.mock.calls[0][1]).toMatchObject({
|
||||||
tenantSlug: "new-tenant",
|
tenantSlug: "new-tenant",
|
||||||
|
isPrimaryTenant: true,
|
||||||
});
|
});
|
||||||
expect(apiClient.put.mock.calls[0][1]).not.toHaveProperty("companyCode");
|
expect(apiClient.put.mock.calls[0][1]).not.toHaveProperty("companyCode");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -146,16 +146,6 @@ export type AdminOverviewStats = {
|
|||||||
auditEvents24h: number;
|
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 = {
|
export type IdentityCacheStatus = {
|
||||||
status: string;
|
status: string;
|
||||||
redisReady: boolean;
|
redisReady: boolean;
|
||||||
@@ -270,13 +260,6 @@ export async function deleteOrphanUserLoginIDs(ids: string[]) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchUserProjectionStatus() {
|
|
||||||
const { data } = await apiClient.get<UserProjectionStatus>(
|
|
||||||
"/v1/admin/projections/users",
|
|
||||||
);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchOrySSOTSystemStatus() {
|
export async function fetchOrySSOTSystemStatus() {
|
||||||
const { data } =
|
const { data } =
|
||||||
await apiClient.get<OrySSOTSystemStatus>("/v1/admin/ory/ssot");
|
await apiClient.get<OrySSOTSystemStatus>("/v1/admin/ory/ssot");
|
||||||
@@ -718,6 +701,7 @@ export type UserUpdateRequest = {
|
|||||||
role?: string;
|
role?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
tenantSlug?: string;
|
tenantSlug?: string;
|
||||||
|
isPrimaryTenant?: boolean;
|
||||||
isAddTenant?: boolean;
|
isAddTenant?: boolean;
|
||||||
isRemoveTenant?: boolean;
|
isRemoveTenant?: boolean;
|
||||||
department?: string;
|
department?: string;
|
||||||
@@ -920,6 +904,7 @@ export type WorksmobileComparisonItem = {
|
|||||||
worksmobileJobRetryCount?: number;
|
worksmobileJobRetryCount?: number;
|
||||||
worksmobileLastError?: string;
|
worksmobileLastError?: string;
|
||||||
worksmobileLastAttemptAt?: string;
|
worksmobileLastAttemptAt?: string;
|
||||||
|
updateReasons?: string[];
|
||||||
status: string;
|
status: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1144,13 +1129,17 @@ export async function bulkUpdateUsers(payload: {
|
|||||||
status?: string;
|
status?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
tenantSlug?: string;
|
tenantSlug?: string;
|
||||||
|
isPrimaryTenant?: boolean;
|
||||||
isAddTenant?: boolean;
|
isAddTenant?: boolean;
|
||||||
department?: string;
|
department?: string;
|
||||||
position?: string;
|
position?: string;
|
||||||
grade?: string;
|
grade?: string;
|
||||||
jobTitle?: string;
|
jobTitle?: string;
|
||||||
}) {
|
}) {
|
||||||
const { data } = await apiClient.put("/v1/admin/users/bulk", payload);
|
const { data } = await apiClient.put<BulkUserResponse>(
|
||||||
|
"/v1/admin/users/bulk",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import {
|
|||||||
buildCommonOidcRuntimeConfig,
|
buildCommonOidcRuntimeConfig,
|
||||||
buildCommonUserManagerSettings,
|
buildCommonUserManagerSettings,
|
||||||
} from "../../../common/core/auth";
|
} from "../../../common/core/auth";
|
||||||
import { resolveAdminPublicOrigin } from "./authConfig";
|
import {
|
||||||
|
resolveAdminOidcAuthority,
|
||||||
|
resolveAdminPublicOrigin,
|
||||||
|
} from "./authConfig";
|
||||||
|
|
||||||
const adminPublicOrigin = resolveAdminPublicOrigin(
|
const adminPublicOrigin = resolveAdminPublicOrigin(
|
||||||
import.meta.env.VITE_ADMIN_PUBLIC_URL,
|
import.meta.env.VITE_ADMIN_PUBLIC_URL,
|
||||||
@@ -12,7 +15,10 @@ const adminPublicOrigin = resolveAdminPublicOrigin(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const oidcConfig: AuthProviderProps = buildCommonOidcRuntimeConfig({
|
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",
|
clientId: import.meta.env.VITE_OIDC_CLIENT_ID || "adminfront",
|
||||||
origin: adminPublicOrigin,
|
origin: adminPublicOrigin,
|
||||||
userStore: new WebStorageStateStore({ store: window.localStorage }),
|
userStore: new WebStorageStateStore({ store: window.localStorage }),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
buildAdminAuthRedirectUris,
|
buildAdminAuthRedirectUris,
|
||||||
canStartBrowserPkceLogin,
|
canStartBrowserPkceLogin,
|
||||||
|
resolveAdminOidcAuthority,
|
||||||
resolveAdminPublicOrigin,
|
resolveAdminPublicOrigin,
|
||||||
} from "./authConfig";
|
} 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", () => {
|
it("blocks browser PKCE login when WebCrypto is unavailable", () => {
|
||||||
expect(
|
expect(
|
||||||
canStartBrowserPkceLogin({
|
canStartBrowserPkceLogin({
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export interface AdminAuthRedirectUris {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ADMIN_AUTH_CALLBACK_PATH = "/auth/callback";
|
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(
|
export function resolveAdminPublicOrigin(
|
||||||
configuredOrigin: string | undefined,
|
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({
|
export function canStartBrowserPkceLogin({
|
||||||
isSecureContext = window.isSecureContext,
|
isSecureContext = window.isSecureContext,
|
||||||
origin = window.location.origin,
|
origin = window.location.origin,
|
||||||
|
|||||||
@@ -313,6 +313,7 @@ move_description = "Bulk move selected users to another tenant."
|
|||||||
move_error = "Error moving users."
|
move_error = "Error moving users."
|
||||||
move_success = "{{count}} users moved successfully."
|
move_success = "{{count}} users moved successfully."
|
||||||
parsed_count = "Parsed {{count}} rows."
|
parsed_count = "Parsed {{count}} rows."
|
||||||
|
update_partial_error = "Failed to update {{count}} users."
|
||||||
update_success = "User info updated successfully."
|
update_success = "User info updated successfully."
|
||||||
|
|
||||||
[msg.admin.users.create]
|
[msg.admin.users.create]
|
||||||
|
|||||||
@@ -317,6 +317,7 @@ move_description = "선택한 사용자를 다른 테넌트로 일괄 이동합
|
|||||||
move_error = "사용자 이동 중 오류가 발생했습니다."
|
move_error = "사용자 이동 중 오류가 발생했습니다."
|
||||||
move_success = "{{count}}명의 사용자가 성공적으로 이동되었습니다."
|
move_success = "{{count}}명의 사용자가 성공적으로 이동되었습니다."
|
||||||
parsed_count = "{{count}}행의 데이터가 파싱되었습니다."
|
parsed_count = "{{count}}행의 데이터가 파싱되었습니다."
|
||||||
|
update_partial_error = "{{count}}명의 사용자 정보 수정에 실패했습니다."
|
||||||
update_success = "사용자 정보가 일괄 업데이트되었습니다."
|
update_success = "사용자 정보가 일괄 업데이트되었습니다."
|
||||||
|
|
||||||
[msg.admin.users.create]
|
[msg.admin.users.create]
|
||||||
@@ -378,7 +379,7 @@ self_password_reset_blocked = "본인 계정의 비밀번호는 사용자 포털
|
|||||||
delete_confirm = "사용자 \"{{name}}\"을(를) 정말 삭제하시겠습니까?"
|
delete_confirm = "사용자 \"{{name}}\"을(를) 정말 삭제하시겠습니까?"
|
||||||
empty = "검색 결과가 없습니다."
|
empty = "검색 결과가 없습니다."
|
||||||
fetch_error = "사용자 목록 조회에 실패했습니다."
|
fetch_error = "사용자 목록 조회에 실패했습니다."
|
||||||
subtitle = "시스템 사용자를 조회하고 관리합니다. (Local DB)"
|
subtitle = "Kratos identity mirror 기준으로 시스템 사용자를 조회하고 관리합니다."
|
||||||
|
|
||||||
[msg.admin.users.list.columns]
|
[msg.admin.users.list.columns]
|
||||||
description = "테이블에 표시할 컬럼을 선택합니다."
|
description = "테이블에 표시할 컬럼을 선택합니다."
|
||||||
|
|||||||
@@ -335,6 +335,7 @@ move_description = ""
|
|||||||
move_error = ""
|
move_error = ""
|
||||||
move_success = ""
|
move_success = ""
|
||||||
parsed_count = ""
|
parsed_count = ""
|
||||||
|
update_partial_error = ""
|
||||||
update_success = ""
|
update_success = ""
|
||||||
|
|
||||||
[msg.admin.users.create]
|
[msg.admin.users.create]
|
||||||
|
|||||||
@@ -77,7 +77,8 @@ const translations: Record<"ko" | "en", Record<string, string>> = {
|
|||||||
"Ory SSOT 시스템 상태를 불러오지 못했습니다.",
|
"Ory SSOT 시스템 상태를 불러오지 못했습니다.",
|
||||||
"msg.admin.ory_ssot.subtitle":
|
"msg.admin.ory_ssot.subtitle":
|
||||||
"Kratos 원장과 Redis identity cache 상태를 분리해서 확인합니다.",
|
"Kratos 원장과 Redis identity cache 상태를 분리해서 확인합니다.",
|
||||||
"msg.admin.users.list.subtitle": "시스템 사용자를 조회하고 관리합니다.",
|
"msg.admin.users.list.subtitle":
|
||||||
|
"Kratos identity mirror 기준으로 시스템 사용자를 조회하고 관리합니다.",
|
||||||
"msg.admin.users.list.registry.count":
|
"msg.admin.users.list.registry.count":
|
||||||
"총 {{count}}명의 사용자가 등록되어 있습니다.",
|
"총 {{count}}명의 사용자가 등록되어 있습니다.",
|
||||||
"msg.admin.integrity.check.duplicate_tenant_slugs.description":
|
"msg.admin.integrity.check.duplicate_tenant_slugs.description":
|
||||||
@@ -168,7 +169,7 @@ const translations: Record<"ko" | "en", Record<string, string>> = {
|
|||||||
"msg.admin.ory_ssot.subtitle":
|
"msg.admin.ory_ssot.subtitle":
|
||||||
"Review Kratos source-of-truth and Redis identity cache status separately.",
|
"Review Kratos source-of-truth and Redis identity cache status separately.",
|
||||||
"msg.admin.users.list.subtitle":
|
"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.users.list.registry.count": "{{count}} users loaded.",
|
||||||
"msg.admin.integrity.check.duplicate_tenant_slugs.description":
|
"msg.admin.integrity.check.duplicate_tenant_slugs.description":
|
||||||
"Checks duplicate active tenant slugs using LOWER(TRIM(slug)).",
|
"Checks duplicate active tenant slugs using LOWER(TRIM(slug)).",
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { installAdminFrontStaticRoutes } from "./helpers/static-adminfront";
|
||||||
|
|
||||||
test.describe("Bulk Actions and Tree Search", () => {
|
test.describe("Bulk Actions and Tree Search", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await installAdminFrontStaticRoutes(page);
|
||||||
|
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
window.localStorage.setItem("locale", "ko");
|
window.localStorage.setItem("locale", "ko");
|
||||||
window.localStorage.setItem("admin_session", "fake-token");
|
window.localStorage.setItem("admin_session", "fake-token");
|
||||||
@@ -196,6 +199,55 @@ test.describe("Bulk Actions and Tree Search", () => {
|
|||||||
await expect(selectionBar).not.toBeVisible({ timeout: 10000 });
|
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 ({
|
test("should let super admins apply selected admin permission to selected users", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -925,6 +925,17 @@ test.describe("Tenants Management", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
page.getByRole("button", { name: "다른 테넌트 선택" }),
|
page.getByRole("button", { name: "다른 테넌트 선택" }),
|
||||||
).toBeVisible();
|
).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
|
const parentLabelTop = await page
|
||||||
.getByText(/상위 테넌트/)
|
.getByText(/상위 테넌트/)
|
||||||
.first()
|
.first()
|
||||||
@@ -1945,6 +1956,22 @@ test.describe("Tenants Management", () => {
|
|||||||
expect(topColumns.split(" ").length).toBe(3);
|
expect(topColumns.split(" ").length).toBe(3);
|
||||||
expect(configColumns.split(" ").length).toBe(4);
|
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
|
const nameTop = await page
|
||||||
.getByTestId("tenant-name-slot")
|
.getByTestId("tenant-name-slot")
|
||||||
.evaluate((element) => element.getBoundingClientRect().top);
|
.evaluate((element) => element.getBoundingClientRect().top);
|
||||||
|
|||||||
@@ -689,6 +689,37 @@ test.describe("User Management", () => {
|
|||||||
await expect(page).toHaveURL(/.*\/users$/, { timeout: 10000 });
|
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 ({
|
test("should export users through the authenticated API client", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -1032,6 +1063,43 @@ test.describe("User Management", () => {
|
|||||||
expect(createPayload).toBeUndefined();
|
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 ({
|
test("should hide Hanmac family subtree and system tenants when creating a non-family user", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { installAdminFrontStaticRoutes } from "./helpers/static-adminfront";
|
||||||
|
|
||||||
test.describe("Users Bulk Upload", () => {
|
test.describe("Users Bulk Upload", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await installAdminFrontStaticRoutes(page);
|
||||||
|
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
window.localStorage.setItem("locale", "ko");
|
window.localStorage.setItem("locale", "ko");
|
||||||
window.localStorage.setItem("admin_session", "fake-token");
|
window.localStorage.setItem("admin_session", "fake-token");
|
||||||
@@ -117,6 +120,56 @@ test.describe("Users Bulk Upload", () => {
|
|||||||
await expect(uploadBtn).toBeDisabled();
|
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 ({
|
test("should create missing tenant before user bulk import", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -2,15 +2,18 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"baron-sso-backend/internal/bootstrap"
|
"baron-sso-backend/internal/bootstrap"
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
"baron-sso-backend/internal/idp"
|
"baron-sso-backend/internal/idp"
|
||||||
"baron-sso-backend/internal/logger"
|
"baron-sso-backend/internal/logger"
|
||||||
"baron-sso-backend/internal/repository"
|
"baron-sso-backend/internal/repository"
|
||||||
"baron-sso-backend/internal/service"
|
"baron-sso-backend/internal/service"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"maps"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -32,6 +35,16 @@ type clearOrphanUserTenantMembershipsConfig struct {
|
|||||||
DryRun bool
|
DryRun bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type repairDeletedTenantIdentitiesConfig struct {
|
||||||
|
DryRun bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type repairUserTenantConfig struct {
|
||||||
|
UserID string
|
||||||
|
TenantSlug string
|
||||||
|
RemoveTenantSlug string
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
loadEnv()
|
loadEnv()
|
||||||
logger.Init(logger.Config{
|
logger.Init(logger.Config{
|
||||||
@@ -56,6 +69,16 @@ func main() {
|
|||||||
slog.Error("clear-orphan-user-tenant-memberships failed", "error", err)
|
slog.Error("clear-orphan-user-tenant-memberships failed", "error", err)
|
||||||
os.Exit(1)
|
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":
|
case "worksmobile-sync":
|
||||||
if err := runWorksmobileSync(os.Args[2:]); err != nil {
|
if err := runWorksmobileSync(os.Args[2:]); err != nil {
|
||||||
slog.Error("worksmobile-sync failed", "error", err)
|
slog.Error("worksmobile-sync failed", "error", err)
|
||||||
@@ -121,6 +144,69 @@ func runCreateSuperAdmin(args []string) error {
|
|||||||
return nil
|
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 {
|
func runClearOrphanUserTenantMemberships(args []string) error {
|
||||||
config, err := resolveClearOrphanUserTenantMembershipsConfig(args)
|
config, err := resolveClearOrphanUserTenantMembershipsConfig(args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -152,6 +238,92 @@ func runClearOrphanUserTenantMemberships(args []string) error {
|
|||||||
return nil
|
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) {
|
func resolveCreateSuperAdminConfig(args []string) (createSuperAdminConfig, error) {
|
||||||
fs := flag.NewFlagSet("create-super-admin", flag.ContinueOnError)
|
fs := flag.NewFlagSet("create-super-admin", flag.ContinueOnError)
|
||||||
fs.SetOutput(os.Stderr)
|
fs.SetOutput(os.Stderr)
|
||||||
@@ -193,6 +365,294 @@ func resolveClearOrphanUserTenantMembershipsConfig(args []string) (clearOrphanUs
|
|||||||
return config, nil
|
return config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolveRepairDeletedTenantIdentitiesConfig(args []string) (repairDeletedTenantIdentitiesConfig, error) {
|
||||||
|
fs := flag.NewFlagSet("repair-deleted-tenant-identities", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(os.Stderr)
|
||||||
|
|
||||||
|
config := repairDeletedTenantIdentitiesConfig{}
|
||||||
|
fs.BoolVar(&config.DryRun, "dry-run", false, "print identities that reference deleted tenants without updating Kratos or local DB")
|
||||||
|
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return config, err
|
||||||
|
}
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveRepairUserTenantConfig(args []string) (repairUserTenantConfig, error) {
|
||||||
|
fs := flag.NewFlagSet("repair-user-tenant", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(os.Stderr)
|
||||||
|
|
||||||
|
config := repairUserTenantConfig{}
|
||||||
|
fs.StringVar(&config.UserID, "user-id", "", "identity/user id to repair")
|
||||||
|
fs.StringVar(&config.TenantSlug, "tenant-slug", "", "target representative tenant slug")
|
||||||
|
fs.StringVar(&config.RemoveTenantSlug, "remove-tenant-slug", "", "appointment tenant slug to remove")
|
||||||
|
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return config, err
|
||||||
|
}
|
||||||
|
config.UserID = strings.TrimSpace(config.UserID)
|
||||||
|
config.TenantSlug = strings.TrimSpace(config.TenantSlug)
|
||||||
|
config.RemoveTenantSlug = strings.TrimSpace(config.RemoveTenantSlug)
|
||||||
|
if config.UserID == "" {
|
||||||
|
return config, fmt.Errorf("--user-id is required")
|
||||||
|
}
|
||||||
|
if config.TenantSlug == "" {
|
||||||
|
return config, fmt.Errorf("--tenant-slug is required")
|
||||||
|
}
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminctlSetPrimaryTenantTraits(traits map[string]any, target domain.Tenant, removeTenant *domain.Tenant) {
|
||||||
|
traits["tenant_id"] = target.ID
|
||||||
|
traits["primaryTenantId"] = target.ID
|
||||||
|
traits["primaryTenantSlug"] = target.Slug
|
||||||
|
traits["primaryTenantName"] = target.Name
|
||||||
|
delete(traits, "companyCode")
|
||||||
|
delete(traits, "companyCodes")
|
||||||
|
|
||||||
|
rawAppointments, _ := adminctlPromoteIdentityAppointments(traits["additionalAppointments"], target, target)
|
||||||
|
if rawAppointments == nil {
|
||||||
|
rawAppointments = []any{}
|
||||||
|
}
|
||||||
|
next := make([]any, 0, len(rawAppointments)+1)
|
||||||
|
targetSeen := false
|
||||||
|
for _, raw := range rawAppointments {
|
||||||
|
appointment, ok := raw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
next = append(next, raw)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if removeTenant != nil && adminctlAppointmentMatchesTenant(appointment, *removeTenant) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
copied := maps.Clone(appointment)
|
||||||
|
if adminctlAppointmentMatchesTenant(copied, target) {
|
||||||
|
copied["tenantId"] = target.ID
|
||||||
|
copied["tenantSlug"] = target.Slug
|
||||||
|
copied["tenantName"] = target.Name
|
||||||
|
copied["isPrimary"] = true
|
||||||
|
targetSeen = true
|
||||||
|
} else {
|
||||||
|
copied["isPrimary"] = false
|
||||||
|
}
|
||||||
|
next = append(next, copied)
|
||||||
|
}
|
||||||
|
if !targetSeen {
|
||||||
|
next = append(next, map[string]any{
|
||||||
|
"tenantId": target.ID,
|
||||||
|
"tenantSlug": target.Slug,
|
||||||
|
"tenantName": target.Name,
|
||||||
|
"isPrimary": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
traits["additionalAppointments"] = next
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminctlAppointmentMatchesTenant(appointment map[string]any, tenant domain.Tenant) bool {
|
||||||
|
return adminctlTraitMatchesTenant(appointment["tenantId"], tenant) ||
|
||||||
|
adminctlTraitMatchesTenant(appointment["tenantSlug"], tenant)
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminctlTenantIndexes(tenants []domain.Tenant) (map[string]domain.Tenant, map[string]domain.Tenant) {
|
||||||
|
tenantByID := make(map[string]domain.Tenant, len(tenants))
|
||||||
|
deletedBySlug := map[string]domain.Tenant{}
|
||||||
|
for _, tenant := range tenants {
|
||||||
|
tenantByID[tenant.ID] = tenant
|
||||||
|
if tenant.DeletedAt.Valid {
|
||||||
|
if slug := strings.ToLower(strings.TrimSpace(tenant.Slug)); slug != "" {
|
||||||
|
deletedBySlug[slug] = tenant
|
||||||
|
}
|
||||||
|
if legacy := adminctlLegacyTenantSlug(tenant); legacy != "" {
|
||||||
|
deletedBySlug[strings.ToLower(legacy)] = tenant
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tenantByID, deletedBySlug
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminctlDeletedTenantPromotion(traits map[string]any, tenantByID map[string]domain.Tenant, deletedBySlug map[string]domain.Tenant) (domain.Tenant, domain.Tenant, bool) {
|
||||||
|
deleted, ok := adminctlFindDeletedTenantInTraits(traits, tenantByID, deletedBySlug)
|
||||||
|
if !ok {
|
||||||
|
return domain.Tenant{}, domain.Tenant{}, false
|
||||||
|
}
|
||||||
|
target, ok := adminctlNearestActiveAncestor(deleted, tenantByID)
|
||||||
|
return deleted, target, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminctlFindDeletedTenantInTraits(traits map[string]any, tenantByID map[string]domain.Tenant, deletedBySlug map[string]domain.Tenant) (domain.Tenant, bool) {
|
||||||
|
for _, key := range []string{"tenant_id", "primaryTenantId", "primaryTenantSlug", "companyCode", "company_code"} {
|
||||||
|
if tenant, ok := adminctlDeletedTenantFromValue(traits[key], tenantByID, deletedBySlug); ok {
|
||||||
|
return tenant, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch appointments := traits["additionalAppointments"].(type) {
|
||||||
|
case []any:
|
||||||
|
for _, raw := range appointments {
|
||||||
|
appointment, ok := raw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, key := range []string{"tenantId", "tenantSlug"} {
|
||||||
|
if tenant, ok := adminctlDeletedTenantFromValue(appointment[key], tenantByID, deletedBySlug); ok {
|
||||||
|
return tenant, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case []map[string]any:
|
||||||
|
for _, appointment := range appointments {
|
||||||
|
for _, key := range []string{"tenantId", "tenantSlug"} {
|
||||||
|
if tenant, ok := adminctlDeletedTenantFromValue(appointment[key], tenantByID, deletedBySlug); ok {
|
||||||
|
return tenant, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return domain.Tenant{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminctlDeletedTenantFromValue(value any, tenantByID map[string]domain.Tenant, deletedBySlug map[string]domain.Tenant) (domain.Tenant, bool) {
|
||||||
|
raw := strings.TrimSpace(fmt.Sprint(value))
|
||||||
|
if raw == "" || raw == "<nil>" {
|
||||||
|
return domain.Tenant{}, false
|
||||||
|
}
|
||||||
|
if tenant, ok := tenantByID[raw]; ok && tenant.DeletedAt.Valid {
|
||||||
|
return tenant, true
|
||||||
|
}
|
||||||
|
tenant, ok := deletedBySlug[strings.ToLower(raw)]
|
||||||
|
return tenant, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminctlNearestActiveAncestor(deleted domain.Tenant, tenantByID map[string]domain.Tenant) (domain.Tenant, bool) {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
parentID := deleted.ParentID
|
||||||
|
for parentID != nil {
|
||||||
|
id := strings.TrimSpace(*parentID)
|
||||||
|
if id == "" || seen[id] {
|
||||||
|
return domain.Tenant{}, false
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
parent, ok := tenantByID[id]
|
||||||
|
if !ok {
|
||||||
|
return domain.Tenant{}, false
|
||||||
|
}
|
||||||
|
if !parent.DeletedAt.Valid {
|
||||||
|
return parent, true
|
||||||
|
}
|
||||||
|
parentID = parent.ParentID
|
||||||
|
}
|
||||||
|
return domain.Tenant{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminctlPromoteIdentityTraits(traits map[string]any, deletedTenant domain.Tenant, targetTenant domain.Tenant) (map[string]any, bool) {
|
||||||
|
next := adminctlCloneIdentityTraits(traits)
|
||||||
|
changed := false
|
||||||
|
if adminctlTraitMatchesTenant(next["tenant_id"], deletedTenant) || strings.TrimSpace(adminctlTraitString(next["tenant_id"])) == "" {
|
||||||
|
next["tenant_id"] = targetTenant.ID
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if adminctlTraitMatchesTenant(next["primaryTenantId"], deletedTenant) || adminctlTraitMatchesTenant(next["primaryTenantSlug"], deletedTenant) {
|
||||||
|
next["primaryTenantId"] = targetTenant.ID
|
||||||
|
next["primaryTenantSlug"] = targetTenant.Slug
|
||||||
|
next["primaryTenantName"] = targetTenant.Name
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if adminctlTraitMatchesTenant(next["companyCode"], deletedTenant) {
|
||||||
|
next["companyCode"] = targetTenant.Slug
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if adminctlTraitMatchesTenant(next["company_code"], deletedTenant) {
|
||||||
|
next["company_code"] = targetTenant.Slug
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if appointments, appointmentsChanged := adminctlPromoteIdentityAppointments(next["additionalAppointments"], deletedTenant, targetTenant); appointmentsChanged {
|
||||||
|
next["additionalAppointments"] = appointments
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
return next, changed
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminctlPromoteIdentityAppointments(raw any, deletedTenant domain.Tenant, targetTenant domain.Tenant) ([]any, bool) {
|
||||||
|
switch appointments := raw.(type) {
|
||||||
|
case []any:
|
||||||
|
next := make([]any, 0, len(appointments))
|
||||||
|
changed := false
|
||||||
|
for _, rawAppointment := range appointments {
|
||||||
|
appointment, ok := rawAppointment.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
next = append(next, rawAppointment)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
copied := maps.Clone(appointment)
|
||||||
|
if adminctlTraitMatchesTenant(copied["tenantId"], deletedTenant) || adminctlTraitMatchesTenant(copied["tenantSlug"], deletedTenant) {
|
||||||
|
copied["tenantId"] = targetTenant.ID
|
||||||
|
copied["tenantSlug"] = targetTenant.Slug
|
||||||
|
copied["tenantName"] = targetTenant.Name
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
next = append(next, copied)
|
||||||
|
}
|
||||||
|
return next, changed
|
||||||
|
case []map[string]any:
|
||||||
|
next := make([]any, 0, len(appointments))
|
||||||
|
changed := false
|
||||||
|
for _, appointment := range appointments {
|
||||||
|
copied := maps.Clone(appointment)
|
||||||
|
if adminctlTraitMatchesTenant(copied["tenantId"], deletedTenant) || adminctlTraitMatchesTenant(copied["tenantSlug"], deletedTenant) {
|
||||||
|
copied["tenantId"] = targetTenant.ID
|
||||||
|
copied["tenantSlug"] = targetTenant.Slug
|
||||||
|
copied["tenantName"] = targetTenant.Name
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
next = append(next, copied)
|
||||||
|
}
|
||||||
|
return next, changed
|
||||||
|
default:
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminctlTraitMatchesTenant(value any, tenant domain.Tenant) bool {
|
||||||
|
raw := strings.TrimSpace(adminctlTraitString(value))
|
||||||
|
if raw == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.EqualFold(raw, tenant.ID) || strings.EqualFold(raw, tenant.Slug) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.EqualFold(raw, adminctlLegacyTenantSlug(tenant))
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminctlLegacyTenantSlug(tenant domain.Tenant) string {
|
||||||
|
slug := strings.TrimSpace(tenant.Slug)
|
||||||
|
idx := strings.LastIndex(slug, "-deleted-")
|
||||||
|
if idx <= 0 {
|
||||||
|
return slug
|
||||||
|
}
|
||||||
|
return slug[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminctlTraitString(value any) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(fmt.Sprint(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminctlCloneIdentityTraits(traits map[string]any) map[string]any {
|
||||||
|
if traits == nil {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(traits)
|
||||||
|
if err != nil {
|
||||||
|
return maps.Clone(traits)
|
||||||
|
}
|
||||||
|
var next map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &next); err != nil {
|
||||||
|
return maps.Clone(traits)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
func openDB() (*gorm.DB, error) {
|
func openDB() (*gorm.DB, error) {
|
||||||
dsn := fmt.Sprintf(
|
dsn := fmt.Sprintf(
|
||||||
"host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Seoul",
|
"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, "usage:")
|
||||||
fmt.Fprintln(os.Stderr, " adminctl create-super-admin [--email EMAIL] [--password PASSWORD] [--name NAME] [--update-password]")
|
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 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]")
|
fmt.Fprintln(os.Stderr, " adminctl worksmobile-sync [--orgunits] [--users-csv PATH] [--credential-batch-id ID] [--process] [--serialize-orgunits] [--serialize-users-batch ID] [--batch-size N] [--delay DURATION]")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,7 +160,6 @@ func TestRecreatePendingWorksmobileUsersFromSnapshotCreatesOnlyMatchedUsers(t *t
|
|||||||
writer,
|
writer,
|
||||||
client,
|
client,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("recreatePendingWorksmobileUsersFromSnapshot returned error: %v", err)
|
t.Fatalf("recreatePendingWorksmobileUsersFromSnapshot returned error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -224,7 +223,6 @@ func TestRecreatePendingWorksmobileUsersFromSnapshotRollsBackWhenCreateFails(t *
|
|||||||
writer,
|
writer,
|
||||||
client,
|
client,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("recreatePendingWorksmobileUsersFromSnapshot returned error: %v", err)
|
t.Fatalf("recreatePendingWorksmobileUsersFromSnapshot returned error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -283,7 +281,6 @@ func TestImportHanmacWorksmobileUsersFromRowsSkipsExistingRemoteLocalPart(t *tes
|
|||||||
writer,
|
writer,
|
||||||
client,
|
client,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("importHanmacWorksmobileUsersFromRows returned error: %v", err)
|
t.Fatalf("importHanmacWorksmobileUsersFromRows returned error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -341,7 +338,6 @@ func TestImportHanmacWorksmobileUsersFromRowsSavesBaronUserAndCreatesWorksmobile
|
|||||||
writer,
|
writer,
|
||||||
client,
|
client,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("importHanmacWorksmobileUsersFromRows returned error: %v", err)
|
t.Fatalf("importHanmacWorksmobileUsersFromRows returned error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -409,7 +405,6 @@ func TestImportHanmacWorksmobileUsersFromRowsKeepsExternalSubEmailOutOfWorksmobi
|
|||||||
writer,
|
writer,
|
||||||
client,
|
client,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("importHanmacWorksmobileUsersFromRows returned error: %v", err)
|
t.Fatalf("importHanmacWorksmobileUsersFromRows returned error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -441,7 +436,6 @@ func TestBuildAdminctlWorksmobileOrgUnitPayloadClearsDomainRootParent(t *testing
|
|||||||
companyID: company,
|
companyID: company,
|
||||||
orgID: org,
|
orgID: org,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("buildAdminctlWorksmobileOrgUnitPayload returned error: %v", err)
|
t.Fatalf("buildAdminctlWorksmobileOrgUnitPayload returned error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -512,6 +506,10 @@ func (f *fakeWorksmobilePendingRecreateClient) UpsertUser(ctx context.Context, p
|
|||||||
return nil
|
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 {
|
func (f *fakeWorksmobilePendingRecreateClient) AddUserAliasEmail(ctx context.Context, userID string, email string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -301,7 +301,6 @@ func main() {
|
|||||||
tenantRepo := repository.NewTenantRepository(db)
|
tenantRepo := repository.NewTenantRepository(db)
|
||||||
userGroupRepo := repository.NewUserGroupRepository(db)
|
userGroupRepo := repository.NewUserGroupRepository(db)
|
||||||
userRepo := repository.NewUserRepository(db)
|
userRepo := repository.NewUserRepository(db)
|
||||||
userProjectionRepo := repository.NewUserProjectionRepository(db)
|
|
||||||
ketoOutboxRepo := repository.NewKetoOutboxRepository(db) // Reuse or re-init
|
ketoOutboxRepo := repository.NewKetoOutboxRepository(db) // Reuse or re-init
|
||||||
rpUsageOutboxRepo := repository.NewRPUsageOutboxRepository(db)
|
rpUsageOutboxRepo := repository.NewRPUsageOutboxRepository(db)
|
||||||
worksmobileOutboxRepo := repository.NewWorksmobileOutboxRepository(db)
|
worksmobileOutboxRepo := repository.NewWorksmobileOutboxRepository(db)
|
||||||
@@ -309,13 +308,6 @@ func main() {
|
|||||||
kratosAdminService := service.NewKratosAdminService()
|
kratosAdminService := service.NewKratosAdminService()
|
||||||
oryAdminProvider := service.NewOryProvider()
|
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)
|
tenantService := service.NewTenantService(tenantRepo, userRepo, userGroupRepo, ketoOutboxRepo)
|
||||||
worksmobilePrivateKey, err := getEnvFileOrValue("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE", "WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY", "")
|
worksmobilePrivateKey, err := getEnvFileOrValue("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE", "WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -336,6 +328,7 @@ func main() {
|
|||||||
)
|
)
|
||||||
configureWorksmobileClientFromEnv(worksmobileClient)
|
configureWorksmobileClientFromEnv(worksmobileClient)
|
||||||
worksmobileService := service.NewWorksmobileSyncService(tenantService, userRepo, worksmobileOutboxRepo, worksmobileClient)
|
worksmobileService := service.NewWorksmobileSyncService(tenantService, userRepo, worksmobileOutboxRepo, worksmobileClient)
|
||||||
|
worksmobileService.SetIdentityMirror(redisService)
|
||||||
worksmobileRelayClient := *worksmobileClient
|
worksmobileRelayClient := *worksmobileClient
|
||||||
worksmobileRelayClient.RateLimiter = service.NewWorksmobileAPIRateLimiter(240, time.Minute)
|
worksmobileRelayClient.RateLimiter = service.NewWorksmobileAPIRateLimiter(240, time.Minute)
|
||||||
worksmobileRelayWorker := service.NewWorksmobileRelayWorker(worksmobileOutboxRepo, &worksmobileRelayClient)
|
worksmobileRelayWorker := service.NewWorksmobileRelayWorker(worksmobileOutboxRepo, &worksmobileRelayClient)
|
||||||
@@ -371,7 +364,6 @@ func main() {
|
|||||||
auditHandler := handler.NewAuditHandler(auditRepo)
|
auditHandler := handler.NewAuditHandler(auditRepo)
|
||||||
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService)
|
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService)
|
||||||
authHandler.HeadlessJWKS = headlessJWKSCache
|
authHandler.HeadlessJWKS = headlessJWKSCache
|
||||||
authHandler.UserProjectionRepo = userProjectionRepo
|
|
||||||
authHandler.RPUserMetadataRepo = rpUserMetadataRepo
|
authHandler.RPUserMetadataRepo = rpUserMetadataRepo
|
||||||
authHandler.RPUsageSink = rpUsageEmitter
|
authHandler.RPUsageSink = rpUsageEmitter
|
||||||
adminHandler := handler.NewAdminHandler(ketoService, ketoOutboxRepo)
|
adminHandler := handler.NewAdminHandler(ketoService, ketoOutboxRepo)
|
||||||
@@ -380,7 +372,6 @@ func main() {
|
|||||||
adminHandler.TenantRepo = tenantRepo
|
adminHandler.TenantRepo = tenantRepo
|
||||||
adminHandler.Hydra = hydraService
|
adminHandler.Hydra = hydraService
|
||||||
adminHandler.AuditRepo = auditRepo
|
adminHandler.AuditRepo = auditRepo
|
||||||
adminHandler.UserProjectionRepo = userProjectionRepo
|
|
||||||
adminHandler.IdentityCache = redisService
|
adminHandler.IdentityCache = redisService
|
||||||
adminHandler.IntegrityChecker = repository.NewDataIntegrityChecker(db)
|
adminHandler.IntegrityChecker = repository.NewDataIntegrityChecker(db)
|
||||||
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, ketoOutboxRepo, tenantService, developerService, authHandler)
|
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.IdentityWriter = service.NewIdentityWriteService(kratosAdminService, redisService)
|
||||||
devHandler.RPUserMetadataRepo = rpUserMetadataRepo
|
devHandler.RPUserMetadataRepo = rpUserMetadataRepo
|
||||||
devHandler.RPUsageQueries = rpUsageQueryRepo
|
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.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)
|
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
|
||||||
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
|
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
|
||||||
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo, userGroupRepo, auditRepo)
|
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo, userGroupRepo, auditRepo)
|
||||||
userHandler.UserProjectionRepo = userProjectionRepo
|
|
||||||
userHandler.IdentityCache = redisService
|
userHandler.IdentityCache = redisService
|
||||||
go func() {
|
go func() {
|
||||||
startedAt := time.Now()
|
startedAt := time.Now()
|
||||||
@@ -735,7 +734,6 @@ func main() {
|
|||||||
admin.Get("/integrity", requireSuperAdmin, adminHandler.GetDataIntegrity)
|
admin.Get("/integrity", requireSuperAdmin, adminHandler.GetDataIntegrity)
|
||||||
admin.Get("/integrity/orphan-user-login-ids", requireSuperAdmin, adminHandler.ListOrphanUserLoginIDs)
|
admin.Get("/integrity/orphan-user-login-ids", requireSuperAdmin, adminHandler.ListOrphanUserLoginIDs)
|
||||||
admin.Delete("/integrity/orphan-user-login-ids", requireSuperAdmin, adminHandler.DeleteOrphanUserLoginIDs)
|
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.Get("/ory/ssot", requireSuperAdmin, adminHandler.GetOrySSOTSystemStatus)
|
||||||
admin.Post("/ory/ssot/identity-cache/flush", requireSuperAdmin, adminHandler.FlushIdentityCache)
|
admin.Post("/ory/ssot/identity-cache/flush", requireSuperAdmin, adminHandler.FlushIdentityCache)
|
||||||
admin.Get("/rp-usage/daily", requireAdmin, adminHandler.GetRPUsageDaily)
|
admin.Get("/rp-usage/daily", requireAdmin, adminHandler.GetRPUsageDaily)
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ func Run(db *gorm.DB) error {
|
|||||||
if err := SanitizeLegacyUserMetadata(db); err != nil {
|
if err := SanitizeLegacyUserMetadata(db); err != nil {
|
||||||
return fmt.Errorf("legacy user metadata sanitize failed: %w", err)
|
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] User seed skipped (Kratos is SoT)")
|
||||||
slog.Info("[Bootstrap] Bootstrap completed successfully.")
|
slog.Info("[Bootstrap] Bootstrap completed successfully.")
|
||||||
@@ -50,7 +53,6 @@ func migrateSchemas(db *gorm.DB) error {
|
|||||||
&domain.TenantDomain{},
|
&domain.TenantDomain{},
|
||||||
&domain.User{},
|
&domain.User{},
|
||||||
&domain.UserLoginID{},
|
&domain.UserLoginID{},
|
||||||
&domain.UserProjectionState{},
|
|
||||||
&domain.UserGroup{},
|
&domain.UserGroup{},
|
||||||
&domain.ApiKey{},
|
&domain.ApiKey{},
|
||||||
&domain.IdentityProviderConfig{},
|
&domain.IdentityProviderConfig{},
|
||||||
|
|||||||
@@ -15,6 +15,42 @@ where metadata ? 'hanmacFamily'
|
|||||||
or metadata ? 'userType'
|
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.
|
// SanitizeLegacyUserMetadata removes legacy UI classification flags from Baron user metadata.
|
||||||
func SanitizeLegacyUserMetadata(db *gorm.DB) error {
|
func SanitizeLegacyUserMetadata(db *gorm.DB) error {
|
||||||
if db == nil {
|
if db == nil {
|
||||||
@@ -32,3 +68,21 @@ func SanitizeLegacyUserMetadata(db *gorm.DB) error {
|
|||||||
slog.Info("[Bootstrap] Legacy user metadata sanitized", "rowsAffected", result.RowsAffected)
|
slog.Info("[Bootstrap] Legacy user metadata sanitized", "rowsAffected", result.RowsAffected)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CanonicalizeUserAppointmentTenants rewrites appointment display fields from the tenant UUID source.
|
||||||
|
func CanonicalizeUserAppointmentTenants(db *gorm.DB) error {
|
||||||
|
if db == nil {
|
||||||
|
return fmt.Errorf("database is not configured")
|
||||||
|
}
|
||||||
|
if !db.Migrator().HasTable("users") || !db.Migrator().HasTable("tenants") {
|
||||||
|
slog.Info("[Bootstrap] User appointment tenant canonicalization skipped because required tables do not exist")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := db.Exec(canonicalizeUserAppointmentTenantsSQL)
|
||||||
|
if result.Error != nil {
|
||||||
|
return fmt.Errorf("canonicalize user appointment tenants: %w", result.Error)
|
||||||
|
}
|
||||||
|
slog.Info("[Bootstrap] User appointment tenant metadata canonicalized", "rowsAffected", result.RowsAffected)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -114,6 +114,89 @@ func TestCanonicalizeLegacyUserStatuses(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCanonicalizeUserAppointmentTenantsUsesTenantUUID(t *testing.T) {
|
||||||
|
db := openBootstrapPostgresTestDB(t)
|
||||||
|
if err := db.AutoMigrate(&domain.Tenant{}, &domain.User{}); err != nil {
|
||||||
|
t.Fatalf("failed to migrate users and tenants tables: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tenant := domain.Tenant{
|
||||||
|
ID: "30000000-0000-0000-0000-000000000101",
|
||||||
|
Type: domain.TenantTypeOrganization,
|
||||||
|
Name: "통합시스템",
|
||||||
|
Slug: "intigrated-system",
|
||||||
|
Status: domain.TenantStatusActive,
|
||||||
|
}
|
||||||
|
if err := db.Create(&tenant).Error; err != nil {
|
||||||
|
t.Fatalf("failed to create tenant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
user := domain.User{
|
||||||
|
ID: "30000000-0000-0000-0000-000000000201",
|
||||||
|
Email: "appointment@example.com",
|
||||||
|
Name: "Appointment User",
|
||||||
|
Role: domain.RoleUser,
|
||||||
|
Status: domain.UserStatusActive,
|
||||||
|
Metadata: domain.JSONMap{
|
||||||
|
"additionalAppointments": []any{
|
||||||
|
map[string]any{
|
||||||
|
"tenantId": tenant.ID,
|
||||||
|
"tenantSlug": "tech-planning",
|
||||||
|
"tenantName": "기술기획",
|
||||||
|
"grade": "연구원",
|
||||||
|
},
|
||||||
|
map[string]any{
|
||||||
|
"tenantId": "30000000-0000-0000-0000-000000000999",
|
||||||
|
"tenantSlug": "unknown-old",
|
||||||
|
"tenantName": "Unknown Old",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := db.Create(&user).Error; err != nil {
|
||||||
|
t.Fatalf("failed to create user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := CanonicalizeUserAppointmentTenants(db); err != nil {
|
||||||
|
t.Fatalf("CanonicalizeUserAppointmentTenants returned error: %v", err)
|
||||||
|
}
|
||||||
|
if err := CanonicalizeUserAppointmentTenants(db); err != nil {
|
||||||
|
t.Fatalf("CanonicalizeUserAppointmentTenants must be idempotent: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got domain.User
|
||||||
|
if err := db.First(&got, "id = ?", user.ID).Error; err != nil {
|
||||||
|
t.Fatalf("failed to load canonicalized user: %v", err)
|
||||||
|
}
|
||||||
|
appointments, ok := got.Metadata["additionalAppointments"].([]any)
|
||||||
|
if !ok || len(appointments) != 2 {
|
||||||
|
t.Fatalf("additionalAppointments = %#v, want two appointments", got.Metadata["additionalAppointments"])
|
||||||
|
}
|
||||||
|
first, ok := appointments[0].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("first appointment = %#v, want object", appointments[0])
|
||||||
|
}
|
||||||
|
if first["tenantId"] != tenant.ID {
|
||||||
|
t.Fatalf("tenantId = %#v, want %s", first["tenantId"], tenant.ID)
|
||||||
|
}
|
||||||
|
if first["tenantSlug"] != tenant.Slug {
|
||||||
|
t.Fatalf("tenantSlug = %#v, want %s", first["tenantSlug"], tenant.Slug)
|
||||||
|
}
|
||||||
|
if first["tenantName"] != tenant.Name {
|
||||||
|
t.Fatalf("tenantName = %#v, want %s", first["tenantName"], tenant.Name)
|
||||||
|
}
|
||||||
|
if first["grade"] != "연구원" {
|
||||||
|
t.Fatalf("grade = %#v, want preserved value", first["grade"])
|
||||||
|
}
|
||||||
|
second, ok := appointments[1].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("second appointment = %#v, want object", appointments[1])
|
||||||
|
}
|
||||||
|
if second["tenantSlug"] != "unknown-old" || second["tenantName"] != "Unknown Old" {
|
||||||
|
t.Fatalf("unknown tenant appointment must be preserved: %#v", second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunSanitizesLegacyUserMetadata(t *testing.T) {
|
func TestRunSanitizesLegacyUserMetadata(t *testing.T) {
|
||||||
db := openBootstrapPostgresTestDB(t)
|
db := openBootstrapPostgresTestDB(t)
|
||||||
if err := db.AutoMigrate(&domain.User{}); err != nil {
|
if err := db.AutoMigrate(&domain.User{}); err != nil {
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
package domain
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
const (
|
|
||||||
UserProjectionNameKratos = "kratos_users"
|
|
||||||
|
|
||||||
UserProjectionStatusSyncing = "syncing"
|
|
||||||
UserProjectionStatusReady = "ready"
|
|
||||||
UserProjectionStatusFailed = "failed"
|
|
||||||
)
|
|
||||||
|
|
||||||
type UserProjectionState struct {
|
|
||||||
Name string `gorm:"primaryKey;column:name" json:"name"`
|
|
||||||
Status string `gorm:"column:status;not null" json:"status"`
|
|
||||||
LastSyncedAt *time.Time `gorm:"column:last_synced_at" json:"lastSyncedAt,omitempty"`
|
|
||||||
LastError string `gorm:"column:last_error;type:text" json:"lastError,omitempty"`
|
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserProjectionStatus struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Ready bool `json:"ready"`
|
|
||||||
LastSyncedAt *time.Time `json:"lastSyncedAt,omitempty"`
|
|
||||||
LastError string `json:"lastError,omitempty"`
|
|
||||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
|
||||||
ProjectedUsers int64 `json:"projectedUsers"`
|
|
||||||
}
|
|
||||||
@@ -24,16 +24,15 @@ type identityCacheAdmin interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AdminHandler struct {
|
type AdminHandler struct {
|
||||||
DB *gorm.DB
|
DB *gorm.DB
|
||||||
Keto service.KetoService
|
Keto service.KetoService
|
||||||
KetoOutbox repository.KetoOutboxRepository
|
KetoOutbox repository.KetoOutboxRepository
|
||||||
RPUsageQueries domain.RPUsageQueryRepository
|
RPUsageQueries domain.RPUsageQueryRepository
|
||||||
TenantRepo repository.TenantRepository
|
TenantRepo repository.TenantRepository
|
||||||
Hydra adminHydraClientLister
|
Hydra adminHydraClientLister
|
||||||
AuditRepo domain.AuditRepository
|
AuditRepo domain.AuditRepository
|
||||||
UserProjectionRepo repository.UserProjectionRepository
|
IdentityCache identityCacheAdmin
|
||||||
IdentityCache identityCacheAdmin
|
IntegrityChecker repository.DataIntegrityChecker
|
||||||
IntegrityChecker repository.DataIntegrityChecker
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const globalCustomClaimsSettingKey = "global_custom_claim_definitions"
|
const globalCustomClaimsSettingKey = "global_custom_claim_definitions"
|
||||||
@@ -289,20 +288,6 @@ func requireSuperAdminProfile(c *fiber.Ctx) bool {
|
|||||||
return true
|
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 {
|
func (h *AdminHandler) GetOrySSOTSystemStatus(c *fiber.Ctx) error {
|
||||||
if !requireSuperAdminProfile(c) {
|
if !requireSuperAdminProfile(c) {
|
||||||
return nil
|
return nil
|
||||||
@@ -428,14 +413,14 @@ func (h *AdminHandler) countTenants(ctx context.Context) int64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *AdminHandler) countUsers(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
|
return 0
|
||||||
}
|
}
|
||||||
status, err := h.UserProjectionRepo.GetStatus(ctx)
|
var total int64
|
||||||
if err != nil {
|
if err := h.DB.WithContext(ctx).Model(&domain.User{}).Count(&total).Error; err != nil {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
return status.ProjectedUsers
|
return total
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AdminHandler) countOIDCClients(ctx context.Context) int64 {
|
func (h *AdminHandler) countOIDCClients(ctx context.Context) int64 {
|
||||||
|
|||||||
@@ -65,34 +65,6 @@ func (f *fakeOverviewAuditRepo) CountEventsSince(ctx context.Context, since time
|
|||||||
return f.count, nil
|
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 {
|
type fakeIdentityCacheAdmin struct {
|
||||||
status domain.IdentityCacheStatus
|
status domain.IdentityCacheStatus
|
||||||
flush domain.IdentityCacheFlushResult
|
flush domain.IdentityCacheFlushResult
|
||||||
@@ -157,58 +129,6 @@ func TestAdminHandler_GetRPUsageDaily(t *testing.T) {
|
|||||||
require.Equal(t, uint64(12), body.Items[0].LoginRequests)
|
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) {
|
func TestAdminHandler_GetOrySSOTSystemStatusReturnsIdentityCacheOnly(t *testing.T) {
|
||||||
syncedAt := time.Date(2026, 5, 11, 3, 0, 0, 0, time.UTC)
|
syncedAt := time.Date(2026, 5, 11, 3, 0, 0, 0, time.UTC)
|
||||||
cache := &fakeIdentityCacheAdmin{
|
cache := &fakeIdentityCacheAdmin{
|
||||||
@@ -237,11 +157,9 @@ func TestAdminHandler_GetOrySSOTSystemStatusReturnsIdentityCacheOnly(t *testing.
|
|||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var body struct {
|
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.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||||
require.Nil(t, body.UserProjection)
|
|
||||||
require.True(t, body.IdentityCache.RedisReady)
|
require.True(t, body.IdentityCache.RedisReady)
|
||||||
require.Equal(t, int64(151), body.IdentityCache.ObservedCount)
|
require.Equal(t, int64(151), body.IdentityCache.ObservedCount)
|
||||||
require.Equal(t, int64(153), body.IdentityCache.KeyCount)
|
require.Equal(t, int64(153), body.IdentityCache.KeyCount)
|
||||||
@@ -305,14 +223,6 @@ func TestAdminHandler_GetSystemStatsIncludesOverviewMetrics(t *testing.T) {
|
|||||||
auditRepo := &fakeOverviewAuditRepo{count: 22}
|
auditRepo := &fakeOverviewAuditRepo{count: 22}
|
||||||
h := &AdminHandler{
|
h := &AdminHandler{
|
||||||
AuditRepo: auditRepo,
|
AuditRepo: auditRepo,
|
||||||
UserProjectionRepo: &fakeAdminUserProjectionRepo{
|
|
||||||
status: domain.UserProjectionStatus{
|
|
||||||
Name: domain.UserProjectionNameKratos,
|
|
||||||
Status: domain.UserProjectionStatusReady,
|
|
||||||
Ready: true,
|
|
||||||
ProjectedUsers: 152,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
app.Get("/api/v1/admin/stats", h.GetSystemStats)
|
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, "totalUsers")
|
||||||
require.Contains(t, body, "oidcClients")
|
require.Contains(t, body, "oidcClients")
|
||||||
require.Contains(t, body, "auditEvents24h")
|
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, float64(22), body["auditEvents24h"])
|
||||||
require.Equal(t, time.UTC, auditRepo.since.Location())
|
require.Equal(t, time.UTC, auditRepo.since.Location())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,6 @@ type AuthHandler struct {
|
|||||||
KetoService service.KetoService
|
KetoService service.KetoService
|
||||||
KetoOutboxRepo repository.KetoOutboxRepository
|
KetoOutboxRepo repository.KetoOutboxRepository
|
||||||
UserRepo repository.UserRepository
|
UserRepo repository.UserRepository
|
||||||
UserProjectionRepo repository.UserProjectionRepository
|
|
||||||
ConsentRepo repository.ClientConsentRepository
|
ConsentRepo repository.ClientConsentRepository
|
||||||
RPUserMetadataRepo repository.RPUserMetadataRepository
|
RPUserMetadataRepo repository.RPUserMetadataRepository
|
||||||
RPUsageSink domain.RPUsageEventSink
|
RPUsageSink domain.RPUsageEventSink
|
||||||
@@ -860,7 +859,6 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
if err := h.UserRepo.Update(ctx, u); err != nil {
|
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)
|
slog.Error("[Signup] Failed to sync user to Read-Model (Local DB)", "email", u.Email, "error", err)
|
||||||
markUserProjectionFailed(ctx, h.UserProjectionRepo, err)
|
|
||||||
} else {
|
} else {
|
||||||
slog.Debug("[Signup] Synced user to Read-Model", "email", u.Email)
|
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 {
|
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)
|
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
|
// [Keto] Sync user-tenant relationship via Outbox
|
||||||
@@ -8120,7 +8117,6 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
if err := h.syncUpdatedKratosUserReadModel(ctx, identityID, traits); err != nil {
|
if err := h.syncUpdatedKratosUserReadModel(ctx, identityID, traits); err != nil {
|
||||||
slog.Error("[UpdateMe] Failed to sync local user read-model", "userID", identityID, "error", err)
|
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 {
|
if err := h.UserRepo.UpdateUserLoginIDs(ctx, identityID, loginIDRecords); err != nil {
|
||||||
slog.Error("[UpdateMe] Failed to update user login IDs", "userID", identityID, "error", err)
|
slog.Error("[UpdateMe] Failed to update user login IDs", "userID", identityID, "error", err)
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,7 +30,13 @@ type hanmacEmailEvaluation struct {
|
|||||||
LocalPart string
|
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)
|
originalEmail := strings.TrimSpace(item.Email)
|
||||||
name := strings.TrimSpace(item.Name)
|
name := strings.TrimSpace(item.Name)
|
||||||
evaluation := hanmacEmailEvaluation{
|
evaluation := hanmacEmailEvaluation{
|
||||||
@@ -68,9 +76,9 @@ func (h *UserHandler) evaluateHanmacImportEmail(ctx context.Context, item bulkUs
|
|||||||
}
|
}
|
||||||
|
|
||||||
evaluation.LocalPart = localPart
|
evaluation.LocalPart = localPart
|
||||||
if usedLocalParts[localPart] {
|
if owner, exists := usedLocalParts[localPart]; exists {
|
||||||
evaluation.Status = "blockingError"
|
evaluation.Status = "blockingError"
|
||||||
evaluation.Message = "한맥가족 내에서 이미 사용 중인 이메일 ID입니다."
|
evaluation.Message = formatHanmacLocalPartConflictMessage(localPart, owner)
|
||||||
evaluation.Blocking = true
|
evaluation.Blocking = true
|
||||||
return evaluation
|
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 {
|
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)
|
scope, err := h.resolveHanmacEmailScope(ctx)
|
||||||
if err != nil || scope == nil || !scope.ContainsTenant(tenantID, tenantSlug) {
|
if err != nil || scope == nil || !scope.ContainsTenant(tenantID, tenantSlug) {
|
||||||
return nil
|
return nil
|
||||||
@@ -102,8 +118,22 @@ func (h *UserHandler) ensureHanmacCreateEmailAllowed(ctx context.Context, email
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if usedLocalParts[localPart] {
|
if owner, exists := usedLocalParts[localPart]; exists {
|
||||||
return fmt.Errorf("한맥가족 내에서 이미 사용 중인 이메일 ID입니다.")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -149,8 +179,8 @@ func (h *UserHandler) resolveHanmacEmailScope(ctx context.Context) (*hanmacEmail
|
|||||||
return scope, nil
|
return scope, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *UserHandler) loadHanmacLocalParts(ctx context.Context, scope *hanmacEmailScope) (map[string]bool, error) {
|
func (h *UserHandler) loadHanmacLocalParts(ctx context.Context, scope *hanmacEmailScope) (map[string]hanmacLocalPartOwner, error) {
|
||||||
used := make(map[string]bool)
|
used := make(map[string]hanmacLocalPartOwner)
|
||||||
if h.UserRepo == nil || scope == nil {
|
if h.UserRepo == nil || scope == nil {
|
||||||
return used, nil
|
return used, nil
|
||||||
}
|
}
|
||||||
@@ -160,7 +190,7 @@ func (h *UserHandler) loadHanmacLocalParts(ctx context.Context, scope *hanmacEma
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
addUserEmailLocalParts(used, users)
|
addUserEmailLocalPartOwners(used, users)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(scope.SlugList) > 0 {
|
if len(scope.SlugList) > 0 {
|
||||||
@@ -168,7 +198,7 @@ func (h *UserHandler) loadHanmacLocalParts(ctx context.Context, scope *hanmacEma
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
addUserEmailLocalParts(used, users)
|
addUserEmailLocalPartOwners(used, users)
|
||||||
}
|
}
|
||||||
|
|
||||||
return used, nil
|
return used, nil
|
||||||
@@ -210,31 +240,79 @@ func isTenantDescendantOf(tenant domain.Tenant, rootID string, tenantByID map[st
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func addUserEmailLocalParts(target map[string]bool, users []domain.User) {
|
func addUserEmailLocalPartOwners(target map[string]hanmacLocalPartOwner, users []domain.User) {
|
||||||
for _, user := range users {
|
for _, user := range users {
|
||||||
localPart, err := domain.ExtractNormalizedEmailLocalPart(user.Email)
|
localPart, err := domain.ExtractNormalizedEmailLocalPart(user.Email)
|
||||||
if err == nil && localPart != "" {
|
if err != nil || localPart == "" {
|
||||||
target[localPart] = true
|
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))
|
base = strings.ToLower(strings.TrimSpace(base))
|
||||||
if base == "" {
|
if base == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if !usedLocalParts[base] {
|
if _, exists := usedLocalParts[base]; !exists {
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
for index := 1; ; index++ {
|
|
||||||
candidate := fmt.Sprintf("%s%d", base, index)
|
stem, nextIndex := splitTrailingNumericSuffix(base)
|
||||||
if !usedLocalParts[candidate] {
|
if stem == "" {
|
||||||
|
stem = base
|
||||||
|
}
|
||||||
|
for index := nextIndex; ; index++ {
|
||||||
|
candidate := fmt.Sprintf("%s%d", stem, index)
|
||||||
|
if _, exists := usedLocalParts[candidate]; !exists {
|
||||||
return candidate
|
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 {
|
func appendUniqueString(values []string, value string) []string {
|
||||||
if slices.Contains(values, value) {
|
if slices.Contains(values, value) {
|
||||||
return values
|
return values
|
||||||
|
|||||||
66
backend/internal/handler/internal_domain_personal_policy.go
Normal file
66
backend/internal/handler/internal_domain_personal_policy.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var internalEmailDomainsDisallowedForPersonal = map[string]bool{
|
||||||
|
"brsw.kr": true,
|
||||||
|
"hanmaceng.co.kr": true,
|
||||||
|
"samaneng.com": true,
|
||||||
|
"hallasanup.com": true,
|
||||||
|
"jangheon.co.kr": true,
|
||||||
|
"jangheon.com": true,
|
||||||
|
"pre-cast.co.kr": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func internalDomainPersonalPolicyMessage(email string) string {
|
||||||
|
return fmt.Sprintf("내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다: %s", strings.ToLower(strings.TrimSpace(email)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func emailUsesInternalPersonalRestrictedDomain(email string) bool {
|
||||||
|
_, domainPart, err := domain.SplitEmailDomain(email)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return internalEmailDomainsDisallowedForPersonal[strings.ToLower(strings.TrimSpace(domainPart))]
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPersonalTenantForInternalDomainPolicy(tenant *domain.Tenant) bool {
|
||||||
|
if tenant == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.EqualFold(strings.TrimSpace(tenant.Type), domain.TenantTypePersonal) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
slug := strings.ToLower(strings.TrimSpace(tenant.Slug))
|
||||||
|
return slug == "personal" || strings.HasPrefix(slug, "personal-")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *UserHandler) ensureInternalDomainNotAssignedToPersonal(ctx context.Context, email string, tenantID string, tenantSlug string, resolvedTenant *domain.Tenant) error {
|
||||||
|
if !emailUsesInternalPersonalRestrictedDomain(email) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tenant := resolvedTenant
|
||||||
|
if tenant == nil && h.TenantService != nil {
|
||||||
|
if id := strings.TrimSpace(tenantID); id != "" {
|
||||||
|
if found, err := h.TenantService.GetTenant(ctx, id); err == nil && found != nil {
|
||||||
|
tenant = found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tenant == nil {
|
||||||
|
if slug := strings.TrimSpace(tenantSlug); slug != "" {
|
||||||
|
if found, err := h.TenantService.GetTenantBySlug(ctx, slug); err == nil && found != nil {
|
||||||
|
tenant = found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isPersonalTenantForInternalDomainPolicy(tenant) {
|
||||||
|
return fmt.Errorf("%s", internalDomainPersonalPolicyMessage(email))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -16,10 +16,8 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"maps"
|
"maps"
|
||||||
"os"
|
|
||||||
"reflect"
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -29,25 +27,28 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type TenantHandler struct {
|
type TenantHandler struct {
|
||||||
DB *gorm.DB
|
DB *gorm.DB
|
||||||
Service service.TenantService
|
Service service.TenantService
|
||||||
UserRepo repository.UserRepository
|
UserRepo repository.UserRepository
|
||||||
UserProjectionRepo repository.UserProjectionRepository
|
OrgChartCache orgChartCacheStore
|
||||||
OrgChartCache orgChartCacheStore
|
IdentityCache domain.RedisRepository
|
||||||
Keto service.KetoService
|
Keto service.KetoService
|
||||||
KetoOutbox repository.KetoOutboxRepository
|
KetoOutbox repository.KetoOutboxRepository
|
||||||
KratosAdmin service.KratosAdminService
|
KratosAdmin service.KratosAdminService
|
||||||
SharedLink service.SharedLinkService
|
SharedLink service.SharedLinkService
|
||||||
Worksmobile service.WorksmobileSyncer
|
Worksmobile service.WorksmobileSyncer
|
||||||
Hydra *service.HydraAdminService
|
Hydra *service.HydraAdminService
|
||||||
ConsentRepo repository.ClientConsentRepository
|
ConsentRepo repository.ClientConsentRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
type orgChartCacheStore interface {
|
type orgChartCacheStore interface {
|
||||||
Get(key string) (string, error)
|
Get(key string) (string, error)
|
||||||
Set(key string, value string, expiration time.Duration) 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 {
|
func seedTenantDeleteError(c *fiber.Ctx) error {
|
||||||
return errorJSON(c, fiber.StatusConflict, "seed tenants cannot be deleted")
|
return errorJSON(c, fiber.StatusConflict, "seed tenants cannot be deleted")
|
||||||
}
|
}
|
||||||
@@ -65,18 +66,17 @@ func seedTenantSlugsForDeleteGuard() []string {
|
|||||||
return result
|
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{
|
return &TenantHandler{
|
||||||
DB: db,
|
DB: db,
|
||||||
Service: svc,
|
Service: svc,
|
||||||
UserRepo: userRepo,
|
UserRepo: userRepo,
|
||||||
UserProjectionRepo: userProjectionRepo,
|
Keto: keto,
|
||||||
Keto: keto,
|
KetoOutbox: outbox,
|
||||||
KetoOutbox: outbox,
|
KratosAdmin: kratos,
|
||||||
KratosAdmin: kratos,
|
SharedLink: sharedLink,
|
||||||
SharedLink: sharedLink,
|
Hydra: hydra,
|
||||||
Hydra: hydra,
|
ConsentRepo: consentRepo,
|
||||||
ConsentRepo: consentRepo,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,6 +263,7 @@ func (h *TenantHandler) ApproveTenant(c *fiber.Ctx) error {
|
|||||||
if err := h.Service.ApproveTenant(c.Context(), tenantID); err != nil {
|
if err := h.Service.ApproveTenant(c.Context(), tenantID); err != nil {
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
|
h.refreshOrgChartSnapshotCacheAfterTenantChange(c.Context(), "tenant_approved")
|
||||||
|
|
||||||
return c.JSON(fiber.Map{"message": "Tenant approved successfully"})
|
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)
|
parentMap := make(map[string]string)
|
||||||
|
tenantIDs := make(map[string]bool, len(allTenants))
|
||||||
for _, t := range allTenants {
|
for _, t := range allTenants {
|
||||||
|
tenantIDs[t.ID] = true
|
||||||
if t.ParentID != nil {
|
if t.ParentID != nil {
|
||||||
parentMap[t.ID] = *t.ParentID
|
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)
|
roots := make(map[string]bool)
|
||||||
for _, id := range baseTenantIDs {
|
for _, id := range baseTenantIDs {
|
||||||
roots[findTenantRootID(parentMap, id)] = true
|
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 {
|
if err != nil {
|
||||||
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
|
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)
|
result.Details = append(result.Details, detail)
|
||||||
}
|
}
|
||||||
|
if result.Created > 0 || result.Updated > 0 {
|
||||||
|
h.refreshOrgChartSnapshotCacheAfterTenantChange(c.Context(), "tenants_imported")
|
||||||
|
}
|
||||||
|
|
||||||
return c.JSON(result)
|
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"} {
|
for _, relation := range []string{"view_private", "view_private_descendants", "view", "manage"} {
|
||||||
allowed, err := h.Keto.CheckPermission(ctx, subject, "Tenant", privateRootID, relation)
|
allowed, err := h.Keto.CheckPermission(ctx, subject, "Tenant", privateRootID, relation)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if isMissingKetoRelationError(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
return false, fmt.Errorf("private tenant permission check failed: %w", err)
|
return false, fmt.Errorf("private tenant permission check failed: %w", err)
|
||||||
}
|
}
|
||||||
if allowed {
|
if allowed {
|
||||||
@@ -1257,6 +1277,9 @@ func (h *TenantHandler) canViewPrivateTenant(ctx context.Context, profile *domai
|
|||||||
for _, ancestorID := range tenantAncestorIDs(privateRootID, tenants) {
|
for _, ancestorID := range tenantAncestorIDs(privateRootID, tenants) {
|
||||||
allowed, err := h.Keto.CheckPermission(ctx, subject, "Tenant", ancestorID, "view_private_descendants")
|
allowed, err := h.Keto.CheckPermission(ctx, subject, "Tenant", ancestorID, "view_private_descendants")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if isMissingKetoRelationError(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
return false, fmt.Errorf("private tenant descendant permission check failed: %w", err)
|
return false, fmt.Errorf("private tenant descendant permission check failed: %w", err)
|
||||||
}
|
}
|
||||||
if allowed {
|
if allowed {
|
||||||
@@ -1266,6 +1289,14 @@ func (h *TenantHandler) canViewPrivateTenant(ctx context.Context, profile *domai
|
|||||||
return false, nil
|
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 {
|
func profileCanManageTenantOrAncestor(profile *domain.UserProfileResponse, tenantID string, tenants []domain.Tenant) bool {
|
||||||
manageableIDs := make(map[string]bool, len(profile.ManageableTenants))
|
manageableIDs := make(map[string]bool, len(profile.ManageableTenants))
|
||||||
for _, tenant := range 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())
|
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 {
|
if err != nil {
|
||||||
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
|
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)
|
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)
|
fmt.Printf("[TenantHandler] failed to enqueue Worksmobile tenant update sync: %v\n", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
h.refreshOrgChartSnapshotCacheAfterTenantChange(c.Context(), "tenant_updated")
|
||||||
|
|
||||||
return c.JSON(mapTenantSummary(tenant))
|
return c.JSON(mapTenantSummary(tenant))
|
||||||
}
|
}
|
||||||
@@ -1980,6 +2013,10 @@ func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error {
|
|||||||
return seedTenantDeleteError(c)
|
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 {
|
if err := cleanupDeletedTenantReferences(c.Context(), h.Hydra, h.ConsentRepo, h.KetoOutbox, []string{tenantID}); err != nil {
|
||||||
logTenantCleanupFailure(err, []string{tenantID})
|
logTenantCleanupFailure(err, []string{tenantID})
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
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)
|
fmt.Printf("[TenantHandler] failed to enqueue Worksmobile tenant delete sync: %v\n", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
h.refreshOrgChartSnapshotCacheAfterTenantChange(c.Context(), "tenant_deleted")
|
||||||
|
|
||||||
return c.SendStatus(fiber.StatusNoContent)
|
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")
|
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 {
|
if len(req.IDs) == 0 {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, "no IDs provided")
|
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 {
|
if err := cleanupDeletedTenantReferences(c.Context(), h.Hydra, h.ConsentRepo, h.KetoOutbox, req.IDs); err != nil {
|
||||||
logTenantCleanupFailure(err, req.IDs)
|
logTenantCleanupFailure(err, req.IDs)
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
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 {
|
if err := h.Service.DeleteTenantsBulk(c.Context(), req.IDs); err != nil {
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
|
h.refreshOrgChartSnapshotCacheAfterTenantChange(c.Context(), "tenants_bulk_deleted")
|
||||||
|
|
||||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
||||||
"message": "Tenants deleted successfully",
|
"message": "Tenants deleted successfully",
|
||||||
@@ -2328,6 +2375,362 @@ func (h *TenantHandler) DeleteTenantsBulk(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func uniqueNonEmptyStrings(values []string) []string {
|
||||||
|
seen := make(map[string]bool, len(values))
|
||||||
|
result := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" || seen[trimmed] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[trimmed] = true
|
||||||
|
result = append(result, trimmed)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TenantHandler) reassignUserMembershipsBeforeTenantDelete(ctx context.Context, tenantIDs []string) error {
|
||||||
|
if h == nil || h.DB == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
deletedIDs := uniqueNonEmptyStrings(tenantIDs)
|
||||||
|
if len(deletedIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
affectedIDs, err := h.deletedTenantIDsReferencedByUsers(ctx, deletedIDs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(affectedIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var tenants []domain.Tenant
|
||||||
|
if err := h.DB.WithContext(ctx).Unscoped().Find(&tenants).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
targets, err := resolveTenantDeletionPromotionTargets(tenants, deletedIDs, affectedIDs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var affectedUsers []domain.User
|
||||||
|
if err := h.DB.WithContext(ctx).
|
||||||
|
Where("tenant_id IN ?", affectedIDs).
|
||||||
|
Find(&affectedUsers).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tenantByID := make(map[string]domain.Tenant, len(tenants))
|
||||||
|
for _, tenant := range tenants {
|
||||||
|
tenantByID[tenant.ID] = tenant
|
||||||
|
}
|
||||||
|
if err := h.promoteKratosUserMembershipsForTenantDelete(ctx, affectedUsers, tenantByID, targets); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return h.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
for deletedID, targetID := range targets {
|
||||||
|
if err := tx.Model(&domain.User{}).
|
||||||
|
Where("tenant_id = ?", deletedID).
|
||||||
|
Updates(map[string]any{"tenant_id": targetID, "updated_at": time.Now()}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Model(&domain.UserLoginID{}).
|
||||||
|
Where("tenant_id = ?", deletedID).
|
||||||
|
Update("tenant_id", targetID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TenantHandler) promoteKratosUserMembershipsForTenantDelete(ctx context.Context, users []domain.User, tenantByID map[string]domain.Tenant, targets map[string]string) error {
|
||||||
|
if h == nil || h.KratosAdmin == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, user := range users {
|
||||||
|
if user.TenantID == nil || strings.TrimSpace(user.ID) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
deletedID := strings.TrimSpace(*user.TenantID)
|
||||||
|
targetID := strings.TrimSpace(targets[deletedID])
|
||||||
|
if deletedID == "" || targetID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
deletedTenant, ok := tenantByID[deletedID]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("deleted tenant not found while promoting user membership: %s", deletedID)
|
||||||
|
}
|
||||||
|
targetTenant, ok := tenantByID[targetID]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("promotion target tenant not found while promoting user membership: %s", targetID)
|
||||||
|
}
|
||||||
|
identity, err := h.KratosAdmin.GetIdentity(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get kratos identity for tenant promotion user=%s: %w", user.ID, err)
|
||||||
|
}
|
||||||
|
if identity == nil {
|
||||||
|
return fmt.Errorf("kratos identity not found for tenant promotion user=%s", user.ID)
|
||||||
|
}
|
||||||
|
traits, changed := promoteIdentityTraitsFromDeletedTenant(identity.Traits, deletedTenant, targetTenant, true)
|
||||||
|
if !changed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
updated, err := h.KratosAdmin.UpdateIdentity(ctx, user.ID, traits, identity.State)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update kratos identity for tenant promotion user=%s: %w", user.ID, err)
|
||||||
|
}
|
||||||
|
if updated == nil {
|
||||||
|
identity.Traits = traits
|
||||||
|
h.storePromotedIdentityMirror(*identity)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h.storePromotedIdentityMirror(*updated)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func promoteIdentityTraitsFromDeletedTenant(traits map[string]any, deletedTenant domain.Tenant, targetTenant domain.Tenant, forcePrimary bool) (map[string]any, bool) {
|
||||||
|
next := cloneIdentityTraits(traits)
|
||||||
|
changed := false
|
||||||
|
primaryChanged := forcePrimary
|
||||||
|
if traitStringEqualTenant(next["tenant_id"], deletedTenant.ID, deletedTenant.Slug) || forcePrimary {
|
||||||
|
next["tenant_id"] = targetTenant.ID
|
||||||
|
changed = true
|
||||||
|
primaryChanged = true
|
||||||
|
}
|
||||||
|
if traitStringEqualTenant(next["primaryTenantId"], deletedTenant.ID, deletedTenant.Slug) || forcePrimary {
|
||||||
|
next["primaryTenantId"] = targetTenant.ID
|
||||||
|
changed = true
|
||||||
|
primaryChanged = true
|
||||||
|
}
|
||||||
|
if traitStringEqualTenant(next["primaryTenantSlug"], deletedTenant.ID, deletedTenant.Slug) || forcePrimary {
|
||||||
|
next["primaryTenantSlug"] = targetTenant.Slug
|
||||||
|
changed = true
|
||||||
|
primaryChanged = true
|
||||||
|
}
|
||||||
|
if traitStringEqualTenant(next["primaryTenantName"], deletedTenant.Name, "") || forcePrimary {
|
||||||
|
next["primaryTenantName"] = targetTenant.Name
|
||||||
|
changed = true
|
||||||
|
primaryChanged = true
|
||||||
|
}
|
||||||
|
if traitStringEqualTenant(next["companyCode"], deletedTenant.ID, deletedTenant.Slug) {
|
||||||
|
next["companyCode"] = targetTenant.Slug
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if traitStringEqualTenant(next["company_code"], deletedTenant.ID, deletedTenant.Slug) {
|
||||||
|
next["company_code"] = targetTenant.Slug
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
appointments, appointmentsChanged := promoteIdentityAppointmentsFromDeletedTenant(next["additionalAppointments"], deletedTenant, targetTenant)
|
||||||
|
if appointmentsChanged {
|
||||||
|
next["additionalAppointments"] = appointments
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if primaryChanged {
|
||||||
|
next["primaryTenantId"] = targetTenant.ID
|
||||||
|
next["primaryTenantSlug"] = targetTenant.Slug
|
||||||
|
next["primaryTenantName"] = targetTenant.Name
|
||||||
|
}
|
||||||
|
return next, changed
|
||||||
|
}
|
||||||
|
|
||||||
|
func promoteIdentityAppointmentsFromDeletedTenant(raw any, deletedTenant domain.Tenant, targetTenant domain.Tenant) ([]any, bool) {
|
||||||
|
switch appointments := raw.(type) {
|
||||||
|
case []any:
|
||||||
|
next := make([]any, 0, len(appointments))
|
||||||
|
changed := false
|
||||||
|
for _, rawAppointment := range appointments {
|
||||||
|
appointment, ok := rawAppointment.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
next = append(next, rawAppointment)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
copied := maps.Clone(appointment)
|
||||||
|
if identityAppointmentMatchesTenant(copied, deletedTenant) {
|
||||||
|
copied["tenantId"] = targetTenant.ID
|
||||||
|
copied["tenantSlug"] = targetTenant.Slug
|
||||||
|
copied["tenantName"] = targetTenant.Name
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
next = append(next, copied)
|
||||||
|
}
|
||||||
|
return next, changed
|
||||||
|
case []map[string]any:
|
||||||
|
next := make([]any, 0, len(appointments))
|
||||||
|
changed := false
|
||||||
|
for _, appointment := range appointments {
|
||||||
|
copied := maps.Clone(appointment)
|
||||||
|
if identityAppointmentMatchesTenant(copied, deletedTenant) {
|
||||||
|
copied["tenantId"] = targetTenant.ID
|
||||||
|
copied["tenantSlug"] = targetTenant.Slug
|
||||||
|
copied["tenantName"] = targetTenant.Name
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
next = append(next, copied)
|
||||||
|
}
|
||||||
|
return next, changed
|
||||||
|
default:
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func identityAppointmentMatchesTenant(appointment map[string]any, tenant domain.Tenant) bool {
|
||||||
|
return traitStringEqualTenant(appointment["tenantId"], tenant.ID, tenant.Slug) ||
|
||||||
|
traitStringEqualTenant(appointment["tenantSlug"], tenant.ID, tenant.Slug) ||
|
||||||
|
traitStringEqualTenant(appointment["tenantName"], tenant.Name, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func traitStringEqualTenant(value any, id string, slug string) bool {
|
||||||
|
raw := strings.TrimSpace(fmt.Sprint(value))
|
||||||
|
if raw == "" || raw == "<nil>" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.EqualFold(raw, strings.TrimSpace(id)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(slug) != "" && strings.EqualFold(raw, strings.TrimSpace(slug))
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneIdentityTraits(traits map[string]any) map[string]any {
|
||||||
|
if traits == nil {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
next := make(map[string]any, len(traits))
|
||||||
|
for key, value := range traits {
|
||||||
|
next[key] = cloneIdentityTraitValue(value)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneIdentityTraitValue(value any) any {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
next := make(map[string]any, len(typed))
|
||||||
|
for key, nested := range typed {
|
||||||
|
next[key] = cloneIdentityTraitValue(nested)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
case []any:
|
||||||
|
next := make([]any, len(typed))
|
||||||
|
for i, nested := range typed {
|
||||||
|
next[i] = cloneIdentityTraitValue(nested)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
case []map[string]any:
|
||||||
|
next := make([]any, len(typed))
|
||||||
|
for i, nested := range typed {
|
||||||
|
next[i] = cloneIdentityTraitValue(nested)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
default:
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TenantHandler) storePromotedIdentityMirror(identity service.KratosIdentity) {
|
||||||
|
if h == nil || h.IdentityCache == nil || strings.TrimSpace(identity.ID) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(identity)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.IdentityCache.Set(identityMirrorKey(identity.ID), string(raw), 0)
|
||||||
|
_ = h.IdentityCache.Delete("identity:mirror:state")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TenantHandler) deletedTenantIDsReferencedByUsers(ctx context.Context, tenantIDs []string) ([]string, error) {
|
||||||
|
referenced := make(map[string]bool)
|
||||||
|
|
||||||
|
var userTenantIDs []string
|
||||||
|
if err := h.DB.WithContext(ctx).
|
||||||
|
Model(&domain.User{}).
|
||||||
|
Where("tenant_id IN ?", tenantIDs).
|
||||||
|
Distinct("tenant_id").
|
||||||
|
Pluck("tenant_id", &userTenantIDs).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, tenantID := range userTenantIDs {
|
||||||
|
if tenantID != "" {
|
||||||
|
referenced[tenantID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var loginTenantIDs []string
|
||||||
|
if err := h.DB.WithContext(ctx).
|
||||||
|
Model(&domain.UserLoginID{}).
|
||||||
|
Where("tenant_id IN ?", tenantIDs).
|
||||||
|
Distinct("tenant_id").
|
||||||
|
Pluck("tenant_id", &loginTenantIDs).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, tenantID := range loginTenantIDs {
|
||||||
|
if tenantID != "" {
|
||||||
|
referenced[tenantID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]string, 0, len(referenced))
|
||||||
|
for _, tenantID := range tenantIDs {
|
||||||
|
if referenced[tenantID] {
|
||||||
|
result = append(result, tenantID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveTenantDeletionPromotionTargets(tenants []domain.Tenant, deletedTenantIDs []string, affectedTenantIDs []string) (map[string]string, error) {
|
||||||
|
deleted := make(map[string]bool, len(deletedTenantIDs))
|
||||||
|
for _, tenantID := range deletedTenantIDs {
|
||||||
|
tenantID = strings.TrimSpace(tenantID)
|
||||||
|
if tenantID != "" {
|
||||||
|
deleted[tenantID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tenantByID := make(map[string]domain.Tenant, len(tenants))
|
||||||
|
for _, tenant := range tenants {
|
||||||
|
if strings.TrimSpace(tenant.ID) != "" {
|
||||||
|
tenantByID[tenant.ID] = tenant
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
targets := make(map[string]string, len(affectedTenantIDs))
|
||||||
|
for _, affectedID := range uniqueNonEmptyStrings(affectedTenantIDs) {
|
||||||
|
tenant, ok := tenantByID[affectedID]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("tenant %s not found for membership reassignment", affectedID)
|
||||||
|
}
|
||||||
|
|
||||||
|
visited := map[string]bool{affectedID: true}
|
||||||
|
for {
|
||||||
|
if tenant.ParentID == nil || strings.TrimSpace(*tenant.ParentID) == "" || *tenant.ParentID == tenant.ID {
|
||||||
|
return nil, fmt.Errorf("tenant %s cannot be deleted while referenced by users because it has no remaining parent tenant", affectedID)
|
||||||
|
}
|
||||||
|
parentID := strings.TrimSpace(*tenant.ParentID)
|
||||||
|
if visited[parentID] {
|
||||||
|
return nil, fmt.Errorf("tenant %s cannot be reassigned because its parent chain has a cycle", affectedID)
|
||||||
|
}
|
||||||
|
visited[parentID] = true
|
||||||
|
|
||||||
|
parent, ok := tenantByID[parentID]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("tenant %s cannot be reassigned because parent tenant %s was not found", affectedID, parentID)
|
||||||
|
}
|
||||||
|
if !deleted[parentID] && !parent.DeletedAt.Valid {
|
||||||
|
targets[affectedID] = parent.ID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
tenant = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return targets, nil
|
||||||
|
}
|
||||||
|
|
||||||
func mapTenantSummary(t domain.Tenant) tenantSummary {
|
func mapTenantSummary(t domain.Tenant) tenantSummary {
|
||||||
domains := make([]string, 0, len(t.Domains))
|
domains := make([]string, 0, len(t.Domains))
|
||||||
for _, d := range t.Domains {
|
for _, d := range t.Domains {
|
||||||
@@ -2673,7 +3076,7 @@ func buildOrgContextTree(rootID string, tenants []domain.Tenant, tenantByID map[
|
|||||||
return build(rootID)
|
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))
|
counts := make(map[string]int64, len(tenants))
|
||||||
for _, tenant := range tenants {
|
for _, tenant := range tenants {
|
||||||
counts[tenant.ID] = 0
|
counts[tenant.ID] = 0
|
||||||
@@ -2681,27 +3084,78 @@ func (h *TenantHandler) countTenantMembersFromProjection(ctx context.Context, te
|
|||||||
if len(tenants) == 0 {
|
if len(tenants) == 0 {
|
||||||
return counts, counts, nil
|
return counts, counts, nil
|
||||||
}
|
}
|
||||||
if h.UserProjectionRepo == nil {
|
if h.UserRepo == nil {
|
||||||
return nil, nil, errors.New("user projection is not configured")
|
return counts, counts, nil
|
||||||
}
|
}
|
||||||
ready, err := h.UserProjectionRepo.IsReady(ctx)
|
|
||||||
if err != nil {
|
tenantIDs := make([]string, 0, len(tenants))
|
||||||
return nil, nil, fmt.Errorf("user projection status unavailable: %w", err)
|
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.UserRepo.CountByTenantIDs(ctx, tenantIDs)
|
||||||
}
|
|
||||||
directCounts, err := h.UserProjectionRepo.CountTenantMembers(ctx, tenants)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
totalCounts, err := h.UserProjectionRepo.CountTenantMembersRecursive(ctx, tenants)
|
|
||||||
if err != nil {
|
totalCounts := make(map[string]int64, len(tenants))
|
||||||
return nil, nil, err
|
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
|
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 {
|
func normalizeTenantStatus(value string) string {
|
||||||
value = strings.ToLower(strings.TrimSpace(value))
|
value = strings.ToLower(strings.TrimSpace(value))
|
||||||
if value == "" {
|
if value == "" {
|
||||||
@@ -2763,7 +3217,6 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
|
|||||||
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||||
cacheMode := strings.ToLower(strings.TrimSpace(c.Query("cache")))
|
cacheMode := strings.ToLower(strings.TrimSpace(c.Query("cache")))
|
||||||
cacheKey := orgChartSnapshotCacheKey(profile, c.Get("X-Tenant-ID"))
|
cacheKey := orgChartSnapshotCacheKey(profile, c.Get("X-Tenant-ID"))
|
||||||
ttl := orgChartSnapshotCacheTTL()
|
|
||||||
role, userID, profileTenantID := orgChartProfileLogValues(profile)
|
role, userID, profileTenantID := orgChartProfileLogValues(profile)
|
||||||
slog.Info("orgchart snapshot request started",
|
slog.Info("orgchart snapshot request started",
|
||||||
"user_id", userID,
|
"user_id", userID,
|
||||||
@@ -2778,9 +3231,8 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
|
|||||||
var cached orgChartSnapshotResponse
|
var cached orgChartSnapshotResponse
|
||||||
if err := json.Unmarshal([]byte(raw), &cached); err == nil {
|
if err := json.Unmarshal([]byte(raw), &cached); err == nil {
|
||||||
cached.Cache = orgChartSnapshotCacheInfo{
|
cached.Cache = orgChartSnapshotCacheInfo{
|
||||||
Source: "redis",
|
Source: "redis",
|
||||||
Hit: true,
|
Hit: true,
|
||||||
TTLSeconds: int(ttl.Seconds()),
|
|
||||||
}
|
}
|
||||||
c.Set("X-Orgfront-Cache", "HIT")
|
c.Set("X-Orgfront-Cache", "HIT")
|
||||||
slog.Info("orgchart snapshot 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())
|
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
|
||||||
}
|
}
|
||||||
snapshot.Cache = orgChartSnapshotCacheInfo{
|
snapshot.Cache = orgChartSnapshotCacheInfo{
|
||||||
Source: "database",
|
Source: "database",
|
||||||
Hit: false,
|
Hit: false,
|
||||||
TTLSeconds: int(ttl.Seconds()),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if cacheMode == "redis" && h.OrgChartCache != nil {
|
if cacheMode == "redis" && h.OrgChartCache != nil {
|
||||||
if raw, err := json.Marshal(snapshot); err == 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",
|
slog.Warn("orgchart snapshot cache write failed",
|
||||||
"user_id", userID,
|
"user_id", userID,
|
||||||
"role", role,
|
"role", role,
|
||||||
@@ -2858,13 +3309,68 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
|
|||||||
return c.JSON(snapshot)
|
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) {
|
func (h *TenantHandler) buildOrgChartSnapshot(ctx context.Context, profile *domain.UserProfileResponse) (orgChartSnapshotResponse, error) {
|
||||||
tenants, err := h.listOrgChartTenantsForProfile(ctx, profile)
|
tenants, err := h.listOrgChartTenantsForProfile(ctx, profile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return orgChartSnapshotResponse{}, err
|
return orgChartSnapshotResponse{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
memberCounts, totalMemberCounts, err := h.countTenantMembersFromProjection(ctx, tenants)
|
memberCounts, totalMemberCounts, err := h.countTenantMembers(ctx, tenants)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return orgChartSnapshotResponse{}, err
|
return orgChartSnapshotResponse{}, err
|
||||||
}
|
}
|
||||||
@@ -3002,7 +3508,10 @@ func orgChartSnapshotCacheKey(profile *domain.UserProfileResponse, tenantHeader
|
|||||||
if profile != nil {
|
if profile != nil {
|
||||||
role = domain.NormalizeRole(profile.Role)
|
role = domain.NormalizeRole(profile.Role)
|
||||||
userID = strings.TrimSpace(profile.ID)
|
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)
|
tenantID = strings.TrimSpace(*profile.TenantID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3012,7 +3521,7 @@ func orgChartSnapshotCacheKey(profile *domain.UserProfileResponse, tenantHeader
|
|||||||
if tenantID == "" {
|
if tenantID == "" {
|
||||||
tenantID = "none"
|
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) {
|
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 {
|
func orgChartSnapshotCacheExpiration() time.Duration {
|
||||||
const defaultTTL = 5 * time.Minute
|
return 0
|
||||||
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 (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
|
func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
|
"baron-sso-backend/internal/service"
|
||||||
"baron-sso-backend/internal/testsupport"
|
"baron-sso-backend/internal/testsupport"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
@@ -15,6 +16,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
"github.com/testcontainers/testcontainers-go"
|
"github.com/testcontainers/testcontainers-go"
|
||||||
postgres_module "github.com/testcontainers/testcontainers-go/modules/postgres"
|
postgres_module "github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||||
"github.com/testcontainers/testcontainers-go/wait"
|
"github.com/testcontainers/testcontainers-go/wait"
|
||||||
@@ -56,8 +58,8 @@ func newTenantHandlerSeedDeleteDB(t *testing.T) *gorm.DB {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to open postgres connection: %v", err)
|
t.Fatalf("failed to open postgres connection: %v", err)
|
||||||
}
|
}
|
||||||
if err := db.AutoMigrate(&domain.Tenant{}); err != nil {
|
if err := db.AutoMigrate(&domain.Tenant{}, &domain.User{}, &domain.UserLoginID{}); err != nil {
|
||||||
t.Fatalf("failed to migrate tenants: %v", err)
|
t.Fatalf("failed to migrate tenant delete models: %v", err)
|
||||||
}
|
}
|
||||||
return db
|
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) {
|
func TestTenantHandlerDeleteTenantsBulkRejectsSeedTenant(t *testing.T) {
|
||||||
setSeedTenantCSVForDeleteGuard(t, "protected-root")
|
setSeedTenantCSVForDeleteGuard(t, "protected-root")
|
||||||
db := newTenantHandlerSeedDeleteDB(t)
|
db := newTenantHandlerSeedDeleteDB(t)
|
||||||
@@ -157,3 +290,78 @@ func TestTenantHandlerDeleteTenantsBulkRejectsSeedTenant(t *testing.T) {
|
|||||||
t.Fatalf("remaining tenant count = %d, want 2", count)
|
t.Fatalf("remaining tenant count = %d, want 2", count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTenantHandlerDeleteTenantsBulkReassignsUsersToNearestRemainingAncestor(t *testing.T) {
|
||||||
|
db := newTenantHandlerSeedDeleteDB(t)
|
||||||
|
root := domain.Tenant{
|
||||||
|
ID: "10000000-0000-0000-0000-000000000011",
|
||||||
|
Name: "Root",
|
||||||
|
Slug: "delete-policy-root",
|
||||||
|
Type: domain.TenantTypeCompanyGroup,
|
||||||
|
Status: domain.TenantStatusActive,
|
||||||
|
}
|
||||||
|
parent := domain.Tenant{
|
||||||
|
ID: "10000000-0000-0000-0000-000000000012",
|
||||||
|
Name: "Parent",
|
||||||
|
Slug: "delete-policy-bulk-parent",
|
||||||
|
Type: domain.TenantTypeCompany,
|
||||||
|
ParentID: &root.ID,
|
||||||
|
Status: domain.TenantStatusActive,
|
||||||
|
}
|
||||||
|
child := domain.Tenant{
|
||||||
|
ID: "10000000-0000-0000-0000-000000000013",
|
||||||
|
Name: "Collaboration",
|
||||||
|
Slug: "delete-policy-bulk-collaboration",
|
||||||
|
Type: domain.TenantTypeUserGroup,
|
||||||
|
ParentID: &parent.ID,
|
||||||
|
Status: domain.TenantStatusActive,
|
||||||
|
}
|
||||||
|
user := domain.User{
|
||||||
|
ID: "10000000-0000-0000-0000-000000000111",
|
||||||
|
Email: "bulk-delete-policy-user@example.com",
|
||||||
|
Name: "Bulk Delete Policy User",
|
||||||
|
Role: domain.RoleUser,
|
||||||
|
TenantID: &child.ID,
|
||||||
|
}
|
||||||
|
if err := db.Create(&root).Error; err != nil {
|
||||||
|
t.Fatalf("failed to create root tenant: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&parent).Error; err != nil {
|
||||||
|
t.Fatalf("failed to create parent tenant: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&child).Error; err != nil {
|
||||||
|
t.Fatalf("failed to create child tenant: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&user).Error; err != nil {
|
||||||
|
t.Fatalf("failed to create user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mockSvc := new(MockTenantService)
|
||||||
|
mockSvc.On("DeleteTenantsBulk", mock.Anything, []string{parent.ID, child.ID}).Return(nil).Once()
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
app.Use(func(c *fiber.Ctx) error {
|
||||||
|
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
|
||||||
|
return c.Next()
|
||||||
|
})
|
||||||
|
app.Delete("/tenants/bulk", (&TenantHandler{DB: db, Service: mockSvc}).DeleteTenantsBulk)
|
||||||
|
body, _ := json.Marshal(map[string][]string{"ids": {parent.ID, child.ID}})
|
||||||
|
req := httptest.NewRequest(http.MethodDelete, "/tenants/bulk", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
var foundUser domain.User
|
||||||
|
if err := db.First(&foundUser, "id = ?", user.ID).Error; err != nil {
|
||||||
|
t.Fatalf("failed to reload user: %v", err)
|
||||||
|
}
|
||||||
|
if foundUser.TenantID == nil || *foundUser.TenantID != root.ID {
|
||||||
|
t.Fatalf("user tenant_id = %v, want %s", foundUser.TenantID, root.ID)
|
||||||
|
}
|
||||||
|
mockSvc.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|||||||
@@ -113,7 +113,16 @@ func (m *MockUserRepoForHandler) DB() *gorm.DB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockUserRepoForHandler) Create(ctx context.Context, user *domain.User) error { return nil }
|
func (m *MockUserRepoForHandler) 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 {
|
func (m *MockUserRepoForHandler) Delete(ctx context.Context, id string) error {
|
||||||
m.deletedIDs = append(m.deletedIDs, id)
|
m.deletedIDs = append(m.deletedIDs, id)
|
||||||
return nil
|
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) {
|
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) {
|
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
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type MockUserProjectionRepoForHandler struct {
|
|
||||||
mock.Mock
|
|
||||||
}
|
|
||||||
|
|
||||||
type mockOrgChartCache struct {
|
type mockOrgChartCache struct {
|
||||||
mock.Mock
|
mock.Mock
|
||||||
values map[string]string
|
values map[string]string
|
||||||
@@ -210,40 +228,9 @@ func (m *mockOrgChartCache) Set(key string, value string, expiration time.Durati
|
|||||||
return args.Error(0)
|
return args.Error(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockUserProjectionRepoForHandler) IsReady(ctx context.Context) (bool, error) {
|
func (m *mockOrgChartCache) DeleteByPrefix(ctx context.Context, prefix string) (int64, error) {
|
||||||
args := m.Called(ctx)
|
args := m.Called(prefix)
|
||||||
return args.Bool(0), args.Error(1)
|
return args.Get(0).(int64), 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 toJSONString(t *testing.T, value any) string {
|
func toJSONString(t *testing.T, value any) string {
|
||||||
@@ -281,14 +268,14 @@ func TestTenantHandler_CreateTenant(t *testing.T) {
|
|||||||
assert.Equal(t, "t1", got["id"])
|
assert.Equal(t, "t1", got["id"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTenantHandler_ListTenantsUsesReadyUserProjectionCountsWithoutKratos(t *testing.T) {
|
func TestTenantHandler_ListTenantsUsesUserRepositoryCounts(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
mockSvc := new(MockTenantService)
|
mockSvc := new(MockTenantService)
|
||||||
mockProjection := new(MockUserProjectionRepoForHandler)
|
mockUsers := new(MockUserRepoForHandler)
|
||||||
|
|
||||||
h := &TenantHandler{
|
h := &TenantHandler{
|
||||||
Service: mockSvc,
|
Service: mockSvc,
|
||||||
UserProjectionRepo: mockProjection,
|
UserRepo: mockUsers,
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Use(func(c *fiber.Ctx) error {
|
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"},
|
{ID: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"},
|
||||||
}
|
}
|
||||||
mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").Return(tenants, int64(1), nil).Once()
|
mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").Return(tenants, int64(1), nil).Once()
|
||||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
|
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil).Once()
|
||||||
mockProjection.On("CountTenantMembers", mock.Anything, tenants).
|
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"00000000-0000-0000-0000-000000000001"}).
|
||||||
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once()
|
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once()
|
||||||
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).
|
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"00000000-0000-0000-0000-000000000001"}, "").
|
||||||
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 7}, nil).Once()
|
Return([]domain.User{}, int64(7), "", nil).Once()
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
|
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
|
||||||
resp, _ := app.Test(req)
|
resp, _ := app.Test(req)
|
||||||
@@ -320,7 +307,7 @@ func TestTenantHandler_ListTenantsUsesReadyUserProjectionCountsWithoutKratos(t *
|
|||||||
require.Len(t, res.Items, 1)
|
require.Len(t, res.Items, 1)
|
||||||
assert.Equal(t, int64(2), res.Items[0].MemberCount)
|
assert.Equal(t, int64(2), res.Items[0].MemberCount)
|
||||||
assert.Equal(t, int64(7), res.Items[0].TotalMemberCount)
|
assert.Equal(t, int64(7), res.Items[0].TotalMemberCount)
|
||||||
mockProjection.AssertExpectations(t)
|
mockUsers.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTenantHandler_GetOrgChartSnapshotReturnsRedisCacheHit(t *testing.T) {
|
func TestTenantHandler_GetOrgChartSnapshotReturnsRedisCacheHit(t *testing.T) {
|
||||||
@@ -353,7 +340,6 @@ func TestTenantHandler_GetOrgChartSnapshotReturnsRedisCacheHit(t *testing.T) {
|
|||||||
func TestTenantHandler_GetOrgChartSnapshotCachesMissResult(t *testing.T) {
|
func TestTenantHandler_GetOrgChartSnapshotCachesMissResult(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
mockSvc := new(MockTenantService)
|
mockSvc := new(MockTenantService)
|
||||||
mockProjection := new(MockUserProjectionRepoForHandler)
|
|
||||||
mockUsers := new(MockUserRepoForHandler)
|
mockUsers := new(MockUserRepoForHandler)
|
||||||
cache := &mockOrgChartCache{}
|
cache := &mockOrgChartCache{}
|
||||||
now := time.Date(2026, 6, 9, 0, 0, 0, 0, time.UTC)
|
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("Get", mock.Anything).Return("", redis.Nil).Once()
|
||||||
cache.On("Set", mock.MatchedBy(func(key string) bool {
|
cache.On("Set", mock.MatchedBy(func(key string) bool {
|
||||||
return strings.HasPrefix(key, "orgchart:snapshot:")
|
return strings.HasPrefix(key, "orgchart:snapshot:")
|
||||||
}), mock.Anything, mock.AnythingOfType("time.Duration")).Return(nil).Once()
|
}), mock.Anything, time.Duration(0)).Return(nil).Once()
|
||||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(2), 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()
|
mockSvc.On("ListJoinedTenants", mock.Anything, "user-1").Return([]domain.Tenant{tenants[1]}, nil).Once()
|
||||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
|
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID, samanID}).Return(map[string]int64{familyID: 0, samanID: 1}, nil).Once()
|
||||||
mockProjection.On("CountTenantMembers", mock.Anything, tenants).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()
|
||||||
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).Return(map[string]int64{familyID: 1, samanID: 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()
|
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 {
|
app.Use(func(c *fiber.Ctx) error {
|
||||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
|
||||||
return c.Next()
|
return c.Next()
|
||||||
@@ -401,14 +387,103 @@ func TestTenantHandler_GetOrgChartSnapshotCachesMissResult(t *testing.T) {
|
|||||||
require.Equal(t, int64(1), body.Tenants[0].TotalMemberCount)
|
require.Equal(t, int64(1), body.Tenants[0].TotalMemberCount)
|
||||||
cache.AssertExpectations(t)
|
cache.AssertExpectations(t)
|
||||||
mockSvc.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)
|
mockUsers.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTenantHandler_GetOrgChartSnapshotHandlesSelfParentHanmacFamily(t *testing.T) {
|
func TestTenantHandler_GetOrgChartSnapshotHandlesSelfParentHanmacFamily(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
mockSvc := new(MockTenantService)
|
mockSvc := new(MockTenantService)
|
||||||
mockProjection := new(MockUserProjectionRepoForHandler)
|
|
||||||
mockUsers := new(MockUserRepoForHandler)
|
mockUsers := new(MockUserRepoForHandler)
|
||||||
now := time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)
|
||||||
parent := func(id string) *string { return &id }
|
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},
|
{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 {
|
app.Use(func(c *fiber.Ctx) error {
|
||||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||||
ID: "user-1",
|
ID: "user-1",
|
||||||
@@ -438,14 +513,11 @@ func TestTenantHandler_GetOrgChartSnapshotHandlesSelfParentHanmacFamily(t *testi
|
|||||||
})
|
})
|
||||||
app.Get("/admin/orgchart/snapshot", h.GetOrgChartSnapshot)
|
app.Get("/admin/orgchart/snapshot", h.GetOrgChartSnapshot)
|
||||||
|
|
||||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Once()
|
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Twice()
|
||||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
|
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID, samanID, teamID}).Return(map[string]int64{familyID: 0, samanID: 1, teamID: 0}, nil).Once()
|
||||||
mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
|
mockUsers.On("List", mock.Anything, 0, 1, "", []string{familyID, samanID, teamID}, "").Return([]domain.User{}, int64(1), "", nil).Once()
|
||||||
return tenantSlugsMatch(got, "hanmac-family", "saman", "saman-platform")
|
mockUsers.On("List", mock.Anything, 0, 1, "", []string{samanID, teamID}, "").Return([]domain.User{}, int64(1), "", nil).Once()
|
||||||
})).Return(map[string]int64{familyID: 0, samanID: 1, teamID: 0}, nil).Once()
|
mockUsers.On("List", mock.Anything, 0, 1, "", []string{teamID}, "").Return([]domain.User{}, int64(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()
|
|
||||||
mockUsers.On("List", mock.Anything, 0, 10000, "", []string{familyID, samanID, teamID}, "").Return(users, int64(1), "", 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()
|
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.True(t, tenantSummarySlugsMatch(body.Tenants, "hanmac-family", "saman", "saman-platform"))
|
||||||
require.Len(t, body.Users, 1)
|
require.Len(t, body.Users, 1)
|
||||||
mockSvc.AssertExpectations(t)
|
mockSvc.AssertExpectations(t)
|
||||||
mockProjection.AssertExpectations(t)
|
|
||||||
mockUsers.AssertExpectations(t)
|
mockUsers.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTenantHandler_ListTenantsReturnsTotalMemberCountForDescendants(t *testing.T) {
|
func TestTenantHandler_ListTenantsReturnsTotalMemberCountForDescendants(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
mockSvc := new(MockTenantService)
|
mockSvc := new(MockTenantService)
|
||||||
mockProjection := new(MockUserProjectionRepoForHandler)
|
mockUsers := new(MockUserRepoForHandler)
|
||||||
|
|
||||||
h := &TenantHandler{
|
h := &TenantHandler{
|
||||||
Service: mockSvc,
|
Service: mockSvc,
|
||||||
UserProjectionRepo: mockProjection,
|
UserRepo: mockUsers,
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Use(func(c *fiber.Ctx) error {
|
app.Use(func(c *fiber.Ctx) error {
|
||||||
@@ -492,11 +563,13 @@ func TestTenantHandler_ListTenantsReturnsTotalMemberCountForDescendants(t *testi
|
|||||||
{ID: childID, Name: "Child", Slug: "child", ParentID: &parentID},
|
{ID: childID, Name: "Child", Slug: "child", ParentID: &parentID},
|
||||||
}
|
}
|
||||||
mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").Return(tenants, int64(2), nil).Once()
|
mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").Return(tenants, int64(2), nil).Once()
|
||||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
|
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(2), nil).Once()
|
||||||
mockProjection.On("CountTenantMembers", mock.Anything, tenants).
|
mockUsers.On("CountByTenantIDs", mock.Anything, []string{parentID, childID}).
|
||||||
Return(map[string]int64{parentID: 1, childID: 2}, nil).Once()
|
Return(map[string]int64{parentID: 1, childID: 2}, nil).Once()
|
||||||
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).
|
mockUsers.On("List", mock.Anything, 0, 1, "", []string{parentID, childID}, "").
|
||||||
Return(map[string]int64{parentID: 3, childID: 2}, nil).Once()
|
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)
|
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
|
||||||
resp, _ := app.Test(req)
|
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(3), res.Items[0].TotalMemberCount)
|
||||||
assert.Equal(t, int64(2), res.Items[1].MemberCount)
|
assert.Equal(t, int64(2), res.Items[1].MemberCount)
|
||||||
assert.Equal(t, int64(2), res.Items[1].TotalMemberCount)
|
assert.Equal(t, int64(2), res.Items[1].TotalMemberCount)
|
||||||
mockProjection.AssertExpectations(t)
|
mockUsers.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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTenantHandler_ListTenants(t *testing.T) {
|
func TestTenantHandler_ListTenants(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
mockSvc := new(MockTenantService)
|
mockSvc := new(MockTenantService)
|
||||||
mockProjection := new(MockUserProjectionRepoForHandler)
|
mockUsers := new(MockUserRepoForHandler)
|
||||||
|
|
||||||
h := &TenantHandler{
|
h := &TenantHandler{
|
||||||
Service: mockSvc,
|
Service: mockSvc,
|
||||||
UserProjectionRepo: mockProjection,
|
UserRepo: mockUsers,
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Use(func(c *fiber.Ctx) error {
|
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
|
// 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()
|
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()
|
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"t1", "t2"}).
|
||||||
mockProjection.On("CountTenantMembers", mock.Anything, tenants).
|
|
||||||
Return(map[string]int64{"t1": 5, "t2": 10}, nil).Once()
|
|
||||||
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).
|
|
||||||
Return(map[string]int64{"t1": 5, "t2": 10}, nil).Once()
|
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)
|
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
|
||||||
resp, _ := app.Test(req)
|
resp, _ := app.Test(req)
|
||||||
@@ -599,11 +639,11 @@ func TestTenantHandler_ListTenants(t *testing.T) {
|
|||||||
func TestTenantHandler_ListTenantsReturnsNextCursorWhenMoreRowsExist(t *testing.T) {
|
func TestTenantHandler_ListTenantsReturnsNextCursorWhenMoreRowsExist(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
mockSvc := new(MockTenantService)
|
mockSvc := new(MockTenantService)
|
||||||
mockProjection := new(MockUserProjectionRepoForHandler)
|
mockUsers := new(MockUserRepoForHandler)
|
||||||
|
|
||||||
h := &TenantHandler{
|
h := &TenantHandler{
|
||||||
Service: mockSvc,
|
Service: mockSvc,
|
||||||
UserProjectionRepo: mockProjection,
|
UserRepo: mockUsers,
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Use(func(c *fiber.Ctx) error {
|
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()
|
mockSvc.On("ListTenants", mock.Anything, 2, 0, "", "").Return(tenants, int64(3), nil).Once()
|
||||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
|
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(3), nil).Once()
|
||||||
mockProjection.On("CountTenantMembers", mock.Anything, tenants).Return(map[string]int64{}, nil).Once()
|
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"00000000-0000-0000-0000-000000000002", "00000000-0000-0000-0000-000000000001"}).Return(map[string]int64{}, nil).Once()
|
||||||
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).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)
|
req := httptest.NewRequest("GET", "/tenants?limit=2&offset=0", nil)
|
||||||
resp, _ := app.Test(req)
|
resp, _ := app.Test(req)
|
||||||
@@ -662,11 +703,11 @@ func TestPageTenantsByCursorUsesStableCreatedAtAndIDOrder(t *testing.T) {
|
|||||||
func TestTenantHandler_ListTenantsHidesPrivateSubtreeForUnauthorizedUser(t *testing.T) {
|
func TestTenantHandler_ListTenantsHidesPrivateSubtreeForUnauthorizedUser(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
mockSvc := new(MockTenantService)
|
mockSvc := new(MockTenantService)
|
||||||
mockProjection := new(MockUserProjectionRepoForHandler)
|
mockUsers := new(MockUserRepoForHandler)
|
||||||
|
|
||||||
h := &TenantHandler{
|
h := &TenantHandler{
|
||||||
Service: mockSvc,
|
Service: mockSvc,
|
||||||
UserProjectionRepo: mockProjection,
|
UserRepo: mockUsers,
|
||||||
}
|
}
|
||||||
|
|
||||||
parent := func(id string) *string { return &id }
|
parent := func(id string) *string { return &id }
|
||||||
@@ -688,14 +729,12 @@ func TestTenantHandler_ListTenantsHidesPrivateSubtreeForUnauthorizedUser(t *test
|
|||||||
})
|
})
|
||||||
app.Get("/tenants", h.ListTenants)
|
app.Get("/tenants", h.ListTenants)
|
||||||
|
|
||||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Once()
|
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Twice()
|
||||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
|
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Twice()
|
||||||
mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
|
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"family", "company", "public-team"}).Return(map[string]int64{}, nil).Once()
|
||||||
return tenantSlugsMatch(got, "hanmac-family", "hanmac", "public-team")
|
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"family", "company", "public-team", "private-team", "private-child"}, "").Return([]domain.User{}, int64(0), "", nil).Maybe()
|
||||||
})).Return(map[string]int64{}, nil).Once()
|
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"company", "public-team", "private-team", "private-child"}, "").Return([]domain.User{}, int64(0), "", nil).Maybe()
|
||||||
mockProjection.On("CountTenantMembersRecursive", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
|
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"public-team"}, "").Return([]domain.User{}, int64(0), "", nil).Maybe()
|
||||||
return tenantSlugsMatch(got, "hanmac-family", "hanmac", "public-team")
|
|
||||||
})).Return(map[string]int64{}, nil).Once()
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/tenants?limit=100&offset=0", nil)
|
req := httptest.NewRequest(http.MethodGet, "/tenants?limit=100&offset=0", nil)
|
||||||
resp, err := app.Test(req)
|
resp, err := app.Test(req)
|
||||||
@@ -712,11 +751,11 @@ func TestTenantHandler_ListTenantsHidesPrivateSubtreeForUnauthorizedUser(t *test
|
|||||||
func TestTenantHandler_ListTenantsShowsPrivateSubtreeForManageableTenant(t *testing.T) {
|
func TestTenantHandler_ListTenantsShowsPrivateSubtreeForManageableTenant(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
mockSvc := new(MockTenantService)
|
mockSvc := new(MockTenantService)
|
||||||
mockProjection := new(MockUserProjectionRepoForHandler)
|
mockUsers := new(MockUserRepoForHandler)
|
||||||
|
|
||||||
h := &TenantHandler{
|
h := &TenantHandler{
|
||||||
Service: mockSvc,
|
Service: mockSvc,
|
||||||
UserProjectionRepo: mockProjection,
|
UserRepo: mockUsers,
|
||||||
}
|
}
|
||||||
|
|
||||||
parent := func(id string) *string { return &id }
|
parent := func(id string) *string { return &id }
|
||||||
@@ -740,14 +779,10 @@ func TestTenantHandler_ListTenantsShowsPrivateSubtreeForManageableTenant(t *test
|
|||||||
})
|
})
|
||||||
app.Get("/tenants", h.ListTenants)
|
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()
|
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Once()
|
||||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
|
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"family", "company", "private-team", "private-child"}).Return(map[string]int64{}, nil).Once()
|
||||||
mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
|
mockUsers.On("List", mock.Anything, 0, 1, "", mock.Anything, "").Return([]domain.User{}, int64(0), "", nil).Maybe()
|
||||||
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()
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/tenants?limit=100&offset=0", nil)
|
req := httptest.NewRequest(http.MethodGet, "/tenants?limit=100&offset=0", nil)
|
||||||
resp, err := app.Test(req)
|
resp, err := app.Test(req)
|
||||||
@@ -761,6 +796,41 @@ func TestTenantHandler_ListTenantsShowsPrivateSubtreeForManageableTenant(t *test
|
|||||||
require.Contains(t, toJSONString(t, res), "private-child")
|
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) {
|
func TestTenantHandler_FilterPrivateTenantsAllowsExplicitPrivatePermission(t *testing.T) {
|
||||||
parent := func(id string) *string { return &id }
|
parent := func(id string) *string { return &id }
|
||||||
tenants := []domain.Tenant{
|
tenants := []domain.Tenant{
|
||||||
@@ -785,6 +855,41 @@ func TestTenantHandler_FilterPrivateTenantsAllowsExplicitPrivatePermission(t *te
|
|||||||
mockKeto.AssertExpectations(t)
|
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 {
|
func tenantSlugsMatch(got []domain.Tenant, want ...string) bool {
|
||||||
if len(got) != len(want) {
|
if len(got) != len(want) {
|
||||||
return false
|
return false
|
||||||
@@ -1127,47 +1232,14 @@ func TestTenantHandler_GetOrgContextJSONRequiresApiKey(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTenantHandler_ListTenantsReturnsServiceUnavailableWhenProjectionStatusFails(t *testing.T) {
|
func TestTenantHandler_ListTenantsUsesUserRepositoryCountsWhenAvailable(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) {
|
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
mockSvc := new(MockTenantService)
|
mockSvc := new(MockTenantService)
|
||||||
mockUserRepo := new(MockUserRepoForHandler)
|
mockUserRepo := new(MockUserRepoForHandler)
|
||||||
mockProjection := new(MockUserProjectionRepoForHandler)
|
|
||||||
|
|
||||||
h := &TenantHandler{
|
h := &TenantHandler{
|
||||||
Service: mockSvc,
|
Service: mockSvc,
|
||||||
UserRepo: mockUserRepo,
|
UserRepo: mockUserRepo,
|
||||||
UserProjectionRepo: mockProjection,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Use(func(c *fiber.Ctx) error {
|
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()
|
mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").Return(tenants, int64(1), nil).Once()
|
||||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
|
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), 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()
|
|
||||||
mockUserRepo.On("CountByTenantIDs", mock.Anything, []string{"00000000-0000-0000-0000-000000000001"}).
|
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)
|
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
|
||||||
resp, _ := app.Test(req)
|
resp, _ := app.Test(req)
|
||||||
@@ -1203,8 +1270,8 @@ func TestTenantHandler_ListTenantsUsesProjectionCountsWhenAvailable(t *testing.T
|
|||||||
json.NewDecoder(resp.Body).Decode(&res)
|
json.NewDecoder(resp.Body).Decode(&res)
|
||||||
|
|
||||||
assert.Len(t, res.Items, 1)
|
assert.Len(t, res.Items, 1)
|
||||||
assert.Equal(t, int64(2), res.Items[0].MemberCount)
|
assert.Equal(t, int64(152), res.Items[0].MemberCount)
|
||||||
mockProjection.AssertExpectations(t)
|
mockUserRepo.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTenantHandler_ExportTenantsCSV(t *testing.T) {
|
func TestTenantHandler_ExportTenantsCSV(t *testing.T) {
|
||||||
@@ -1718,6 +1785,46 @@ func TestTenantHandler_ApproveTenant(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
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 {
|
func (m *MockTenantService) DeleteTenantsBulk(ctx context.Context, tenantIDs []string) error {
|
||||||
args := m.Called(ctx, tenantIDs)
|
args := m.Called(ctx, tenantIDs)
|
||||||
return args.Error(0)
|
return args.Error(0)
|
||||||
|
|||||||
@@ -34,17 +34,16 @@ type OryProviderAPI interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UserHandler struct {
|
type UserHandler struct {
|
||||||
KratosAdmin service.KratosAdminService
|
KratosAdmin service.KratosAdminService
|
||||||
OryProvider OryProviderAPI
|
OryProvider OryProviderAPI
|
||||||
TenantService service.TenantService
|
TenantService service.TenantService
|
||||||
KetoService service.KetoService
|
KetoService service.KetoService
|
||||||
KetoOutboxRepo repository.KetoOutboxRepository
|
KetoOutboxRepo repository.KetoOutboxRepository
|
||||||
UserRepo repository.UserRepository
|
UserRepo repository.UserRepository
|
||||||
UserProjectionRepo repository.UserProjectionRepository
|
UserGroupRepo repository.UserGroupRepository
|
||||||
UserGroupRepo repository.UserGroupRepository
|
AuditRepo domain.AuditRepository
|
||||||
AuditRepo domain.AuditRepository
|
IdentityCache domain.RedisRepository
|
||||||
IdentityCache domain.RedisRepository
|
Worksmobile service.WorksmobileSyncer
|
||||||
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 {
|
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)
|
appointments = append(appointments, value)
|
||||||
}
|
}
|
||||||
return appointments
|
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:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -323,7 +332,7 @@ func sanitizeUserRepresentativeTenants(ctx context.Context, tenantService servic
|
|||||||
delete(metadata, "primaryTenantIsOwner")
|
delete(metadata, "primaryTenantIsOwner")
|
||||||
cleared = true
|
cleared = true
|
||||||
}
|
}
|
||||||
if isNonPublicRepresentativeTenant(ctx, tenantService, normalizeMetadataString(metadata["primaryTenantId"]), normalizeMetadataString(metadata["primaryTenantSlug"])) {
|
if isBlockedRepresentativeTenant(ctx, tenantService, normalizeMetadataString(metadata["primaryTenantId"]), normalizeMetadataString(metadata["primaryTenantSlug"])) {
|
||||||
clearMetadataPrimary()
|
clearMetadataPrimary()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,7 +345,7 @@ func sanitizeUserRepresentativeTenants(ctx context.Context, tenantService servic
|
|||||||
if tenantSlug == "" {
|
if tenantSlug == "" {
|
||||||
tenantSlug = normalizeMetadataString(appointment["slug"])
|
tenantSlug = normalizeMetadataString(appointment["slug"])
|
||||||
}
|
}
|
||||||
if !isNonPublicRepresentativeTenant(ctx, tenantService, tenantID, tenantSlug) {
|
if !isBlockedRepresentativeTenant(ctx, tenantService, tenantID, tenantSlug) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
appointment["isPrimary"] = false
|
appointment["isPrimary"] = false
|
||||||
@@ -359,7 +368,7 @@ func sanitizeUserRepresentativeTenants(ctx context.Context, tenantService servic
|
|||||||
return cleared, nil
|
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 tenant *domain.Tenant
|
||||||
var err error
|
var err error
|
||||||
if strings.TrimSpace(tenantID) != "" {
|
if strings.TrimSpace(tenantID) != "" {
|
||||||
@@ -371,7 +380,7 @@ func isNonPublicRepresentativeTenant(ctx context.Context, tenantService service.
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
visibility := tenantVisibility(tenant.Config)
|
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 {
|
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 {
|
func identityTenantAccessKeys(traits map[string]any) []string {
|
||||||
keys := make([]string, 0, 2)
|
keys := make([]string, 0, 4)
|
||||||
if tenantID := strings.ToLower(strings.TrimSpace(extractTraitString(traits, "tenant_id"))); tenantID != "" {
|
seen := make(map[string]bool)
|
||||||
keys = append(keys, tenantID)
|
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
|
return keys
|
||||||
}
|
}
|
||||||
@@ -673,6 +707,7 @@ func kratosIdentityCursorKey(identity service.KratosIdentity) (time.Time, string
|
|||||||
}
|
}
|
||||||
|
|
||||||
func identityMatchesSearch(identity service.KratosIdentity, searchLower string) bool {
|
func identityMatchesSearch(identity service.KratosIdentity, searchLower string) bool {
|
||||||
|
searchLower = strings.TrimSpace(searchLower)
|
||||||
if searchLower == "" {
|
if searchLower == "" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -685,6 +720,9 @@ func identityMatchesSearch(identity service.KratosIdentity, searchLower string)
|
|||||||
if strings.Contains(strings.ToLower(extractTraitString(identity.Traits, "name")), searchLower) {
|
if strings.Contains(strings.ToLower(extractTraitString(identity.Traits, "name")), searchLower) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if identityEmailLocalPartMatchesSearch(identity, searchLower) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
rawTraits, err := json.Marshal(identity.Traits)
|
rawTraits, err := json.Marshal(identity.Traits)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
@@ -692,6 +730,21 @@ func identityMatchesSearch(identity service.KratosIdentity, searchLower string)
|
|||||||
return strings.Contains(strings.ToLower(string(rawTraits)), searchLower)
|
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 {
|
func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||||
// [New] Get requester profile from middleware
|
// [New] Get requester profile from middleware
|
||||||
var requesterRole string
|
var requesterRole string
|
||||||
@@ -813,11 +866,11 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
|||||||
searchLower := strings.ToLower(search)
|
searchLower := strings.ToLower(search)
|
||||||
|
|
||||||
for _, identity := range identities {
|
for _, identity := range identities {
|
||||||
tID := strings.ToLower(extractTraitString(identity.Traits, "tenant_id"))
|
tenantAccessKeys := identityTenantAccessKeys(identity.Traits)
|
||||||
|
|
||||||
// Tenant Admin & Member filtering
|
// Tenant Admin & Member filtering
|
||||||
if requesterRole != domain.RoleSuperAdmin {
|
if requesterRole != domain.RoleSuperAdmin {
|
||||||
hasAccess := manageableSlugs[tID]
|
hasAccess := anyTenantKeyAllowed(tenantAccessKeys, manageableSlugs)
|
||||||
if !hasAccess {
|
if !hasAccess {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -825,7 +878,11 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
// Dedicated tenantSlug filter
|
// Dedicated tenantSlug filter
|
||||||
if tenantSlug != "" {
|
if tenantSlug != "" {
|
||||||
matches := tID == targetTenantID
|
targetKeys := map[string]bool{
|
||||||
|
targetTenantID: true,
|
||||||
|
strings.ToLower(tenantSlug): true,
|
||||||
|
}
|
||||||
|
matches := anyTenantKeyAllowed(tenantAccessKeys, targetKeys)
|
||||||
if !matches {
|
if !matches {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1195,6 +1252,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
// [Resolve TenantID and Custom Login IDs before Kratos creation]
|
// [Resolve TenantID and Custom Login IDs before Kratos creation]
|
||||||
var tenantID string
|
var tenantID string
|
||||||
|
var resolvedTenant *domain.Tenant
|
||||||
primaryAppointments := req.AdditionalAppointments
|
primaryAppointments := req.AdditionalAppointments
|
||||||
if representativeCleared {
|
if representativeCleared {
|
||||||
primaryAppointments = nil
|
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 {
|
if tenant, err := h.TenantService.GetTenant(c.Context(), requestedPrimaryTenantID); err == nil && tenant != nil {
|
||||||
tenantID = tenant.ID
|
tenantID = tenant.ID
|
||||||
req.CompanyCode = tenant.Slug
|
req.CompanyCode = tenant.Slug
|
||||||
|
resolvedTenant = tenant
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if req.CompanyCode != "" && h.TenantService != nil {
|
if req.CompanyCode != "" && h.TenantService != nil {
|
||||||
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode); err == nil && tenant != nil {
|
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode); err == nil && tenant != nil {
|
||||||
tenantID = tenant.ID
|
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 tenantID == "" {
|
||||||
if req.CompanyCode != "" || requestedPrimaryTenantID != "" {
|
if req.CompanyCode != "" || requestedPrimaryTenantID != "" {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, "invalid tenant assignment")
|
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)
|
tenant, err := createPersonalTenantForUser(c.Context(), h.TenantService, email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errorJSON(c, fiber.StatusServiceUnavailable, "failed to create personal tenant")
|
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)
|
// Sync to local DB (Synchronous for immediate consistency)
|
||||||
if err := h.UserRepo.Update(c.Context(), localUser); err != nil {
|
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)
|
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
|
// Update User Login IDs in local DB
|
||||||
for i := range loginIDRecords {
|
for i := range loginIDRecords {
|
||||||
loginIDRecords[i].UserID = localUser.ID
|
loginIDRecords[i].UserID = localUser.ID
|
||||||
}
|
}
|
||||||
if err := h.UserRepo.UpdateUserLoginIDs(c.Context(), localUser.ID, loginIDRecords); err != nil {
|
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)
|
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)
|
// [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)
|
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||||
results := make([]bulkUserResult, 0, len(req.Users))
|
results := make([]bulkUserResult, 0, len(req.Users))
|
||||||
var hanmacScope *hanmacEmailScope
|
var hanmacScope *hanmacEmailScope
|
||||||
var hanmacLocalParts map[string]bool
|
var hanmacLocalParts map[string]hanmacLocalPartOwner
|
||||||
hanmacScopeLoaded := false
|
hanmacScopeLoaded := false
|
||||||
bulkEmailErrors := validateBulkUserEmailUniqueness(req.Users)
|
bulkEmailErrors := validateBulkUserEmailUniqueness(req.Users)
|
||||||
|
|
||||||
@@ -1414,6 +1472,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
ID string
|
ID string
|
||||||
Slug string
|
Slug string
|
||||||
Name string
|
Name string
|
||||||
|
Type string
|
||||||
ParentID *string
|
ParentID *string
|
||||||
Schema []any
|
Schema []any
|
||||||
Groups []domain.UserGroup
|
Groups []domain.UserGroup
|
||||||
@@ -1428,6 +1487,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
ID: tenant.ID,
|
ID: tenant.ID,
|
||||||
Slug: tenant.Slug,
|
Slug: tenant.Slug,
|
||||||
Name: tenant.Name,
|
Name: tenant.Name,
|
||||||
|
Type: tenant.Type,
|
||||||
ParentID: tenant.ParentID,
|
ParentID: tenant.ParentID,
|
||||||
}
|
}
|
||||||
if s, ok := tenant.Config["userSchema"].([]any); ok {
|
if s, ok := tenant.Config["userSchema"].([]any); ok {
|
||||||
@@ -1558,6 +1618,10 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
tenantSlug = tItem.Slug
|
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 != "" {
|
} else if tenantSlug != "" {
|
||||||
tItem, err = resolveTenantBySlug(tenantSlug)
|
tItem, err = resolveTenantBySlug(tenantSlug)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1565,6 +1629,10 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
tenantSlug = tItem.Slug
|
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 {
|
} else {
|
||||||
for _, domainName := range bulkUserEmailDomainCandidates(item.EmailDomain, email) {
|
for _, domainName := range bulkUserEmailDomainCandidates(item.EmailDomain, email) {
|
||||||
if domainTenant, ok := resolveTenantByDomain(domainName); ok {
|
if domainTenant, ok := resolveTenantByDomain(domainName); ok {
|
||||||
@@ -1574,6 +1642,10 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if tenantSlug == "" {
|
if tenantSlug == "" {
|
||||||
|
if emailUsesInternalPersonalRestrictedDomain(email) {
|
||||||
|
results = append(results, bulkUserResult{Email: email, Success: false, Status: "blockingError", Message: internalDomainPersonalPolicyMessage(email)})
|
||||||
|
continue
|
||||||
|
}
|
||||||
tItem, err = createPersonalTenantItem(email)
|
tItem, err = createPersonalTenantItem(email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
results = append(results, bulkUserResult{Email: email, Success: false, Message: "failed to create personal tenant"})
|
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
|
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
|
// Role-based access check
|
||||||
if requester != nil && requester.Role != domain.RoleSuperAdmin {
|
if requester != nil && requester.Role != domain.RoleSuperAdmin {
|
||||||
@@ -1678,7 +1754,10 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
userEmail = emailEvaluation.Email
|
userEmail = emailEvaluation.Email
|
||||||
if emailEvaluation.LocalPart != "" {
|
if emailEvaluation.LocalPart != "" {
|
||||||
hanmacLocalParts[emailEvaluation.LocalPart] = true
|
hanmacLocalParts[emailEvaluation.LocalPart] = hanmacLocalPartOwner{
|
||||||
|
Email: userEmail,
|
||||||
|
Name: item.Name,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if _, _, err := domain.SplitEmailDomain(email); err != nil {
|
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 {
|
if err := h.UserRepo.Update(c.Context(), localUser); err != nil {
|
||||||
slog.Error("Failed to sync bulk user to local DB", "email", email, "error", err)
|
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
|
// Update User Login IDs in local DB
|
||||||
for i := range loginIDRecords {
|
for i := range loginIDRecords {
|
||||||
loginIDRecords[i].UserID = localUser.ID
|
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 {
|
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)
|
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{
|
results = append(results, bulkUserResult{
|
||||||
Email: userEmail,
|
Email: userEmail,
|
||||||
OriginalEmail: emailEvaluation.OriginalEmail,
|
OriginalEmail: emailEvaluation.OriginalEmail,
|
||||||
@@ -2165,6 +2236,8 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
|||||||
// Pre-fetch tenant cache if tenantSlug is being changed.
|
// Pre-fetch tenant cache if tenantSlug is being changed.
|
||||||
type tenantCacheItem struct {
|
type tenantCacheItem struct {
|
||||||
ID string
|
ID string
|
||||||
|
Slug string
|
||||||
|
Type string
|
||||||
Schema []any
|
Schema []any
|
||||||
}
|
}
|
||||||
tenantCache := make(map[string]tenantCacheItem)
|
tenantCache := make(map[string]tenantCacheItem)
|
||||||
@@ -2223,6 +2296,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
|||||||
if req.CompanyCode != nil {
|
if req.CompanyCode != nil {
|
||||||
delete(traits, "companyCode")
|
delete(traits, "companyCode")
|
||||||
delete(traits, "companyCodes")
|
delete(traits, "companyCodes")
|
||||||
|
var targetTenant *domain.Tenant
|
||||||
|
|
||||||
if req.IsAddTenant {
|
if req.IsAddTenant {
|
||||||
if h.TenantService == nil {
|
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"})
|
results = append(results, map[string]any{"id": id, "success": false, "message": "invalid tenant assignment"})
|
||||||
continue
|
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)
|
metadata := mergeUserAddTenantAppointment(traits, nil, tenant)
|
||||||
if appointments, ok := metadata["additionalAppointments"]; ok {
|
if appointments, ok := metadata["additionalAppointments"]; ok {
|
||||||
traits["additionalAppointments"] = appointments
|
traits["additionalAppointments"] = appointments
|
||||||
@@ -2249,12 +2327,22 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
} else if tItem, exists := tenantCache[*req.CompanyCode]; exists {
|
} else if tItem, exists := tenantCache[*req.CompanyCode]; exists {
|
||||||
traits["tenant_id"] = tItem.ID
|
traits["tenant_id"] = tItem.ID
|
||||||
|
targetTenant = &domain.Tenant{ID: tItem.ID, Slug: tItem.Slug, Type: tItem.Type}
|
||||||
} else if h.TenantService != nil {
|
} else if h.TenantService != nil {
|
||||||
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), *req.CompanyCode)
|
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), *req.CompanyCode)
|
||||||
if err == nil && tenant != nil {
|
if err == nil && tenant != nil {
|
||||||
tItem.ID = tenant.ID
|
tItem.ID = tenant.ID
|
||||||
|
tItem.Slug = tenant.Slug
|
||||||
|
tItem.Type = tenant.Type
|
||||||
tenantCache[*req.CompanyCode] = tItem
|
tenantCache[*req.CompanyCode] = tItem
|
||||||
traits["tenant_id"] = tenant.ID
|
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
|
localUser.JobTitle = *req.JobTitle
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve TenantID if changing tenantSlug.
|
// Resolve TenantID only for representative tenant changes.
|
||||||
if req.CompanyCode != nil && h.TenantService != nil {
|
if req.CompanyCode != nil && !req.IsAddTenant && h.TenantService != nil {
|
||||||
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), *req.CompanyCode); err == nil && tenant != nil {
|
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), *req.CompanyCode); err == nil && tenant != nil {
|
||||||
localUser.TenantID = &tenant.ID
|
localUser.TenantID = &tenant.ID
|
||||||
}
|
}
|
||||||
@@ -2315,7 +2403,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
_ = h.UserRepo.Update(c.Context(), localUser)
|
_ = h.UserRepo.Update(c.Context(), localUser)
|
||||||
if h.Worksmobile != nil {
|
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)
|
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"`
|
Status *string `json:"status"`
|
||||||
TenantSlug *string `json:"tenantSlug"`
|
TenantSlug *string `json:"tenantSlug"`
|
||||||
CompanyCode *string `json:"companyCode"`
|
CompanyCode *string `json:"companyCode"`
|
||||||
|
IsPrimaryTenant bool `json:"isPrimaryTenant"`
|
||||||
IsAddTenant bool `json:"isAddTenant"`
|
IsAddTenant bool `json:"isAddTenant"`
|
||||||
IsRemoveTenant bool `json:"isRemoveTenant"`
|
IsRemoveTenant bool `json:"isRemoveTenant"`
|
||||||
Department *string `json:"department"`
|
Department *string `json:"department"`
|
||||||
@@ -2498,6 +2587,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
req.PrimaryTenantID = ""
|
req.PrimaryTenantID = ""
|
||||||
req.PrimaryTenantName = ""
|
req.PrimaryTenantName = ""
|
||||||
req.PrimaryTenantIsOwner = nil
|
req.PrimaryTenantIsOwner = nil
|
||||||
|
req.IsPrimaryTenant = false
|
||||||
req.CompanyCode = nil
|
req.CompanyCode = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2592,6 +2682,17 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
if requester == nil || domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
|
if requester == nil || domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
|
||||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can change user email")
|
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 h.UserRepo != nil {
|
||||||
if existing, err := h.UserRepo.FindByEmail(c.Context(), nextEmail); err == nil && existing != nil && existing.ID != userID {
|
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")
|
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 {
|
if req.CompanyCode != nil {
|
||||||
code := strings.TrimSpace(*req.CompanyCode)
|
code := strings.TrimSpace(*req.CompanyCode)
|
||||||
|
var resolvedTenant *domain.Tenant
|
||||||
|
representativeTenantRequested := req.IsPrimaryTenant || strings.TrimSpace(req.PrimaryTenantID) != ""
|
||||||
|
|
||||||
if req.IsRemoveTenant {
|
if req.IsRemoveTenant {
|
||||||
if h.TenantService != nil && code != "" {
|
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 h.TenantService != nil && code != "" {
|
||||||
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code); err == nil && tenant != nil {
|
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code); err == nil && tenant != nil {
|
||||||
traits["tenant_id"] = tenant.ID
|
traits["tenant_id"] = tenant.ID
|
||||||
|
resolvedTenant = tenant
|
||||||
} else {
|
} else {
|
||||||
traits["tenant_id"] = ""
|
traits["tenant_id"] = ""
|
||||||
}
|
}
|
||||||
@@ -2652,6 +2756,9 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
if err != nil || tenant == nil {
|
if err != nil || tenant == nil {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, "invalid tenant assignment")
|
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)
|
req.Metadata = mergeUserAddTenantAppointment(traits, req.Metadata, tenant)
|
||||||
if h.KetoOutboxRepo != nil {
|
if h.KetoOutboxRepo != nil {
|
||||||
_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
|
_ = 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, "companyCode")
|
||||||
delete(traits, "companyCodes")
|
delete(traits, "companyCodes")
|
||||||
@@ -2719,6 +2831,9 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
traits["secondary_emails"] = subEmails
|
traits["secondary_emails"] = subEmails
|
||||||
traits["worksmobileAliasEmails"] = 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]
|
// [LoginID Sync based on Tenant Settings]
|
||||||
// Perform sync AFTER metadata merge to ensure traits contains current values
|
// 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
|
ctx := context.Background() // Use request context if appropriate, but sync must finish
|
||||||
if err := h.UserRepo.Update(ctx, updatedLocalUser); err != nil {
|
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)
|
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 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)
|
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
|
// Update User Login IDs in local DB
|
||||||
if err := h.UserRepo.UpdateUserLoginIDs(ctx, updatedLocalUser.ID, loginIDRecords); err != nil {
|
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)
|
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
|
// [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 h.UserRepo != nil {
|
||||||
if err := h.UserRepo.Delete(context.Background(), userID); err != 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)
|
slog.Error("[UserHandler] Failed to delete local user read-model", "userID", userID, "error", err)
|
||||||
markUserProjectionFailed(context.Background(), h.UserProjectionRepo, err)
|
|
||||||
} else {
|
} else {
|
||||||
slog.Info("[UserHandler] Successfully deleted local user read-model", "userID", userID)
|
slog.Info("[UserHandler] Successfully deleted local user read-model", "userID", userID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -171,6 +172,7 @@ func (m *userHandlerMockKetoOutboxRepository) MarkProcessed(ctx context.Context,
|
|||||||
|
|
||||||
type fakeUserHandlerWorksmobileSyncer struct {
|
type fakeUserHandlerWorksmobileSyncer struct {
|
||||||
upserts []domain.User
|
upserts []domain.User
|
||||||
|
updates []domain.User
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeUserHandlerWorksmobileSyncer) EnqueueTenantUpsertIfInScope(ctx context.Context, tenant domain.Tenant) error {
|
func (f *fakeUserHandlerWorksmobileSyncer) EnqueueTenantUpsertIfInScope(ctx context.Context, tenant domain.Tenant) error {
|
||||||
@@ -186,6 +188,11 @@ func (f *fakeUserHandlerWorksmobileSyncer) EnqueueUserUpsertIfInScope(ctx contex
|
|||||||
return nil
|
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 {
|
func (f *fakeUserHandlerWorksmobileSyncer) EnqueueUserDeleteIfInScope(ctx context.Context, user domain.User) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -206,6 +213,18 @@ func TestSanitizeUserMetadataRemovesLegacyClassificationFlags(t *testing.T) {
|
|||||||
assert.Contains(t, metadata, "userType")
|
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) {
|
func TestSanitizeUserRepresentativeTenantsClearsNonPublicPrimary(t *testing.T) {
|
||||||
mockTenant := new(MockTenantServiceForUser)
|
mockTenant := new(MockTenantServiceForUser)
|
||||||
internalTenantID := "internal-tenant"
|
internalTenantID := "internal-tenant"
|
||||||
@@ -249,6 +268,44 @@ func TestSanitizeUserRepresentativeTenantsClearsNonPublicPrimary(t *testing.T) {
|
|||||||
mockTenant.AssertExpectations(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 {
|
type MockTenantServiceForUser struct {
|
||||||
mock.Mock
|
mock.Mock
|
||||||
service.TenantService
|
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) {
|
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)
|
for _, call := range m.ExpectedCalls {
|
||||||
if args.Get(0) == nil {
|
if call.Method == "ListTenants" {
|
||||||
return nil, args.Get(1).(int64), args.Error(2)
|
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) {
|
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) {
|
func TestUserHandler_BulkCreateUsersRejectsRequestedUserID(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
mockKratos := new(MockKratosAdmin)
|
mockKratos := new(MockKratosAdmin)
|
||||||
@@ -1135,6 +1250,84 @@ func TestUserHandler_ListUsersWarmsIdentityMirrorFromKratosWhenMirrorEmpty(t *te
|
|||||||
mockKratos.AssertExpectations(t)
|
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) {
|
func TestUserHandler_WarmIdentityMirrorRebuildsRedisFromKratos(t *testing.T) {
|
||||||
mockKratos := new(MockKratosAdmin)
|
mockKratos := new(MockKratosAdmin)
|
||||||
redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{
|
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) {
|
func TestUserHandler_CreateUser_HanmacEmailPolicyBlocksDuplicateLocalPart(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
mockKratos := new(MockKratosAdmin)
|
mockKratos := new(MockKratosAdmin)
|
||||||
@@ -1570,7 +1848,7 @@ func TestUserHandler_CreateUser_HanmacEmailPolicyBlocksDuplicateLocalPart(t *tes
|
|||||||
}, nil).Maybe()
|
}, nil).Maybe()
|
||||||
mockTenant.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), 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{
|
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()
|
}, nil).Maybe()
|
||||||
mockRepo.On("FindByCompanyCodes", mock.Anything, []string{"hanmac-family", "hanmac"}).Return([]domain.User{}, 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 := httptest.NewRequest("POST", "/users", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
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)
|
resp, _ := app.Test(req)
|
||||||
assert.Equal(t, http.StatusConflict, resp.StatusCode)
|
assert.Equal(t, http.StatusConflict, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]any
|
var result map[string]any
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
json.NewDecoder(resp.Body).Decode(&result)
|
||||||
assert.Contains(t, result["error"].(string), "한맥가족 내에서 이미 사용 중인 이메일 ID입니다.")
|
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")
|
mockOry.AssertNotCalled(t, "CreateUser")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1635,9 +1924,9 @@ func TestUserHandler_BulkUpdateUsers(t *testing.T) {
|
|||||||
json.NewDecoder(resp.Body).Decode(&result)
|
json.NewDecoder(resp.Body).Decode(&result)
|
||||||
results := result["results"].([]any)
|
results := result["results"].([]any)
|
||||||
assert.True(t, results[0].(map[string]any)["success"].(bool))
|
assert.True(t, results[0].(map[string]any)["success"].(bool))
|
||||||
assert.Len(t, worksmobile.upserts, 1)
|
assert.Len(t, worksmobile.updates, 1)
|
||||||
assert.Equal(t, "u-1", worksmobile.upserts[0].ID)
|
assert.Equal(t, "u-1", worksmobile.updates[0].ID)
|
||||||
assert.Equal(t, domain.UserStatusPreboarding, worksmobile.upserts[0].Status)
|
assert.Equal(t, domain.UserStatusPreboarding, worksmobile.updates[0].Status)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Success - Super admin assigns legacy roles as user", func(t *testing.T) {
|
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)
|
mockKratos := new(MockKratosAdmin)
|
||||||
mockTenant := new(MockTenantServiceForUser)
|
mockTenant := new(MockTenantServiceForUser)
|
||||||
mockOutbox := new(userHandlerMockKetoOutboxRepository)
|
mockOutbox := new(userHandlerMockKetoOutboxRepository)
|
||||||
|
mockRepo := new(MockUserRepoForHandler)
|
||||||
h := &UserHandler{
|
h := &UserHandler{
|
||||||
KratosAdmin: mockKratos,
|
KratosAdmin: mockKratos,
|
||||||
TenantService: mockTenant,
|
TenantService: mockTenant,
|
||||||
KetoOutboxRepo: mockOutbox,
|
KetoOutboxRepo: mockOutbox,
|
||||||
|
UserRepo: mockRepo,
|
||||||
}
|
}
|
||||||
app.Put("/users/bulk", func(c *fiber.Ctx) error {
|
app.Put("/users/bulk", func(c *fiber.Ctx) error {
|
||||||
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
|
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"}},
|
"additionalAppointments": []any{map[string]any{"tenantId": "team-a-id", "tenantSlug": "team-a", "tenantName": "Team A"}},
|
||||||
},
|
},
|
||||||
}, nil).Once()
|
}, 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 {
|
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
|
||||||
return entry.Namespace == "Tenant" &&
|
return entry.Namespace == "Tenant" &&
|
||||||
entry.Object == "team-a-id" &&
|
entry.Object == "team-a-id" &&
|
||||||
@@ -1753,6 +2050,7 @@ func TestUserHandler_BulkUpdateUsersAddTenantMembership(t *testing.T) {
|
|||||||
mockKratos.AssertExpectations(t)
|
mockKratos.AssertExpectations(t)
|
||||||
mockTenant.AssertExpectations(t)
|
mockTenant.AssertExpectations(t)
|
||||||
mockOutbox.AssertExpectations(t)
|
mockOutbox.AssertExpectations(t)
|
||||||
|
mockRepo.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUserHandler_BulkDeleteUsers(t *testing.T) {
|
func TestUserHandler_BulkDeleteUsers(t *testing.T) {
|
||||||
@@ -2258,6 +2556,73 @@ func TestUserHandler_UpdateUser_AllowsSuperAdminEmailChange(t *testing.T) {
|
|||||||
mockKratos.AssertExpectations(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) {
|
func TestUserHandler_UpdateUserClearsWorksmobileAliasMetadataWhenSubEmailIsCleared(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
mockKratos := new(MockKratosAdmin)
|
mockKratos := new(MockKratosAdmin)
|
||||||
@@ -2769,9 +3134,7 @@ func TestUserHandler_CreateUser_UsesAdditionalAppointmentAsPrimaryTenant(t *test
|
|||||||
resp, _ := app.Test(req)
|
resp, _ := app.Test(req)
|
||||||
|
|
||||||
assert.Equal(t, 201, resp.StatusCode)
|
assert.Equal(t, 201, resp.StatusCode)
|
||||||
assert.Len(t, worksmobile.upserts, 1)
|
assert.Empty(t, worksmobile.upserts)
|
||||||
assert.Equal(t, "some-id", worksmobile.upserts[0].ID)
|
|
||||||
assert.Equal(t, tenantID, *worksmobile.upserts[0].TenantID)
|
|
||||||
mockOry.AssertExpectations(t)
|
mockOry.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2850,6 +3213,66 @@ func TestUserHandler_CreateUser_AutoCreatesPersonalTenantWhenAssignmentMissing(t
|
|||||||
mockKratos.AssertExpectations(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) {
|
func TestUserHandler_CreateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
mockKratos := new(MockKratosAdmin)
|
mockKratos := new(MockKratosAdmin)
|
||||||
@@ -2925,9 +3348,9 @@ func TestUserHandler_UpdateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing
|
|||||||
ID: "new-tenant-id",
|
ID: "new-tenant-id",
|
||||||
Slug: "new-tenant",
|
Slug: "new-tenant",
|
||||||
}, nil).Maybe()
|
}, nil).Maybe()
|
||||||
mockTenant.On("GetTenant", mock.Anything, "new-tenant-id").Return(&domain.Tenant{
|
mockTenant.On("GetTenant", mock.Anything, "old-tenant-id").Return(&domain.Tenant{
|
||||||
ID: "new-tenant-id",
|
ID: "old-tenant-id",
|
||||||
Slug: "new-tenant",
|
Slug: "old-tenant",
|
||||||
Config: domain.JSONMap{},
|
Config: domain.JSONMap{},
|
||||||
}, nil).Maybe()
|
}, nil).Maybe()
|
||||||
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.Anything, mock.Anything).Return(&service.KratosIdentity{
|
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{
|
Traits: map[string]any{
|
||||||
"email": "user@test.com",
|
"email": "user@test.com",
|
||||||
"name": "Test User",
|
"name": "Test User",
|
||||||
"tenant_id": "new-tenant-id",
|
"tenant_id": "old-tenant-id",
|
||||||
"role": domain.RoleUser,
|
"role": domain.RoleUser,
|
||||||
},
|
},
|
||||||
}, nil).Maybe()
|
}, nil).Maybe()
|
||||||
@@ -2952,6 +3375,100 @@ func TestUserHandler_UpdateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing
|
|||||||
mockKratos.AssertExpectations(t)
|
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) {
|
func TestUserHandler_UpdateUserAddTenantKeepsPrimaryAndAddsAppointment(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
mockKratos := new(MockKratosAdmin)
|
mockKratos := new(MockKratosAdmin)
|
||||||
@@ -3272,6 +3789,163 @@ func TestUserHandler_BulkUpdateUsersAcceptsTenantSlugAndRejectsCompanyCode(t *te
|
|||||||
require.Contains(t, legacyErr.Error(), "companyCode is deprecated")
|
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) {
|
func TestUserHandler_MapToLocalUserKeepsRoleAndGradeSeparate(t *testing.T) {
|
||||||
handler := &UserHandler{}
|
handler := &UserHandler{}
|
||||||
identity := service.KratosIdentity{
|
identity := service.KratosIdentity{
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"baron-sso-backend/internal/repository"
|
|
||||||
"context"
|
|
||||||
"log/slog"
|
|
||||||
)
|
|
||||||
|
|
||||||
func markUserProjectionFailed(ctx context.Context, repo repository.UserProjectionRepository, syncErr error) {
|
|
||||||
if repo == nil || syncErr == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := repo.MarkFailed(ctx, syncErr); err != nil {
|
|
||||||
slog.Error("Failed to mark user projection as failed", "syncError", syncErr, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -63,7 +63,7 @@ func TestMain(m *testing.M) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auto-migrate
|
// 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 {
|
if err != nil {
|
||||||
log.Fatalf("failed to migrate database: %s", err)
|
log.Fatalf("failed to migrate database: %s", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,26 +26,76 @@ WHERE u.deleted_at IS NULL
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ClearOrphanUserTenantMemberships(ctx context.Context, db *gorm.DB) (int64, error) {
|
func ClearOrphanUserTenantMemberships(ctx context.Context, db *gorm.DB) (int64, error) {
|
||||||
result := db.WithContext(ctx).Exec(`
|
userResult := db.WithContext(ctx).Exec(`
|
||||||
WITH orphan_users AS (
|
WITH orphan_users AS (
|
||||||
SELECT u.id
|
SELECT u.id AS user_id,
|
||||||
|
replacement.id AS replacement_tenant_id
|
||||||
FROM users AS u
|
FROM users AS u
|
||||||
WHERE u.deleted_at IS NULL
|
JOIN tenants AS deleted_tenant
|
||||||
AND (
|
ON deleted_tenant.id = u.tenant_id
|
||||||
u.tenant_id IS NOT NULL
|
AND deleted_tenant.deleted_at IS NOT NULL
|
||||||
AND NOT EXISTS (
|
JOIN LATERAL (
|
||||||
SELECT 1
|
WITH RECURSIVE ancestors AS (
|
||||||
FROM tenants AS t
|
SELECT parent.id, parent.parent_id, parent.deleted_at, 1 AS depth
|
||||||
WHERE t.id = u.tenant_id
|
FROM tenants AS parent
|
||||||
AND t.deleted_at IS NULL
|
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
|
UPDATE users AS u
|
||||||
SET tenant_id = NULL,
|
SET tenant_id = ou.replacement_tenant_id,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
FROM orphan_users AS ou
|
FROM orphan_users AS ou
|
||||||
WHERE u.id = ou.id
|
WHERE u.id = ou.user_id
|
||||||
`)
|
`)
|
||||||
return result.RowsAffected, result.Error
|
if userResult.Error != nil {
|
||||||
|
return userResult.RowsAffected, userResult.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
loginResult := db.WithContext(ctx).Exec(`
|
||||||
|
WITH orphan_login_ids AS (
|
||||||
|
SELECT uli.id AS login_id,
|
||||||
|
replacement.id AS replacement_tenant_id
|
||||||
|
FROM user_login_ids AS uli
|
||||||
|
JOIN tenants AS deleted_tenant
|
||||||
|
ON deleted_tenant.id = uli.tenant_id
|
||||||
|
AND deleted_tenant.deleted_at IS NOT NULL
|
||||||
|
JOIN LATERAL (
|
||||||
|
WITH RECURSIVE ancestors AS (
|
||||||
|
SELECT parent.id, parent.parent_id, parent.deleted_at, 1 AS depth
|
||||||
|
FROM tenants AS parent
|
||||||
|
WHERE parent.id = deleted_tenant.parent_id
|
||||||
|
UNION ALL
|
||||||
|
SELECT parent.id, parent.parent_id, parent.deleted_at, ancestors.depth + 1
|
||||||
|
FROM tenants AS parent
|
||||||
|
JOIN ancestors ON parent.id = ancestors.parent_id
|
||||||
|
WHERE ancestors.parent_id IS NOT NULL
|
||||||
|
AND ancestors.parent_id <> ancestors.id
|
||||||
|
)
|
||||||
|
SELECT id
|
||||||
|
FROM ancestors
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
ORDER BY depth
|
||||||
|
LIMIT 1
|
||||||
|
) AS replacement ON true
|
||||||
|
)
|
||||||
|
UPDATE user_login_ids AS uli
|
||||||
|
SET tenant_id = oli.replacement_tenant_id
|
||||||
|
FROM orphan_login_ids AS oli
|
||||||
|
WHERE uli.id = oli.login_id
|
||||||
|
`)
|
||||||
|
return userResult.RowsAffected + loginResult.RowsAffected, loginResult.Error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func TestClearOrphanUserTenantMemberships(t *testing.T) {
|
|||||||
require.NoError(t, testDB.Unscoped().Where("slug IN ?", []string{"orphan-active", "orphan-deleted"}).Delete(&domain.Tenant{}).Error)
|
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}
|
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, activeTenant))
|
||||||
require.NoError(t, tenantRepo.Create(ctx, deletedTenant))
|
require.NoError(t, tenantRepo.Create(ctx, deletedTenant))
|
||||||
require.NoError(t, testDB.Delete(&domain.Tenant{}, "id = ?", deletedTenant.ID).Error)
|
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, activeUser))
|
||||||
require.NoError(t, repo.Create(ctx, orphanUser))
|
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)
|
count, err := CountOrphanUserTenantMemberships(ctx, testDB)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -46,7 +53,7 @@ func TestClearOrphanUserTenantMemberships(t *testing.T) {
|
|||||||
|
|
||||||
affected, err := ClearOrphanUserTenantMemberships(ctx, testDB)
|
affected, err := ClearOrphanUserTenantMemberships(ctx, testDB)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, int64(1), affected)
|
assert.Equal(t, int64(2), affected)
|
||||||
|
|
||||||
foundActive, err := repo.FindByEmail(ctx, activeUser.Email)
|
foundActive, err := repo.FindByEmail(ctx, activeUser.Email)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -56,7 +63,12 @@ func TestClearOrphanUserTenantMemberships(t *testing.T) {
|
|||||||
|
|
||||||
foundOrphan, err := repo.FindByEmail(ctx, orphanUser.Email)
|
foundOrphan, err := repo.FindByEmail(ctx, orphanUser.Email)
|
||||||
require.NoError(t, err)
|
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)
|
count, err = CountOrphanUserTenantMemberships(ctx, testDB)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -1,227 +0,0 @@
|
|||||||
package repository
|
|
||||||
|
|
||||||
import (
|
|
||||||
"baron-sso-backend/internal/domain"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
|
||||||
|
|
||||||
type UserProjectionRepository interface {
|
|
||||||
IsReady(ctx context.Context) (bool, error)
|
|
||||||
GetStatus(ctx context.Context) (domain.UserProjectionStatus, error)
|
|
||||||
CountTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error)
|
|
||||||
CountTenantMembersRecursive(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error)
|
|
||||||
ReplaceAllFromKratos(ctx context.Context, users []domain.User) error
|
|
||||||
MarkFailed(ctx context.Context, syncErr error) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type userProjectionRepository struct {
|
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewUserProjectionRepository(db *gorm.DB) UserProjectionRepository {
|
|
||||||
return &userProjectionRepository{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *userProjectionRepository) IsReady(ctx context.Context) (bool, error) {
|
|
||||||
status, err := r.GetStatus(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return status.Ready, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *userProjectionRepository) GetStatus(ctx context.Context) (domain.UserProjectionStatus, error) {
|
|
||||||
var projectedUsers int64
|
|
||||||
if err := r.db.WithContext(ctx).Model(&domain.User{}).Count(&projectedUsers).Error; err != nil {
|
|
||||||
return domain.UserProjectionStatus{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var state domain.UserProjectionState
|
|
||||||
err := r.db.WithContext(ctx).First(&state, "name = ?", domain.UserProjectionNameKratos).Error
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return domain.UserProjectionStatus{
|
|
||||||
Name: domain.UserProjectionNameKratos,
|
|
||||||
Status: domain.UserProjectionStatusFailed,
|
|
||||||
Ready: false,
|
|
||||||
ProjectedUsers: projectedUsers,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return domain.UserProjectionStatus{}, err
|
|
||||||
}
|
|
||||||
return domain.UserProjectionStatus{
|
|
||||||
Name: state.Name,
|
|
||||||
Status: state.Status,
|
|
||||||
Ready: state.Status == domain.UserProjectionStatusReady && state.LastSyncedAt != nil,
|
|
||||||
LastSyncedAt: state.LastSyncedAt,
|
|
||||||
LastError: state.LastError,
|
|
||||||
UpdatedAt: &state.UpdatedAt,
|
|
||||||
ProjectedUsers: projectedUsers,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *userProjectionRepository) CountTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
|
|
||||||
counts := make(map[string]int64, len(tenants))
|
|
||||||
for _, tenant := range tenants {
|
|
||||||
counts[tenant.ID] = 0
|
|
||||||
}
|
|
||||||
if len(tenants) == 0 {
|
|
||||||
return counts, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
valuePlaceholders := make([]string, 0, len(tenants))
|
|
||||||
args := make([]any, 0, len(tenants)*2)
|
|
||||||
for _, tenant := range tenants {
|
|
||||||
valuePlaceholders = append(valuePlaceholders, "(?, ?)")
|
|
||||||
args = append(args, strings.TrimSpace(tenant.ID), strings.TrimSpace(tenant.Slug))
|
|
||||||
}
|
|
||||||
|
|
||||||
query := fmt.Sprintf(`
|
|
||||||
WITH requested(tenant_id, slug) AS (
|
|
||||||
VALUES %s
|
|
||||||
)
|
|
||||||
SELECT requested.tenant_id, COUNT(DISTINCT users.id) AS count
|
|
||||||
FROM requested
|
|
||||||
LEFT JOIN users ON users.deleted_at IS NULL AND (
|
|
||||||
users.tenant_id::text = requested.tenant_id
|
|
||||||
)
|
|
||||||
GROUP BY requested.tenant_id
|
|
||||||
`, strings.Join(valuePlaceholders, ","))
|
|
||||||
|
|
||||||
type result struct {
|
|
||||||
TenantID string
|
|
||||||
Count int64
|
|
||||||
}
|
|
||||||
var rows []result
|
|
||||||
if err := r.db.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, row := range rows {
|
|
||||||
counts[row.TenantID] = row.Count
|
|
||||||
}
|
|
||||||
return counts, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *userProjectionRepository) CountTenantMembersRecursive(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
|
|
||||||
counts := make(map[string]int64, len(tenants))
|
|
||||||
for _, tenant := range tenants {
|
|
||||||
counts[tenant.ID] = 0
|
|
||||||
}
|
|
||||||
if len(tenants) == 0 {
|
|
||||||
return counts, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
valuePlaceholders := make([]string, 0, len(tenants))
|
|
||||||
args := make([]any, 0, len(tenants))
|
|
||||||
for _, tenant := range tenants {
|
|
||||||
valuePlaceholders = append(valuePlaceholders, "(?)")
|
|
||||||
args = append(args, strings.TrimSpace(tenant.ID))
|
|
||||||
}
|
|
||||||
|
|
||||||
query := fmt.Sprintf(`
|
|
||||||
WITH RECURSIVE requested(tenant_id) AS (
|
|
||||||
VALUES %s
|
|
||||||
),
|
|
||||||
descendants(root_tenant_id, tenant_id) AS (
|
|
||||||
SELECT requested.tenant_id, requested.tenant_id
|
|
||||||
FROM requested
|
|
||||||
UNION ALL
|
|
||||||
SELECT descendants.root_tenant_id, child.id::text
|
|
||||||
FROM descendants
|
|
||||||
JOIN tenants child
|
|
||||||
ON child.parent_id::text = descendants.tenant_id
|
|
||||||
AND child.deleted_at IS NULL
|
|
||||||
)
|
|
||||||
SELECT requested.tenant_id, COUNT(DISTINCT users.id) AS count
|
|
||||||
FROM requested
|
|
||||||
LEFT JOIN descendants
|
|
||||||
ON descendants.root_tenant_id = requested.tenant_id
|
|
||||||
LEFT JOIN users
|
|
||||||
ON users.deleted_at IS NULL
|
|
||||||
AND users.tenant_id::text = descendants.tenant_id
|
|
||||||
GROUP BY requested.tenant_id
|
|
||||||
`, strings.Join(valuePlaceholders, ","))
|
|
||||||
|
|
||||||
type result struct {
|
|
||||||
TenantID string
|
|
||||||
Count int64
|
|
||||||
}
|
|
||||||
var rows []result
|
|
||||||
if err := r.db.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, row := range rows {
|
|
||||||
counts[row.TenantID] = row.Count
|
|
||||||
}
|
|
||||||
return counts, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *userProjectionRepository) ReplaceAllFromKratos(ctx context.Context, users []domain.User) error {
|
|
||||||
now := time.Now()
|
|
||||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
for i := range users {
|
|
||||||
users[i].DeletedAt = gorm.DeletedAt{}
|
|
||||||
if users[i].CreatedAt.IsZero() {
|
|
||||||
users[i].CreatedAt = now
|
|
||||||
}
|
|
||||||
if users[i].UpdatedAt.IsZero() {
|
|
||||||
users[i].UpdatedAt = now
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(users) > 0 {
|
|
||||||
// [FIX] Handle email conflicts before bulk upsert
|
|
||||||
for _, u := range users {
|
|
||||||
if u.Email != "" {
|
|
||||||
// Hard-delete any record with same email but different ID to clear unique constraint
|
|
||||||
_ = tx.Unscoped().Where("email = ? AND id != ?", u.Email, u.ID).Delete(&domain.User{}).Error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Clauses(clause.OnConflict{
|
|
||||||
Columns: []clause.Column{{Name: "id"}},
|
|
||||||
UpdateAll: true,
|
|
||||||
}).Create(&users).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return upsertUserProjectionState(tx, domain.UserProjectionStatusReady, &now, "")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *userProjectionRepository) MarkFailed(ctx context.Context, syncErr error) error {
|
|
||||||
message := ""
|
|
||||||
if syncErr != nil {
|
|
||||||
message = syncErr.Error()
|
|
||||||
}
|
|
||||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
return upsertUserProjectionState(tx, domain.UserProjectionStatusFailed, nil, message)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func upsertUserProjectionState(tx *gorm.DB, status string, syncedAt *time.Time, lastError string) error {
|
|
||||||
state := domain.UserProjectionState{
|
|
||||||
Name: domain.UserProjectionNameKratos,
|
|
||||||
Status: status,
|
|
||||||
LastSyncedAt: syncedAt,
|
|
||||||
LastError: lastError,
|
|
||||||
UpdatedAt: time.Now(),
|
|
||||||
}
|
|
||||||
return tx.Clauses(clause.OnConflict{
|
|
||||||
Columns: []clause.Column{{Name: "name"}},
|
|
||||||
DoUpdates: clause.AssignmentColumns([]string{
|
|
||||||
"status",
|
|
||||||
"last_synced_at",
|
|
||||||
"last_error",
|
|
||||||
"updated_at",
|
|
||||||
}),
|
|
||||||
}).Create(&state).Error
|
|
||||||
}
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
package repository
|
|
||||||
|
|
||||||
import (
|
|
||||||
"baron-sso-backend/internal/domain"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestUserProjectionRepository_ReplaceAllFromKratosMarksReadyWithoutDeletingUsersMissingFromPartialList(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
repo := NewUserProjectionRepository(testDB)
|
|
||||||
|
|
||||||
require.NoError(t, testDB.Exec("DELETE FROM user_projection_states").Error)
|
|
||||||
require.NoError(t, testDB.Exec("DELETE FROM user_login_ids").Error)
|
|
||||||
require.NoError(t, testDB.Exec("DELETE FROM users").Error)
|
|
||||||
|
|
||||||
tenantID := "10000000-0000-0000-0000-000000000001"
|
|
||||||
tenantSlug := "projection-saman"
|
|
||||||
require.NoError(t, testDB.Create(&domain.Tenant{
|
|
||||||
ID: tenantID,
|
|
||||||
Name: "Projection Saman",
|
|
||||||
Slug: tenantSlug,
|
|
||||||
Type: domain.TenantTypeCompany,
|
|
||||||
Status: domain.TenantStatusActive,
|
|
||||||
}).Error)
|
|
||||||
existing := &domain.User{
|
|
||||||
ID: "00000000-0000-0000-0000-000000000099",
|
|
||||||
Email: "existing@example.com",
|
|
||||||
Name: "Existing",
|
|
||||||
CompanyCode: tenantSlug,
|
|
||||||
TenantID: &tenantID,
|
|
||||||
}
|
|
||||||
require.NoError(t, NewUserRepository(testDB).Create(ctx, existing))
|
|
||||||
|
|
||||||
users := []domain.User{
|
|
||||||
{
|
|
||||||
ID: "00000000-0000-0000-0000-000000000101",
|
|
||||||
Email: "one@example.com",
|
|
||||||
Name: "One",
|
|
||||||
CompanyCode: tenantSlug,
|
|
||||||
TenantID: &tenantID,
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
UpdatedAt: time.Now(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "00000000-0000-0000-0000-000000000102",
|
|
||||||
Email: "two@example.com",
|
|
||||||
Name: "Two",
|
|
||||||
TenantID: &tenantID,
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
UpdatedAt: time.Now(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(t, repo.ReplaceAllFromKratos(ctx, users))
|
|
||||||
|
|
||||||
ready, err := repo.IsReady(ctx)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.True(t, ready)
|
|
||||||
|
|
||||||
counts, err := repo.CountTenantMembers(ctx, []domain.Tenant{
|
|
||||||
{ID: tenantID, Slug: tenantSlug},
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, int64(3), counts[tenantID])
|
|
||||||
|
|
||||||
var activeCount int64
|
|
||||||
require.NoError(t, testDB.Model(&domain.User{}).Count(&activeCount).Error)
|
|
||||||
assert.Equal(t, int64(3), activeCount)
|
|
||||||
|
|
||||||
var existingCount int64
|
|
||||||
require.NoError(t, testDB.Model(&domain.User{}).Where("id = ?", existing.ID).Count(&existingCount).Error)
|
|
||||||
assert.Equal(t, int64(1), existingCount)
|
|
||||||
|
|
||||||
var existingRow domain.User
|
|
||||||
require.NoError(t, testDB.Unscoped().First(&existingRow, "id = ?", existing.ID).Error)
|
|
||||||
assert.False(t, existingRow.DeletedAt.Valid)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUserProjectionRepository_CountTenantMembersRecursiveIncludesDescendantsAndExcludesSoftDeletedUsers(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
repo := NewUserProjectionRepository(testDB)
|
|
||||||
|
|
||||||
parentID := "20000000-0000-0000-0000-000000000001"
|
|
||||||
childID := "20000000-0000-0000-0000-000000000002"
|
|
||||||
grandchildID := "20000000-0000-0000-0000-000000000003"
|
|
||||||
siblingID := "20000000-0000-0000-0000-000000000004"
|
|
||||||
tenantIDs := []string{parentID, childID, grandchildID, siblingID}
|
|
||||||
|
|
||||||
require.NoError(t, testDB.Exec("DELETE FROM user_login_ids").Error)
|
|
||||||
require.NoError(t, testDB.Exec("DELETE FROM users").Error)
|
|
||||||
require.NoError(t, testDB.Unscoped().Where("id IN ?", tenantIDs).Delete(&domain.Tenant{}).Error)
|
|
||||||
|
|
||||||
require.NoError(t, testDB.Create(&domain.Tenant{
|
|
||||||
ID: parentID,
|
|
||||||
Name: "Recursive Parent",
|
|
||||||
Slug: "recursive-parent",
|
|
||||||
Type: domain.TenantTypeCompany,
|
|
||||||
Status: domain.TenantStatusActive,
|
|
||||||
}).Error)
|
|
||||||
require.NoError(t, testDB.Create(&domain.Tenant{
|
|
||||||
ID: childID,
|
|
||||||
Name: "Recursive Child",
|
|
||||||
Slug: "recursive-child",
|
|
||||||
Type: domain.TenantTypeOrganization,
|
|
||||||
Status: domain.TenantStatusActive,
|
|
||||||
ParentID: &parentID,
|
|
||||||
}).Error)
|
|
||||||
require.NoError(t, testDB.Create(&domain.Tenant{
|
|
||||||
ID: grandchildID,
|
|
||||||
Name: "Recursive Grandchild",
|
|
||||||
Slug: "recursive-grandchild",
|
|
||||||
Type: domain.TenantTypeUserGroup,
|
|
||||||
Status: domain.TenantStatusActive,
|
|
||||||
ParentID: &childID,
|
|
||||||
}).Error)
|
|
||||||
require.NoError(t, testDB.Create(&domain.Tenant{
|
|
||||||
ID: siblingID,
|
|
||||||
Name: "Recursive Sibling",
|
|
||||||
Slug: "recursive-sibling",
|
|
||||||
Type: domain.TenantTypeCompany,
|
|
||||||
Status: domain.TenantStatusActive,
|
|
||||||
}).Error)
|
|
||||||
|
|
||||||
users := []domain.User{
|
|
||||||
{ID: "21000000-0000-0000-0000-000000000001", Email: "parent@example.com", Name: "Parent", TenantID: &parentID},
|
|
||||||
{ID: "21000000-0000-0000-0000-000000000002", Email: "child@example.com", Name: "Child", TenantID: &childID},
|
|
||||||
{ID: "21000000-0000-0000-0000-000000000003", Email: "grandchild@example.com", Name: "Grandchild", TenantID: &grandchildID},
|
|
||||||
{ID: "21000000-0000-0000-0000-000000000004", Email: "deleted-grandchild@example.com", Name: "Deleted Grandchild", TenantID: &grandchildID},
|
|
||||||
{ID: "21000000-0000-0000-0000-000000000005", Email: "sibling@example.com", Name: "Sibling", TenantID: &siblingID},
|
|
||||||
}
|
|
||||||
for i := range users {
|
|
||||||
require.NoError(t, testDB.Create(&users[i]).Error)
|
|
||||||
}
|
|
||||||
require.NoError(t, testDB.Delete(&domain.User{}, "id = ?", users[3].ID).Error)
|
|
||||||
|
|
||||||
directCounts, err := repo.CountTenantMembers(ctx, []domain.Tenant{{ID: parentID}, {ID: childID}, {ID: grandchildID}, {ID: siblingID}})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, int64(1), directCounts[parentID])
|
|
||||||
assert.Equal(t, int64(1), directCounts[childID])
|
|
||||||
assert.Equal(t, int64(1), directCounts[grandchildID])
|
|
||||||
assert.Equal(t, int64(1), directCounts[siblingID])
|
|
||||||
|
|
||||||
recursiveCounts, err := repo.CountTenantMembersRecursive(ctx, []domain.Tenant{{ID: parentID}, {ID: childID}, {ID: grandchildID}, {ID: siblingID}})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, int64(3), recursiveCounts[parentID])
|
|
||||||
assert.Equal(t, int64(2), recursiveCounts[childID])
|
|
||||||
assert.Equal(t, int64(1), recursiveCounts[grandchildID])
|
|
||||||
assert.Equal(t, int64(1), recursiveCounts[siblingID])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUserProjectionRepository_MarkFailedMakesProjectionNotReady(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
repo := NewUserProjectionRepository(testDB)
|
|
||||||
|
|
||||||
require.NoError(t, testDB.Exec("DELETE FROM user_projection_states").Error)
|
|
||||||
|
|
||||||
require.NoError(t, repo.MarkFailed(ctx, errors.New("kratos down")))
|
|
||||||
|
|
||||||
ready, err := repo.IsReady(ctx)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.False(t, ready)
|
|
||||||
}
|
|
||||||
@@ -272,7 +272,12 @@ func (r *userRepository) List(ctx context.Context, offset, limit int, search str
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *userRepository) Delete(ctx context.Context, id string) error {
|
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 {
|
func (r *userRepository) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestUserRepository(t *testing.T) {
|
func TestUserRepository(t *testing.T) {
|
||||||
@@ -95,8 +96,14 @@ func TestUserRepository(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Delete User", func(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"}
|
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)
|
err := repo.Delete(ctx, user.ID)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -104,6 +111,14 @@ func TestUserRepository(t *testing.T) {
|
|||||||
found, err := repo.FindByEmail(ctx, "delete@example.com")
|
found, err := repo.FindByEmail(ctx, "delete@example.com")
|
||||||
assert.Error(t, err) // Should not be found
|
assert.Error(t, err) // Should not be found
|
||||||
assert.Nil(t, 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) {
|
t.Run("CountByCompanyCodes", func(t *testing.T) {
|
||||||
|
|||||||
@@ -102,6 +102,44 @@ func (s *RedisService) Delete(key string) error {
|
|||||||
return s.Client.Del(ctx, key).Err()
|
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) {
|
func (s *RedisService) GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error) {
|
||||||
if s == nil || s.Client == nil {
|
if s == nil || s.Client == nil {
|
||||||
return domain.IdentityCacheStatus{
|
return domain.IdentityCacheStatus{
|
||||||
|
|||||||
@@ -134,6 +134,27 @@ func TestRedisServiceFlushIdentityCacheDeletesOnlyIdentityMirrorAndIndexKeys(t *
|
|||||||
}, stub.deleted)
|
}, 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) {
|
func TestRedisServiceGetIdentityCacheStatusReturnsUnavailableWithoutClient(t *testing.T) {
|
||||||
status, err := (*RedisService)(nil).GetIdentityCacheStatus(context.Background())
|
status, err := (*RedisService)(nil).GetIdentityCacheStatus(context.Background())
|
||||||
|
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string
|
|||||||
if err := s.userRepo.Update(ctx, localUser); err != nil {
|
if err := s.userRepo.Update(ctx, localUser); err != nil {
|
||||||
slog.Error("Failed to sync local user during AddMember", "user", userID, "error", err)
|
slog.Error("Failed to sync local user during AddMember", "user", userID, "error", err)
|
||||||
} else if s.worksmobile != nil {
|
} 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)
|
slog.Warn("Failed to enqueue Worksmobile user sync during AddMember", "user", userID, "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,6 +156,11 @@ func (f *fakeUserGroupWorksmobileSyncer) EnqueueUserUpsertIfInScope(ctx context.
|
|||||||
return nil
|
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 {
|
func (f *fakeUserGroupWorksmobileSyncer) EnqueueUserDeleteIfInScope(ctx context.Context, user domain.User) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,153 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"baron-sso-backend/internal/domain"
|
|
||||||
"baron-sso-backend/internal/repository"
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type UserProjectionSyncService struct {
|
|
||||||
kratos KratosAdminService
|
|
||||||
repo repository.UserProjectionRepository
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserProjectionReconciler interface {
|
|
||||||
Reconcile(ctx context.Context) (int, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewUserProjectionSyncService(kratos KratosAdminService, repo repository.UserProjectionRepository) *UserProjectionSyncService {
|
|
||||||
return &UserProjectionSyncService{
|
|
||||||
kratos: kratos,
|
|
||||||
repo: repo,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *UserProjectionSyncService) Reconcile(ctx context.Context) (int, error) {
|
|
||||||
if s == nil || s.kratos == nil || s.repo == nil {
|
|
||||||
return 0, fmt.Errorf("user projection sync dependencies are not configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
identities, err := s.kratos.ListIdentities(ctx)
|
|
||||||
if err != nil {
|
|
||||||
_ = s.repo.MarkFailed(ctx, err)
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
users := make([]domain.User, 0, len(identities))
|
|
||||||
for _, identity := range identities {
|
|
||||||
users = append(users, MapKratosIdentityToLocalUser(identity))
|
|
||||||
}
|
|
||||||
if err := s.repo.ReplaceAllFromKratos(ctx, users); err != nil {
|
|
||||||
_ = s.repo.MarkFailed(ctx, err)
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return len(users), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func MapKratosIdentityToLocalUser(identity KratosIdentity) domain.User {
|
|
||||||
traits := identity.Traits
|
|
||||||
now := time.Now()
|
|
||||||
createdAt := identity.CreatedAt
|
|
||||||
if createdAt.IsZero() {
|
|
||||||
createdAt = now
|
|
||||||
}
|
|
||||||
updatedAt := identity.UpdatedAt
|
|
||||||
if updatedAt.IsZero() {
|
|
||||||
updatedAt = now
|
|
||||||
}
|
|
||||||
|
|
||||||
role, ok := domain.NormalizeRoleAlias(kratosProjectionTraitString(traits, "role"))
|
|
||||||
if !ok {
|
|
||||||
role, ok = domain.NormalizeRoleAlias(kratosProjectionTraitString(traits, "grade"))
|
|
||||||
if !ok {
|
|
||||||
role = domain.RoleUser
|
|
||||||
}
|
|
||||||
}
|
|
||||||
grade := kratosProjectionTraitString(traits, "grade")
|
|
||||||
if _, ok := domain.NormalizeRoleAlias(grade); ok {
|
|
||||||
grade = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
user := domain.User{
|
|
||||||
ID: identity.ID,
|
|
||||||
Email: kratosProjectionTraitString(traits, "email"),
|
|
||||||
Name: kratosProjectionTraitString(traits, "name"),
|
|
||||||
Phone: domain.NormalizePhoneNumber(kratosProjectionTraitString(traits, "phone_number")),
|
|
||||||
Role: role,
|
|
||||||
Status: normalizeProjectionStatus(identity.State),
|
|
||||||
Department: kratosProjectionTraitString(traits, "department"),
|
|
||||||
Grade: grade,
|
|
||||||
Position: kratosProjectionTraitString(traits, "position"),
|
|
||||||
JobTitle: kratosProjectionTraitString(traits, "jobTitle"),
|
|
||||||
AffiliationType: kratosProjectionTraitString(traits, "affiliationType"),
|
|
||||||
CreatedAt: createdAt,
|
|
||||||
UpdatedAt: updatedAt,
|
|
||||||
Metadata: make(domain.JSONMap),
|
|
||||||
}
|
|
||||||
if tenantID := kratosProjectionTraitString(traits, "tenant_id"); tenantID != "" {
|
|
||||||
user.TenantID = &tenantID
|
|
||||||
}
|
|
||||||
if relyingPartyID := kratosProjectionTraitString(traits, "relying_party_id"); relyingPartyID != "" {
|
|
||||||
user.RelyingPartyID = &relyingPartyID
|
|
||||||
}
|
|
||||||
|
|
||||||
coreTraits := map[string]bool{
|
|
||||||
"email": true, "name": true, "phone_number": true,
|
|
||||||
"grade": true, "role": true,
|
|
||||||
"companyCode": true, "company_code": true, "companyCodes": true,
|
|
||||||
"tenant_id": true, "department": true,
|
|
||||||
"position": true, "jobTitle": true, "affiliationType": true,
|
|
||||||
"relying_party_id": true, "custom_login_ids": true, "id": true,
|
|
||||||
}
|
|
||||||
for key, value := range traits {
|
|
||||||
if !coreTraits[key] {
|
|
||||||
user.Metadata[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return user
|
|
||||||
}
|
|
||||||
|
|
||||||
func kratosProjectionTraitString(traits map[string]any, key string) string {
|
|
||||||
if traits == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
value, ok := traits[key]
|
|
||||||
if !ok || value == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if str, ok := value.(string); ok {
|
|
||||||
return str
|
|
||||||
}
|
|
||||||
return fmt.Sprint(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func kratosProjectionTraitStringArray(traits map[string]any, key string) []string {
|
|
||||||
if traits == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
switch value := traits[key].(type) {
|
|
||||||
case []string:
|
|
||||||
return value
|
|
||||||
case []any:
|
|
||||||
items := make([]string, 0, len(value))
|
|
||||||
for _, item := range value {
|
|
||||||
if str, ok := item.(string); ok && strings.TrimSpace(str) != "" {
|
|
||||||
items = append(items, str)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeProjectionStatus(state string) string {
|
|
||||||
normalized := domain.NormalizeUserStatus(state)
|
|
||||||
if normalized == "" {
|
|
||||||
return domain.UserStatusActive
|
|
||||||
}
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"baron-sso-backend/internal/domain"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
type fakeUserProjectionRepo struct {
|
|
||||||
replacedUsers []domain.User
|
|
||||||
failedErr error
|
|
||||||
replaceErr error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeUserProjectionRepo) IsReady(ctx context.Context) (bool, error) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeUserProjectionRepo) GetStatus(ctx context.Context) (domain.UserProjectionStatus, error) {
|
|
||||||
return domain.UserProjectionStatus{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeUserProjectionRepo) CountTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeUserProjectionRepo) CountTenantMembersRecursive(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeUserProjectionRepo) ReplaceAllFromKratos(ctx context.Context, users []domain.User) error {
|
|
||||||
f.replacedUsers = append([]domain.User(nil), users...)
|
|
||||||
return f.replaceErr
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeUserProjectionRepo) MarkFailed(ctx context.Context, syncErr error) error {
|
|
||||||
f.failedErr = syncErr
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUserProjectionSyncService_ReconcileReplacesProjectionFromKratos(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
kratos := new(MockKratosAdminServiceShared)
|
|
||||||
repo := &fakeUserProjectionRepo{}
|
|
||||||
svc := NewUserProjectionSyncService(kratos, repo)
|
|
||||||
|
|
||||||
tenantID := "00000000-0000-0000-0000-000000000001"
|
|
||||||
kratos.On("ListIdentities", ctx).Return([]KratosIdentity{
|
|
||||||
{
|
|
||||||
ID: "00000000-0000-0000-0000-000000000101",
|
|
||||||
Traits: map[string]any{
|
|
||||||
"email": "one@example.com",
|
|
||||||
"name": "One",
|
|
||||||
"phone_number": "+821012345678",
|
|
||||||
"companyCode": "saman",
|
|
||||||
"companyCodes": []any{"saman", "group-a"},
|
|
||||||
"tenant_id": tenantID,
|
|
||||||
"department": "DX",
|
|
||||||
"customAttr": "kept",
|
|
||||||
},
|
|
||||||
State: "active",
|
|
||||||
},
|
|
||||||
}, nil).Once()
|
|
||||||
|
|
||||||
count, err := svc.Reconcile(ctx)
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, 1, count)
|
|
||||||
require.Len(t, repo.replacedUsers, 1)
|
|
||||||
assert.Equal(t, "one@example.com", repo.replacedUsers[0].Email)
|
|
||||||
assert.Equal(t, "One", repo.replacedUsers[0].Name)
|
|
||||||
assert.Equal(t, "+821012345678", repo.replacedUsers[0].Phone)
|
|
||||||
assert.Empty(t, repo.replacedUsers[0].CompanyCode)
|
|
||||||
assert.Empty(t, repo.replacedUsers[0].CompanyCodes)
|
|
||||||
require.NotNil(t, repo.replacedUsers[0].TenantID)
|
|
||||||
assert.Equal(t, tenantID, *repo.replacedUsers[0].TenantID)
|
|
||||||
assert.Equal(t, "kept", repo.replacedUsers[0].Metadata["customAttr"])
|
|
||||||
assert.NoError(t, repo.failedErr)
|
|
||||||
kratos.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUserProjectionSyncService_ReconcileDeduplicatesKoreanCountryCodePhone(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
kratos := new(MockKratosAdminServiceShared)
|
|
||||||
repo := &fakeUserProjectionRepo{}
|
|
||||||
svc := NewUserProjectionSyncService(kratos, repo)
|
|
||||||
|
|
||||||
kratos.On("ListIdentities", ctx).Return([]KratosIdentity{
|
|
||||||
{
|
|
||||||
ID: "00000000-0000-0000-0000-000000000102",
|
|
||||||
Traits: map[string]any{
|
|
||||||
"email": "two@example.com",
|
|
||||||
"name": "Two",
|
|
||||||
"phone_number": "+82 +821091917771",
|
|
||||||
},
|
|
||||||
State: "active",
|
|
||||||
},
|
|
||||||
}, nil).Once()
|
|
||||||
|
|
||||||
count, err := svc.Reconcile(ctx)
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, 1, count)
|
|
||||||
require.Len(t, repo.replacedUsers, 1)
|
|
||||||
assert.Equal(t, "+821091917771", repo.replacedUsers[0].Phone)
|
|
||||||
kratos.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUserProjectionSyncService_ReconcileMarksFailedWhenKratosFails(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
kratos := new(MockKratosAdminServiceShared)
|
|
||||||
repo := &fakeUserProjectionRepo{}
|
|
||||||
svc := NewUserProjectionSyncService(kratos, repo)
|
|
||||||
|
|
||||||
expectedErr := errors.New("kratos down")
|
|
||||||
kratos.On("ListIdentities", ctx).Return([]KratosIdentity{}, expectedErr).Once()
|
|
||||||
|
|
||||||
count, err := svc.Reconcile(ctx)
|
|
||||||
|
|
||||||
assert.Equal(t, 0, count)
|
|
||||||
assert.ErrorIs(t, err, expectedErr)
|
|
||||||
assert.ErrorIs(t, repo.failedErr, expectedErr)
|
|
||||||
assert.Empty(t, repo.replacedUsers)
|
|
||||||
kratos.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMapKratosIdentityToLocalUserPreservesArchivedStatus(t *testing.T) {
|
|
||||||
user := MapKratosIdentityToLocalUser(KratosIdentity{
|
|
||||||
ID: "00000000-0000-0000-0000-000000000201",
|
|
||||||
State: domain.UserStatusArchived,
|
|
||||||
Traits: map[string]any{
|
|
||||||
"email": "archived@example.com",
|
|
||||||
"name": "Archived User",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.Equal(t, domain.UserStatusArchived, user.Status)
|
|
||||||
}
|
|
||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -33,6 +34,7 @@ type WorksmobileDirectoryClient interface {
|
|||||||
DeleteOrgUnit(ctx context.Context, orgUnitID string) error
|
DeleteOrgUnit(ctx context.Context, orgUnitID string) error
|
||||||
CreateUser(ctx context.Context, payload WorksmobileUserPayload) error
|
CreateUser(ctx context.Context, payload WorksmobileUserPayload) error
|
||||||
UpsertUser(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
|
AddUserAliasEmail(ctx context.Context, userID string, email string) error
|
||||||
ResetUserPassword(ctx context.Context, userID string, password string) error
|
ResetUserPassword(ctx context.Context, userID string, password string) error
|
||||||
DeleteUser(ctx context.Context, userID 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 {
|
func (c *WorksmobileHTTPClient) CreateUser(ctx context.Context, payload WorksmobileUserPayload) error {
|
||||||
|
payload = normalizeWorksmobileUserCreatePayload(payload)
|
||||||
return c.sendDirectoryJSON(ctx, http.MethodPost, "/v1.0/users", payload)
|
return c.sendDirectoryJSON(ctx, http.MethodPost, "/v1.0/users", payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *WorksmobileHTTPClient) UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error {
|
func (c *WorksmobileHTTPClient) UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error {
|
||||||
err := c.CreateUser(ctx, payload)
|
err := c.CreateUser(ctx, payload)
|
||||||
if apiErr, ok := err.(WorksmobileHTTPError); ok && apiErr.StatusCode == http.StatusConflict {
|
if apiErr, ok := err.(WorksmobileHTTPError); ok && apiErr.StatusCode == http.StatusConflict {
|
||||||
identifier := strings.TrimSpace(payload.Email)
|
if patchErr := c.updateUserByPatchOnly(ctx, payload); patchErr != nil {
|
||||||
if identifier == "" {
|
|
||||||
identifier = strings.TrimSpace(payload.UserExternalKey)
|
|
||||||
}
|
|
||||||
if patchErr := c.PatchUser(ctx, identifier, NewWorksmobileUserPatchPayload(payload)); patchErr != nil {
|
|
||||||
return fmt.Errorf("worksmobile user create conflict: %w; patch after conflict failed: %v", err, patchErr)
|
return fmt.Errorf("worksmobile user create conflict: %w; patch after conflict failed: %v", err, patchErr)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -342,6 +341,163 @@ func (c *WorksmobileHTTPClient) UpsertUser(ctx context.Context, payload Worksmob
|
|||||||
return err
|
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 {
|
func (c *WorksmobileHTTPClient) AddUserAliasEmail(ctx context.Context, userID string, email string) error {
|
||||||
userID = strings.TrimSpace(userID)
|
userID = strings.TrimSpace(userID)
|
||||||
email = strings.TrimSpace(email)
|
email = strings.TrimSpace(email)
|
||||||
@@ -995,7 +1151,22 @@ func NewWorksmobileSCIMUserPayload(payload WorksmobileUserPayload) WorksmobileSC
|
|||||||
}
|
}
|
||||||
|
|
||||||
func normalizeWorksmobileOutboundCellPhone(value string) string {
|
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 {
|
func NewWorksmobileUserPatchPayload(payload WorksmobileUserPayload) WorksmobileUserPatchPayload {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ func TestWorksmobileHTTPClientCreateUserPostsDirectoryAdminPasswordPayload(t *te
|
|||||||
Email: "tester@samaneng.com",
|
Email: "tester@samaneng.com",
|
||||||
UserExternalKey: "user-1",
|
UserExternalKey: "user-1",
|
||||||
UserName: WorksmobileUserName{LastName: "Tester"},
|
UserName: WorksmobileUserName{LastName: "Tester"},
|
||||||
|
CellPhone: "+821041585840",
|
||||||
AliasEmails: []string{"tester.alias@samaneng.com", "tester.alias2@samaneng.com"},
|
AliasEmails: []string{"tester.alias@samaneng.com", "tester.alias2@samaneng.com"},
|
||||||
Locale: "ko_KR",
|
Locale: "ko_KR",
|
||||||
PasswordConfig: WorksmobilePasswordConfig{
|
PasswordConfig: WorksmobilePasswordConfig{
|
||||||
@@ -57,11 +58,13 @@ func TestWorksmobileHTTPClientCreateUserPostsDirectoryAdminPasswordPayload(t *te
|
|||||||
require.NoError(t, json.Unmarshal(transport.requestBody, &payload))
|
require.NoError(t, json.Unmarshal(transport.requestBody, &payload))
|
||||||
require.Equal(t, "tester@samaneng.com", payload["email"])
|
require.Equal(t, "tester@samaneng.com", payload["email"])
|
||||||
require.Equal(t, "user-1", payload["userExternalKey"])
|
require.Equal(t, "user-1", payload["userExternalKey"])
|
||||||
|
require.Equal(t, "+82 01041585840", payload["cellPhone"])
|
||||||
require.NotContains(t, payload, "privateEmail")
|
require.NotContains(t, payload, "privateEmail")
|
||||||
require.Equal(t, []any{"tester.alias@samaneng.com", "tester.alias2@samaneng.com"}, payload["aliasEmails"])
|
require.Equal(t, []any{"tester.alias@samaneng.com", "tester.alias2@samaneng.com"}, payload["aliasEmails"])
|
||||||
passwordConfig := payload["passwordConfig"].(map[string]any)
|
passwordConfig := payload["passwordConfig"].(map[string]any)
|
||||||
require.Equal(t, "ADMIN", passwordConfig["passwordCreationType"])
|
require.Equal(t, "ADMIN", passwordConfig["passwordCreationType"])
|
||||||
require.Len(t, passwordConfig["password"], 16)
|
require.Len(t, passwordConfig["password"], 16)
|
||||||
|
require.Equal(t, true, passwordConfig["changePasswordAtNextLogin"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWorksmobileHTTPClientDeleteUserUsesDirectDirectoryDeleteForEmail(t *testing.T) {
|
func TestWorksmobileHTTPClientDeleteUserUsesDirectDirectoryDeleteForEmail(t *testing.T) {
|
||||||
@@ -92,7 +95,7 @@ func TestNewWorksmobileUserPatchPayloadNormalizesMalformedKoreanCellPhone(t *tes
|
|||||||
UserName: WorksmobileUserName{LastName: "Phone Canonical User"},
|
UserName: WorksmobileUserName{LastName: "Phone Canonical User"},
|
||||||
})
|
})
|
||||||
|
|
||||||
require.Equal(t, "+821062836786", payload.CellPhone)
|
require.Equal(t, "+82 01062836786", payload.CellPhone)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewWorksmobileSCIMUserPayloadNormalizesMalformedKoreanCellPhone(t *testing.T) {
|
func TestNewWorksmobileSCIMUserPayloadNormalizesMalformedKoreanCellPhone(t *testing.T) {
|
||||||
@@ -103,7 +106,7 @@ func TestNewWorksmobileSCIMUserPayloadNormalizesMalformedKoreanCellPhone(t *test
|
|||||||
})
|
})
|
||||||
|
|
||||||
require.Len(t, payload.PhoneNumbers, 1)
|
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) {
|
func TestWorksmobileHTTPClientUpsertUserPatchesOnCreateConflictWithoutPasswordOrPrivateEmail(t *testing.T) {
|
||||||
@@ -155,6 +158,196 @@ func TestWorksmobileHTTPClientUpsertUserPatchesOnCreateConflictWithoutPasswordOr
|
|||||||
require.Equal(t, "user-1", patchPayload["userExternalKey"])
|
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) {
|
func TestWorksmobileHTTPClientAddUserAliasEmailPostsDirectoryAliasEndpoint(t *testing.T) {
|
||||||
transport := &captureRoundTripper{
|
transport := &captureRoundTripper{
|
||||||
statusCode: http.StatusCreated,
|
statusCode: http.StatusCreated,
|
||||||
@@ -637,6 +830,45 @@ func TestWorksmobileRelayWorkerProcessesUserCreateAndMarksProcessed(t *testing.T
|
|||||||
require.Equal(t, "tester@samaneng.com", client.createdUsers[0].Email)
|
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) {
|
func TestWorksmobileRelayWorkerRegistersAliasEmailsAfterUserUpsert(t *testing.T) {
|
||||||
repo := &fakeWorksmobileOutboxRepo{
|
repo := &fakeWorksmobileOutboxRepo{
|
||||||
ready: []domain.WorksmobileOutbox{
|
ready: []domain.WorksmobileOutbox{
|
||||||
@@ -1482,6 +1714,7 @@ type fakeWorksmobileDirectoryClient struct {
|
|||||||
createdOrgUnits []WorksmobileOrgUnitPayload
|
createdOrgUnits []WorksmobileOrgUnitPayload
|
||||||
deletedOrgUnits []string
|
deletedOrgUnits []string
|
||||||
createdUsers []WorksmobileUserPayload
|
createdUsers []WorksmobileUserPayload
|
||||||
|
updatedUsers []WorksmobileUserPayload
|
||||||
deletedUsers []string
|
deletedUsers []string
|
||||||
activeUsers []string
|
activeUsers []string
|
||||||
suspendedUsers []string
|
suspendedUsers []string
|
||||||
@@ -1610,6 +1843,11 @@ func (f *fakeWorksmobileDirectoryClient) UpsertUser(ctx context.Context, payload
|
|||||||
return nil
|
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 {
|
func (f *fakeWorksmobileDirectoryClient) AddUserAliasEmail(ctx context.Context, userID string, email string) error {
|
||||||
f.aliasEmails = append(f.aliasEmails, userID+":"+email)
|
f.aliasEmails = append(f.aliasEmails, userID+":"+email)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -202,6 +202,14 @@ func BuildWorksmobileUserPayloadForDomainTenant(user domain.User, tenant domain.
|
|||||||
}
|
}
|
||||||
|
|
||||||
func BuildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain.Tenant, tenantByID map[string]domain.Tenant, rootConfig domain.JSONMap) (WorksmobileUserPayload, error) {
|
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 {
|
if err := ValidateWorksmobileExternalKey(user.ID); err != nil {
|
||||||
return WorksmobileUserPayload{}, err
|
return WorksmobileUserPayload{}, err
|
||||||
}
|
}
|
||||||
@@ -211,7 +219,9 @@ func BuildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain
|
|||||||
if tenantByID == nil {
|
if tenantByID == nil {
|
||||||
tenantByID = map[string]domain.Tenant{}
|
tenantByID = map[string]domain.Tenant{}
|
||||||
}
|
}
|
||||||
tenantByID[tenant.ID] = tenant
|
if includeFallbackTenant {
|
||||||
|
tenantByID[tenant.ID] = tenant
|
||||||
|
}
|
||||||
domainID, err := ResolveWorksmobileAccountDomainIDFromEmail(user.Email, tenant, rootConfig)
|
domainID, err := ResolveWorksmobileAccountDomainIDFromEmail(user.Email, tenant, rootConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return WorksmobileUserPayload{}, err
|
return WorksmobileUserPayload{}, err
|
||||||
@@ -253,7 +263,7 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
|
|||||||
appointments := worksmobileAppointmentsFromMetadata(user.Metadata)
|
appointments := worksmobileAppointmentsFromMetadata(user.Metadata)
|
||||||
if len(appointments) == 0 {
|
if len(appointments) == 0 {
|
||||||
appointments = []worksmobileAppointment{{TenantID: tenant.ID, IsPrimary: true}}
|
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{{
|
appointments = append([]worksmobileAppointment{{
|
||||||
TenantID: tenant.ID,
|
TenantID: tenant.ID,
|
||||||
IsPrimary: true,
|
IsPrimary: true,
|
||||||
@@ -284,6 +294,10 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if worksmobileTenantExcludedFromSync(appointmentTenant, tenantByID) {
|
||||||
|
seen[appointment.TenantID] = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
if worksmobileShouldSkipEmailDomainRootAppointment(appointment, appointmentTenant, appointments, tenantByID) {
|
if worksmobileShouldSkipEmailDomainRootAppointment(appointment, appointmentTenant, appointments, tenantByID) {
|
||||||
seen[appointment.TenantID] = true
|
seen[appointment.TenantID] = true
|
||||||
continue
|
continue
|
||||||
@@ -303,8 +317,7 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
isAccountDomain := worksmobileTenantDomainIDEnvKey(domainTenant) == accountDomainEnvKey
|
isPrimaryOrganization := !worksmobileOrganizationsHavePrimary(organizations)
|
||||||
isPrimaryOrganization := isAccountDomain && !worksmobileOrganizationsHavePrimary(organizations)
|
|
||||||
organizationIndex, organizationExists := organizationIndexByDomainID[domainID]
|
organizationIndex, organizationExists := organizationIndexByDomainID[domainID]
|
||||||
orgUnit := WorksmobileUserOrgUnit{
|
orgUnit := WorksmobileUserOrgUnit{
|
||||||
OrgUnitID: "externalKey:" + appointmentTenant.ID,
|
OrgUnitID: "externalKey:" + appointmentTenant.ID,
|
||||||
@@ -361,6 +374,23 @@ func worksmobileAppointmentsContainTenant(appointments []worksmobileAppointment,
|
|||||||
return false
|
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 {
|
func worksmobileAppointmentsHavePrimary(appointments []worksmobileAppointment) bool {
|
||||||
for _, appointment := range appointments {
|
for _, appointment := range appointments {
|
||||||
if appointment.IsPrimary {
|
if appointment.IsPrimary {
|
||||||
@@ -376,6 +406,9 @@ func worksmobileAppointmentsContainDomain(appointments []worksmobileAppointment,
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if worksmobileTenantExcludedFromSync(tenant, tenantByID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
domainTenant := worksmobileDomainClassificationTenant(tenant, tenantByID)
|
domainTenant := worksmobileDomainClassificationTenant(tenant, tenantByID)
|
||||||
if worksmobileTenantDomainIDEnvKey(domainTenant) == envKey {
|
if worksmobileTenantDomainIDEnvKey(domainTenant) == envKey {
|
||||||
return true
|
return true
|
||||||
@@ -384,6 +417,26 @@ func worksmobileAppointmentsContainDomain(appointments []worksmobileAppointment,
|
|||||||
return false
|
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 {
|
func worksmobileShouldSkipEmailDomainRootAppointment(appointment worksmobileAppointment, tenant domain.Tenant, appointments []worksmobileAppointment, tenantByID map[string]domain.Tenant) bool {
|
||||||
if strings.TrimSpace(appointment.Source) != "email_domain" || !isWorksmobileDomainRootTenant(tenant) {
|
if strings.TrimSpace(appointment.Source) != "email_domain" || !isWorksmobileDomainRootTenant(tenant) {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -315,19 +315,164 @@ func TestBuildWorksmobileUserPayloadMapsAdditionalAppointmentsToOrgUnits(t *test
|
|||||||
)
|
)
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, "Engineering", payload.Task)
|
require.Equal(t, "PM", payload.Task)
|
||||||
require.Len(t, payload.Organizations, 2)
|
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.Equal(t, int64(1001), payload.Organizations[0].DomainID)
|
||||||
require.True(t, payload.Organizations[0].Primary)
|
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.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) {
|
func TestBuildWorksmobileUserPayloadKeepsPrimaryTenantWhenEmailDomainAppointmentExists(t *testing.T) {
|
||||||
|
|||||||
@@ -141,7 +141,11 @@ func (w *WorksmobileRelayWorker) dispatch(ctx context.Context, job domain.Worksm
|
|||||||
}
|
}
|
||||||
aliasEmails := append([]string(nil), payload.AliasEmails...)
|
aliasEmails := append([]string(nil), payload.AliasEmails...)
|
||||||
payload.AliasEmails = nil
|
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)
|
return fmt.Errorf("worksmobile user upsert failed: %w", err)
|
||||||
}
|
}
|
||||||
for _, aliasEmail := range aliasEmails {
|
for _, aliasEmail := range aliasEmails {
|
||||||
|
|||||||
@@ -16,14 +16,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
HanmacFamilyTenantSlug = "hanmac-family"
|
HanmacFamilyTenantSlug = "hanmac-family"
|
||||||
worksmobileExcludedConfigKey = "worksmobileExcluded"
|
worksmobileExcludedConfigKey = "worksmobileExcluded"
|
||||||
|
worksmobileIdentityMirrorVersion = "kratos-full-pagination-v1"
|
||||||
|
worksmobileProvisioningModeKey = "provisioningMode"
|
||||||
|
worksmobileProvisioningUpdateOnly = "update_only"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WorksmobileSyncer interface {
|
type WorksmobileSyncer interface {
|
||||||
EnqueueTenantUpsertIfInScope(ctx context.Context, tenant domain.Tenant) error
|
EnqueueTenantUpsertIfInScope(ctx context.Context, tenant domain.Tenant) error
|
||||||
EnqueueTenantDeleteIfInScope(ctx context.Context, tenant domain.Tenant) error
|
EnqueueTenantDeleteIfInScope(ctx context.Context, tenant domain.Tenant) error
|
||||||
EnqueueUserUpsertIfInScope(ctx context.Context, user domain.User) 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
|
EnqueueUserDeleteIfInScope(ctx context.Context, user domain.User) error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,55 +107,62 @@ type WorksmobileComparison struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type WorksmobileComparisonItem struct {
|
type WorksmobileComparisonItem struct {
|
||||||
ResourceType string `json:"resourceType"`
|
ResourceType string `json:"resourceType"`
|
||||||
BaronID string `json:"baronId,omitempty"`
|
BaronID string `json:"baronId,omitempty"`
|
||||||
BaronSlug string `json:"baronSlug,omitempty"`
|
BaronSlug string `json:"baronSlug,omitempty"`
|
||||||
BaronName string `json:"baronName,omitempty"`
|
BaronName string `json:"baronName,omitempty"`
|
||||||
BaronEmail string `json:"baronEmail,omitempty"`
|
BaronEmail string `json:"baronEmail,omitempty"`
|
||||||
BaronPhone string `json:"baronPhone,omitempty"`
|
BaronPhone string `json:"baronPhone,omitempty"`
|
||||||
BaronEmployeeNumber string `json:"baronEmployeeNumber,omitempty"`
|
BaronEmployeeNumber string `json:"baronEmployeeNumber,omitempty"`
|
||||||
BaronPrimaryOrgID string `json:"baronPrimaryOrgId,omitempty"`
|
BaronPrimaryOrgID string `json:"baronPrimaryOrgId,omitempty"`
|
||||||
BaronPrimaryOrgSlug string `json:"baronPrimaryOrgSlug,omitempty"`
|
BaronPrimaryOrgSlug string `json:"baronPrimaryOrgSlug,omitempty"`
|
||||||
BaronPrimaryOrgName string `json:"baronPrimaryOrgName,omitempty"`
|
BaronPrimaryOrgName string `json:"baronPrimaryOrgName,omitempty"`
|
||||||
BaronParentID string `json:"baronParentId,omitempty"`
|
BaronParentID string `json:"baronParentId,omitempty"`
|
||||||
BaronParentSlug string `json:"baronParentSlug,omitempty"`
|
BaronParentSlug string `json:"baronParentSlug,omitempty"`
|
||||||
BaronParentName string `json:"baronParentName,omitempty"`
|
BaronParentName string `json:"baronParentName,omitempty"`
|
||||||
WorksmobileID string `json:"worksmobileId,omitempty"`
|
WorksmobileID string `json:"worksmobileId,omitempty"`
|
||||||
ExternalKey string `json:"externalKey,omitempty"`
|
ExternalKey string `json:"externalKey,omitempty"`
|
||||||
WorksmobileName string `json:"worksmobileName,omitempty"`
|
WorksmobileName string `json:"worksmobileName,omitempty"`
|
||||||
WorksmobileEmail string `json:"worksmobileEmail,omitempty"`
|
WorksmobileEmail string `json:"worksmobileEmail,omitempty"`
|
||||||
WorksmobilePhone string `json:"worksmobilePhone,omitempty"`
|
WorksmobilePhone string `json:"worksmobilePhone,omitempty"`
|
||||||
WorksmobileEmployeeNumber string `json:"worksmobileEmployeeNumber,omitempty"`
|
WorksmobileEmployeeNumber string `json:"worksmobileEmployeeNumber,omitempty"`
|
||||||
WorksmobileAccountStatus string `json:"worksmobileAccountStatus,omitempty"`
|
WorksmobileAccountStatus string `json:"worksmobileAccountStatus,omitempty"`
|
||||||
WorksmobileLevelID string `json:"worksmobileLevelId,omitempty"`
|
WorksmobileLevelID string `json:"worksmobileLevelId,omitempty"`
|
||||||
WorksmobileLevelName string `json:"worksmobileLevelName,omitempty"`
|
WorksmobileLevelName string `json:"worksmobileLevelName,omitempty"`
|
||||||
WorksmobileTask string `json:"worksmobileTask,omitempty"`
|
WorksmobileTask string `json:"worksmobileTask,omitempty"`
|
||||||
WorksmobileDomainID int64 `json:"worksmobileDomainId,omitempty"`
|
WorksmobileDomainID int64 `json:"worksmobileDomainId,omitempty"`
|
||||||
WorksmobileDomainName string `json:"worksmobileDomainName,omitempty"`
|
WorksmobileDomainName string `json:"worksmobileDomainName,omitempty"`
|
||||||
WorksmobilePrimaryOrgID string `json:"worksmobilePrimaryOrgId,omitempty"`
|
WorksmobilePrimaryOrgID string `json:"worksmobilePrimaryOrgId,omitempty"`
|
||||||
WorksmobilePrimaryOrgName string `json:"worksmobilePrimaryOrgName,omitempty"`
|
WorksmobilePrimaryOrgName string `json:"worksmobilePrimaryOrgName,omitempty"`
|
||||||
WorksmobilePrimaryOrgPositionID string `json:"worksmobilePrimaryOrgPositionId,omitempty"`
|
WorksmobilePrimaryOrgPositionID string `json:"worksmobilePrimaryOrgPositionId,omitempty"`
|
||||||
WorksmobilePrimaryOrgPositionName string `json:"worksmobilePrimaryOrgPositionName,omitempty"`
|
WorksmobilePrimaryOrgPositionName string `json:"worksmobilePrimaryOrgPositionName,omitempty"`
|
||||||
WorksmobilePrimaryOrgIsManager *bool `json:"worksmobilePrimaryOrgIsManager,omitempty"`
|
WorksmobilePrimaryOrgIsManager *bool `json:"worksmobilePrimaryOrgIsManager,omitempty"`
|
||||||
BaronParentWorksmobileID string `json:"baronParentWorksmobileId,omitempty"`
|
BaronParentWorksmobileID string `json:"baronParentWorksmobileId,omitempty"`
|
||||||
BaronParentWorksmobileName string `json:"baronParentWorksmobileName,omitempty"`
|
BaronParentWorksmobileName string `json:"baronParentWorksmobileName,omitempty"`
|
||||||
BaronParentWorksmobileEmail string `json:"baronParentWorksmobileEmail,omitempty"`
|
BaronParentWorksmobileEmail string `json:"baronParentWorksmobileEmail,omitempty"`
|
||||||
WorksmobileParentID string `json:"worksmobileParentId,omitempty"`
|
WorksmobileParentID string `json:"worksmobileParentId,omitempty"`
|
||||||
WorksmobileParentName string `json:"worksmobileParentName,omitempty"`
|
WorksmobileParentName string `json:"worksmobileParentName,omitempty"`
|
||||||
WorksmobileParentEmail string `json:"worksmobileParentEmail,omitempty"`
|
WorksmobileParentEmail string `json:"worksmobileParentEmail,omitempty"`
|
||||||
WorksmobileParentExternalKey string `json:"worksmobileParentExternalKey,omitempty"`
|
WorksmobileParentExternalKey string `json:"worksmobileParentExternalKey,omitempty"`
|
||||||
WorksmobileJobStatus string `json:"worksmobileJobStatus,omitempty"`
|
WorksmobileJobStatus string `json:"worksmobileJobStatus,omitempty"`
|
||||||
WorksmobileJobRetryCount int `json:"worksmobileJobRetryCount,omitempty"`
|
WorksmobileJobRetryCount int `json:"worksmobileJobRetryCount,omitempty"`
|
||||||
WorksmobileLastError string `json:"worksmobileLastError,omitempty"`
|
WorksmobileLastError string `json:"worksmobileLastError,omitempty"`
|
||||||
WorksmobileLastAttemptAt string `json:"worksmobileLastAttemptAt,omitempty"`
|
WorksmobileLastAttemptAt string `json:"worksmobileLastAttemptAt,omitempty"`
|
||||||
Status string `json:"status"`
|
UpdateReasons []string `json:"updateReasons,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type worksmobileSyncService struct {
|
type worksmobileSyncService struct {
|
||||||
tenantService TenantService
|
tenantService TenantService
|
||||||
userRepo repository.UserRepository
|
userRepo repository.UserRepository
|
||||||
outboxRepo repository.WorksmobileOutboxRepository
|
outboxRepo repository.WorksmobileOutboxRepository
|
||||||
client WorksmobileDirectoryClient
|
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 {
|
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) {
|
func (s *worksmobileSyncService) GetTenantOverview(ctx context.Context, tenantID string) (WorksmobileTenantOverview, error) {
|
||||||
tenant, err := s.tenantService.GetTenant(ctx, tenantID)
|
tenant, err := s.tenantService.GetTenant(ctx, tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -344,7 +362,7 @@ func (s *worksmobileSyncService) GetComparison(ctx context.Context, tenantID str
|
|||||||
tenantIDs = append(tenantIDs, tenant.ID)
|
tenantIDs = append(tenantIDs, tenant.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
users, err := s.userRepo.FindByTenantIDs(ctx, tenantIDs)
|
users, err := s.comparisonUsers(ctx, tenantIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return WorksmobileComparison{}, err
|
return WorksmobileComparison{}, err
|
||||||
}
|
}
|
||||||
@@ -360,11 +378,96 @@ func (s *worksmobileSyncService) GetComparison(ctx context.Context, tenantID str
|
|||||||
recentJobs, _ := s.outboxRepo.ListRecent(ctx, 1000)
|
recentJobs, _ := s.outboxRepo.ListRecent(ctx, 1000)
|
||||||
|
|
||||||
return WorksmobileComparison{
|
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),
|
Groups: compareWorksmobileGroups(append([]domain.Tenant{*root}, tenants...), remoteGroups, includeMatched),
|
||||||
}, nil
|
}, 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) {
|
func (s *worksmobileSyncService) EnqueueBackfillDryRun(ctx context.Context, tenantID string) (WorksmobileBackfillDryRun, error) {
|
||||||
root, err := s.hanmacRoot(ctx, tenantID)
|
root, err := s.hanmacRoot(ctx, tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -545,35 +648,32 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
||||||
if _, ok := tenantByID[tenant.ID]; !ok {
|
_, tenantInScope := tenantByID[tenant.ID]
|
||||||
return nil, errors.New("target user tenant is excluded from Worksmobile sync")
|
|
||||||
}
|
|
||||||
if domain.IsWorksDeprovisionUserStatus(user.Status) {
|
if domain.IsWorksDeprovisionUserStatus(user.Status) {
|
||||||
return s.enqueueUserDelete(ctx, *user, "user:delete:"+user.ID, root.ID)
|
return s.enqueueUserDelete(ctx, *user, "user:delete:"+user.ID, root.ID)
|
||||||
}
|
}
|
||||||
if !domain.IsWorksProvisionedUserStatus(user.Status) {
|
if !domain.IsWorksProvisionedUserStatus(user.Status) {
|
||||||
return nil, errors.New("target user status is excluded from Worksmobile sync")
|
err := errors.New("target user status is excluded from Worksmobile sync")
|
||||||
}
|
if recordErr := s.recordRejectedUserSync(ctx, root.ID, *user, *tenant, err); recordErr != nil {
|
||||||
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
|
return nil, errors.Join(err, recordErr)
|
||||||
*user,
|
}
|
||||||
*tenant,
|
|
||||||
tenantByID,
|
|
||||||
root.Config,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
initialPassword = strings.TrimSpace(initialPassword)
|
buildPayload := BuildWorksmobileUserPayloadForDomainTenants
|
||||||
if initialPassword != "" {
|
if !tenantInScope {
|
||||||
payload.PasswordConfig = WorksmobilePasswordConfig{
|
buildPayload = BuildWorksmobileUserPayloadForScopedDomainTenants
|
||||||
PasswordCreationType: "ADMIN",
|
}
|
||||||
Password: initialPassword,
|
payload, err := buildPayload(*user, *tenant, tenantByID, root.Config)
|
||||||
}
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := s.validateUserAliasLocalParts(ctx, root, *user, payload); err != nil {
|
if err := s.validateUserAliasLocalParts(ctx, root, *user, payload); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
action := WorksmobileUserStatusAction(user.Status)
|
action := WorksmobileUserStatusAction(user.Status)
|
||||||
|
if action == domain.WorksmobileActionUpsert {
|
||||||
|
payload.PasswordConfig = worksmobileAdminInitialPasswordConfig(initialPassword)
|
||||||
|
}
|
||||||
item := &domain.WorksmobileOutbox{
|
item := &domain.WorksmobileOutbox{
|
||||||
ResourceType: domain.WorksmobileResourceUser,
|
ResourceType: domain.WorksmobileResourceUser,
|
||||||
ResourceID: user.ID,
|
ResourceID: user.ID,
|
||||||
@@ -594,10 +694,48 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
|
|||||||
return item, nil
|
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 {
|
func worksmobileUserSyncDedupeKey(action, userID string) string {
|
||||||
return "user:" + strings.ToLower(action) + ":" + userID + ":" + uuid.NewString()
|
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) {
|
func (s *worksmobileSyncService) EnqueueUserPasswordReset(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) {
|
||||||
root, err := s.hanmacRoot(ctx, tenantID)
|
root, err := s.hanmacRoot(ctx, tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -629,10 +767,11 @@ func (s *worksmobileSyncService) EnqueueUserPasswordReset(ctx context.Context, t
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
||||||
|
buildPayload := BuildWorksmobileUserPayloadForDomainTenants
|
||||||
if _, ok := tenantByID[tenant.ID]; !ok {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
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 == "" {
|
if user.TenantID == nil || *user.TenantID == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -887,12 +1034,19 @@ func (s *worksmobileSyncService) EnqueueUserUpsertIfInScope(ctx context.Context,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
action := WorksmobileUserStatusAction(user.Status)
|
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{
|
return s.outboxRepo.Create(ctx, &domain.WorksmobileOutbox{
|
||||||
ResourceType: domain.WorksmobileResourceUser,
|
ResourceType: domain.WorksmobileResourceUser,
|
||||||
ResourceID: user.ID,
|
ResourceID: user.ID,
|
||||||
Action: action,
|
Action: action,
|
||||||
DedupeKey: worksmobileUserSyncDedupeKey(action, user.ID),
|
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 {
|
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{}
|
jobSummaryByUserID := map[string]worksmobileUserJobSummary{}
|
||||||
if len(jobSummaries) > 0 && jobSummaries[0] != nil {
|
if len(jobSummaries) > 0 && jobSummaries[0] != nil {
|
||||||
jobSummaryByUserID = jobSummaries[0]
|
jobSummaryByUserID = jobSummaries[0]
|
||||||
}
|
}
|
||||||
|
remoteOrgUnitByExternalID := worksmobileRemoteOrgUnitByExternalID(remoteGroups)
|
||||||
remoteByExternalID := map[string]WorksmobileRemoteUser{}
|
remoteByExternalID := map[string]WorksmobileRemoteUser{}
|
||||||
remoteByEmail := map[string]WorksmobileRemoteUser{}
|
remoteByEmail := map[string]WorksmobileRemoteUser{}
|
||||||
for _, remote := range remoteUsers {
|
for _, remote := range remoteUsers {
|
||||||
@@ -1462,7 +1621,11 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
|
|||||||
if !matched {
|
if !matched {
|
||||||
remote, matched = remoteByEmail[strings.ToLower(strings.TrimSpace(user.Email))]
|
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 {
|
if matched && !includeMatched && !needsUpdate {
|
||||||
matchedRemoteIDs[remote.ID] = true
|
matchedRemoteIDs[remote.ID] = true
|
||||||
continue
|
continue
|
||||||
@@ -1491,6 +1654,7 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
|
|||||||
item.Status = "matched"
|
item.Status = "matched"
|
||||||
if needsUpdate {
|
if needsUpdate {
|
||||||
item.Status = "needs_update"
|
item.Status = "needs_update"
|
||||||
|
item.UpdateReasons = updateReasons
|
||||||
}
|
}
|
||||||
item.WorksmobileID = remote.ID
|
item.WorksmobileID = remote.ID
|
||||||
item.ExternalKey = remote.ExternalID
|
item.ExternalKey = remote.ExternalID
|
||||||
@@ -1571,6 +1735,18 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
|
|||||||
return result
|
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 {
|
func worksmobileRemoteAccountStatus(remote WorksmobileRemoteUser) string {
|
||||||
return normalizeWorksmobileAccountStatus(
|
return normalizeWorksmobileAccountStatus(
|
||||||
remote.AccountStatus,
|
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) {
|
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) {
|
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)) {
|
if strings.ToLower(strings.TrimSpace(remote.Email)) != strings.ToLower(strings.TrimSpace(user.Email)) {
|
||||||
return true
|
reasons = append(reasons, "email")
|
||||||
}
|
}
|
||||||
if worksmobileUserPhoneNeedsUpdate(user, remote) {
|
if worksmobileUserPhoneNeedsUpdate(user, remote) {
|
||||||
return true
|
reasons = append(reasons, "phone")
|
||||||
}
|
}
|
||||||
if worksmobileUserEmployeeNumberNeedsUpdate(user, remote) {
|
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 {
|
func worksmobileUserPhoneNeedsUpdate(user domain.User, remote WorksmobileRemoteUser) bool {
|
||||||
localPhone := normalizeWorksmobilePhoneForCompare(user.Phone)
|
localPhone := normalizeWorksmobilePhoneForCompare(user.Phone)
|
||||||
remotePhone := normalizeWorksmobilePhoneForCompare(remote.CellPhone)
|
remotePhone := normalizeWorksmobilePhoneForCompare(remote.CellPhone)
|
||||||
if localPhone == "" && remotePhone == "" {
|
if localPhone == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if localPhone != remotePhone {
|
if localPhone != remotePhone {
|
||||||
@@ -1636,11 +1823,11 @@ func worksmobileUserEmployeeNumberNeedsUpdate(user domain.User, remote Worksmobi
|
|||||||
return localEmployeeNumber != remoteEmployeeNumber
|
return localEmployeeNumber != remoteEmployeeNumber
|
||||||
}
|
}
|
||||||
|
|
||||||
func worksmobileUserOrganizationsNeedUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant) bool {
|
func worksmobileUserOrganizationsNeedUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) bool {
|
||||||
if len(remote.Organizations) == 0 || user.TenantID == nil || localTenants == nil {
|
if localTenants == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
tenantID := strings.TrimSpace(*user.TenantID)
|
tenantID := worksmobileUserComparisonTenantID(user, localTenants)
|
||||||
if tenantID == "" {
|
if tenantID == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -1648,11 +1835,34 @@ func worksmobileUserOrganizationsNeedUpdate(user domain.User, remote Worksmobile
|
|||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
expected, err := BuildWorksmobileUserPayloadForDomainTenants(user, tenant, localTenants, worksmobileComparisonRootConfig(localTenants))
|
expectedOrganizations, _, err := buildWorksmobileUserOrganizations(user, tenant, localTenants, worksmobileComparisonRootConfig(localTenants))
|
||||||
if err != nil {
|
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 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 {
|
func worksmobileComparisonRootConfig(localTenants map[string]domain.Tenant) domain.JSONMap {
|
||||||
@@ -1664,9 +1874,131 @@ func worksmobileComparisonRootConfig(localTenants map[string]domain.Tenant) doma
|
|||||||
return nil
|
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 {
|
type worksmobileComparableOrgUnit struct {
|
||||||
organizationPrimary bool
|
organizationPrimary bool
|
||||||
organizationEmail string
|
|
||||||
unitPrimary bool
|
unitPrimary bool
|
||||||
positionID string
|
positionID string
|
||||||
comparePosition bool
|
comparePosition bool
|
||||||
@@ -1688,9 +2020,6 @@ func worksmobileUserOrganizationsEqual(expected []WorksmobileUserOrganization, r
|
|||||||
if expectedUnit.organizationPrimary != remoteUnit.organizationPrimary {
|
if expectedUnit.organizationPrimary != remoteUnit.organizationPrimary {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if strings.ToLower(expectedUnit.organizationEmail) != strings.ToLower(remoteUnit.organizationEmail) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if expectedUnit.unitPrimary != remoteUnit.unitPrimary {
|
if expectedUnit.unitPrimary != remoteUnit.unitPrimary {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -1704,6 +2033,23 @@ func worksmobileUserOrganizationsEqual(expected []WorksmobileUserOrganization, r
|
|||||||
return true
|
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 {
|
func flattenExpectedWorksmobileUserOrganizations(organizations []WorksmobileUserOrganization) map[string]worksmobileComparableOrgUnit {
|
||||||
result := map[string]worksmobileComparableOrgUnit{}
|
result := map[string]worksmobileComparableOrgUnit{}
|
||||||
for _, organization := range organizations {
|
for _, organization := range organizations {
|
||||||
@@ -1714,7 +2060,6 @@ func flattenExpectedWorksmobileUserOrganizations(organizations []WorksmobileUser
|
|||||||
}
|
}
|
||||||
result[key] = worksmobileComparableOrgUnit{
|
result[key] = worksmobileComparableOrgUnit{
|
||||||
organizationPrimary: organization.Primary,
|
organizationPrimary: organization.Primary,
|
||||||
organizationEmail: strings.TrimSpace(organization.Email),
|
|
||||||
unitPrimary: orgUnit.Primary,
|
unitPrimary: orgUnit.Primary,
|
||||||
positionID: strings.TrimSpace(orgUnit.PositionID),
|
positionID: strings.TrimSpace(orgUnit.PositionID),
|
||||||
comparePosition: strings.TrimSpace(orgUnit.PositionID) != "",
|
comparePosition: strings.TrimSpace(orgUnit.PositionID) != "",
|
||||||
@@ -1736,7 +2081,6 @@ func flattenRemoteWorksmobileUserOrganizations(organizations []WorksmobileUserOr
|
|||||||
}
|
}
|
||||||
result[key] = worksmobileComparableOrgUnit{
|
result[key] = worksmobileComparableOrgUnit{
|
||||||
organizationPrimary: organization.Primary,
|
organizationPrimary: organization.Primary,
|
||||||
organizationEmail: strings.TrimSpace(organization.Email),
|
|
||||||
unitPrimary: orgUnit.Primary,
|
unitPrimary: orgUnit.Primary,
|
||||||
positionID: strings.TrimSpace(orgUnit.PositionID),
|
positionID: strings.TrimSpace(orgUnit.PositionID),
|
||||||
manager: orgUnit.IsManager,
|
manager: orgUnit.IsManager,
|
||||||
@@ -1766,22 +2110,43 @@ func worksmobileUserManagerNeedsUpdate(user domain.User, remote WorksmobileRemot
|
|||||||
if len(localManagers) == 0 {
|
if len(localManagers) == 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
remoteManagers := remote.OrgUnitManagers
|
remoteManagers := worksmobileRemoteOrgUnitManagerMap(remote)
|
||||||
if len(remoteManagers) == 0 && remote.PrimaryOrgUnitID != "" {
|
for localOrgUnitID, localManager := range localManagers {
|
||||||
remoteManagers = map[string]*bool{remote.PrimaryOrgUnitID: remote.PrimaryOrgUnitIsManager}
|
remoteManager := false
|
||||||
}
|
if value, ok := remoteManagers[localOrgUnitID]; ok && value != nil {
|
||||||
for remoteOrgUnitID, remoteManager := range remoteManagers {
|
remoteManager = *value
|
||||||
if remoteManager == nil {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
localManager, ok := localManagers[worksmobileOrgUnitLocalExternalKey(remoteOrgUnitID)]
|
if localManager != remoteManager {
|
||||||
if ok && localManager != *remoteManager {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
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 {
|
func worksmobileUserExplicitOrgUnitManagers(user domain.User) map[string]bool {
|
||||||
managers := map[string]bool{}
|
managers := map[string]bool{}
|
||||||
for _, appointment := range worksmobileAppointmentsFromMetadata(user.Metadata) {
|
for _, appointment := range worksmobileAppointmentsFromMetadata(user.Metadata) {
|
||||||
|
|||||||
@@ -160,9 +160,11 @@ func TestWorksmobileSyncServiceEnqueuesUserCredentialBatchID(t *testing.T) {
|
|||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
require.Equal(t, "ADMIN", request.PasswordConfig.PasswordCreationType)
|
require.Equal(t, "ADMIN", request.PasswordConfig.PasswordCreationType)
|
||||||
require.Equal(t, "InputPass1!", request.PasswordConfig.Password)
|
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")
|
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||||
rootID := "root-tenant"
|
rootID := "root-tenant"
|
||||||
tenantID := "saman-tenant"
|
tenantID := "saman-tenant"
|
||||||
@@ -198,10 +200,14 @@ func TestWorksmobileSyncServiceDoesNotAutoGenerateInitialPassword(t *testing.T)
|
|||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, item)
|
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)
|
request, ok := outboxRepo.created[0].Payload["request"].(WorksmobileUserPayload)
|
||||||
require.True(t, ok)
|
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) {
|
func TestWorksmobileSyncServiceCreatesDistinctUserSyncHistoryJobs(t *testing.T) {
|
||||||
@@ -286,6 +292,48 @@ func TestWorksmobileSyncServiceCreatesDistinctAutomaticUserSyncHistoryJobs(t *te
|
|||||||
require.NotEqual(t, outboxRepo.created[0].DedupeKey, outboxRepo.created[1].DedupeKey)
|
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) {
|
func TestWorksmobileSyncServiceEnqueuesUserPasswordResetCredentialBatch(t *testing.T) {
|
||||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||||
rootID := "root-tenant"
|
rootID := "root-tenant"
|
||||||
@@ -333,6 +381,49 @@ func TestWorksmobileSyncServiceEnqueuesUserPasswordResetCredentialBatch(t *testi
|
|||||||
require.NotEmpty(t, outboxRepo.created[0].Payload["initialPassword"])
|
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) {
|
func TestWorksmobileSyncServiceFiltersInitialPasswordsByCredentialBatchID(t *testing.T) {
|
||||||
rootID := "root-tenant"
|
rootID := "root-tenant"
|
||||||
root := domain.Tenant{
|
root := domain.Tenant{
|
||||||
@@ -1650,6 +1741,69 @@ func TestWorksmobileSyncServiceKeepsCompanyUsersInComparisonScope(t *testing.T)
|
|||||||
require.ElementsMatch(t, []string{companyID, userGroupID}, userRepo.requestedTenantIDs)
|
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) {
|
func TestWorksmobileSyncServiceSkipsArchivedUsersInComparison(t *testing.T) {
|
||||||
rootID := "root-tenant"
|
rootID := "root-tenant"
|
||||||
companyID := "company-tenant"
|
companyID := "company-tenant"
|
||||||
@@ -1898,6 +2052,7 @@ func TestWorksmobileSyncServiceRejectsExcludedOrgUnitSync(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestWorksmobileSyncServiceSkipsExcludedTenantAndUserEventSync(t *testing.T) {
|
func TestWorksmobileSyncServiceSkipsExcludedTenantAndUserEventSync(t *testing.T) {
|
||||||
|
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||||
rootID := "root-tenant"
|
rootID := "root-tenant"
|
||||||
excludedCompanyID := "excluded-company"
|
excludedCompanyID := "excluded-company"
|
||||||
excludedOrgID := "excluded-org"
|
excludedOrgID := "excluded-org"
|
||||||
@@ -1944,12 +2099,26 @@ func TestWorksmobileSyncServiceSkipsExcludedTenantAndUserEventSync(t *testing.T)
|
|||||||
require.NoError(t, service.EnqueueUserUpsertIfInScope(context.Background(), user))
|
require.NoError(t, service.EnqueueUserUpsertIfInScope(context.Background(), user))
|
||||||
item, err := service.EnqueueUserSync(context.Background(), rootID, user.ID, "", "")
|
item, err := service.EnqueueUserSync(context.Background(), rootID, user.ID, "", "")
|
||||||
|
|
||||||
require.Nil(t, item)
|
require.NoError(t, err)
|
||||||
require.ErrorContains(t, err, "excluded from Worksmobile sync")
|
require.NotNil(t, item)
|
||||||
require.Empty(t, outboxRepo.created)
|
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"
|
tenantID := "tenant-leaf"
|
||||||
user := domain.User{
|
user := domain.User{
|
||||||
ID: "user-manager",
|
ID: "user-manager",
|
||||||
@@ -1977,20 +2146,37 @@ func TestCompareWorksmobileUsersIgnoresManagerChange(t *testing.T) {
|
|||||||
DisplayName: user.Name,
|
DisplayName: user.Name,
|
||||||
PrimaryOrgUnitID: "externalKey:" + tenantID,
|
PrimaryOrgUnitID: "externalKey:" + tenantID,
|
||||||
PrimaryOrgUnitIsManager: &remoteManager,
|
PrimaryOrgUnitIsManager: &remoteManager,
|
||||||
|
Organizations: []WorksmobileUserOrganization{
|
||||||
|
{
|
||||||
|
DomainID: 1001,
|
||||||
|
Email: user.Email,
|
||||||
|
Primary: true,
|
||||||
|
OrgUnits: []WorksmobileUserOrgUnit{
|
||||||
|
{
|
||||||
|
OrgUnitID: "externalKey:" + tenantID,
|
||||||
|
Primary: true,
|
||||||
|
IsManager: &remoteManager,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}},
|
}},
|
||||||
true,
|
true,
|
||||||
map[string]domain.Tenant{
|
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.Len(t, items, 1)
|
||||||
require.Equal(t, "matched", items[0].Status)
|
require.Equal(t, "needs_update", items[0].Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCompareWorksmobileUsersIgnoresSecondaryManagerChange(t *testing.T) {
|
func TestCompareWorksmobileUsersMarksSecondaryManagerChangeNeedsUpdate(t *testing.T) {
|
||||||
primaryTenantID := "tenant-company"
|
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||||
secondaryTenantID := "tenant-gpdtdc-leaf"
|
rootID := "tenant-saman"
|
||||||
|
primaryTenantID := "tenant-primary"
|
||||||
|
secondaryTenantID := "tenant-secondary"
|
||||||
user := domain.User{
|
user := domain.User{
|
||||||
ID: "user-secondary-manager",
|
ID: "user-secondary-manager",
|
||||||
Email: "secondary-manager@samaneng.com",
|
Email: "secondary-manager@samaneng.com",
|
||||||
@@ -2026,19 +2212,39 @@ func TestCompareWorksmobileUsersIgnoresSecondaryManagerChange(t *testing.T) {
|
|||||||
"externalKey:" + primaryTenantID: &remotePrimaryManager,
|
"externalKey:" + primaryTenantID: &remotePrimaryManager,
|
||||||
"externalKey:" + secondaryTenantID: &remoteSecondaryManager,
|
"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,
|
true,
|
||||||
map[string]domain.Tenant{
|
map[string]domain.Tenant{
|
||||||
primaryTenantID: {ID: primaryTenantID, Name: "Company", Type: domain.TenantTypeCompany},
|
rootID: {ID: rootID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
|
||||||
secondaryTenantID: {ID: secondaryTenantID, Name: "GPDTDC Leaf", Type: domain.TenantTypeOrganization},
|
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.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("SAMAN_DOMAIN_ID", "1001")
|
||||||
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
||||||
rootID := "tenant-root"
|
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.Len(t, items, 1)
|
||||||
require.Equal(t, "matched", items[0].Status)
|
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) {
|
func TestCompareWorksmobileUsersMarksPhoneAndEmployeeNumberChangesNeedsUpdate(t *testing.T) {
|
||||||
tenantID := "tenant-saman"
|
tenantID := "tenant-saman"
|
||||||
user := domain.User{
|
user := domain.User{
|
||||||
@@ -2167,6 +2699,63 @@ func TestCompareWorksmobileUsersMarksMalformedRemoteKoreanPhoneNeedsUpdate(t *te
|
|||||||
require.Equal(t, "needs_update", items[0].Status)
|
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 {
|
type fakeWorksmobileTenantService struct {
|
||||||
tenants map[string]domain.Tenant
|
tenants map[string]domain.Tenant
|
||||||
list []domain.Tenant
|
list []domain.Tenant
|
||||||
@@ -2229,6 +2818,19 @@ type fakeWorksmobileUserRepo struct {
|
|||||||
requestedTenantIDs []string
|
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) 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) Update(ctx context.Context, user *domain.User) error { return nil }
|
||||||
func (f *fakeWorksmobileUserRepo) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
|
func (f *fakeWorksmobileUserRepo) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { dateTimeInputToUnixSeconds } from "../src/features/clients/rpClaimDateTime";
|
||||||
import {
|
import {
|
||||||
type Consent,
|
type Consent,
|
||||||
installDevApiMock,
|
installDevApiMock,
|
||||||
@@ -7,7 +8,6 @@ import {
|
|||||||
} from "./helpers/devfront-fixtures";
|
} from "./helpers/devfront-fixtures";
|
||||||
import { captureEvidence } from "./helpers/evidence";
|
import { captureEvidence } from "./helpers/evidence";
|
||||||
import { installDevFrontStaticRoutes } from "./helpers/static-devfront";
|
import { installDevFrontStaticRoutes } from "./helpers/static-devfront";
|
||||||
import { dateTimeInputToUnixSeconds } from "../src/features/clients/rpClaimDateTime";
|
|
||||||
|
|
||||||
test.describe("DevFront consents", () => {
|
test.describe("DevFront consents", () => {
|
||||||
test.afterEach(async ({ page }, testInfo) => {
|
test.afterEach(async ({ page }, testInfo) => {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ services:
|
|||||||
- IDP_PROVIDER=ory
|
- IDP_PROVIDER=ory
|
||||||
- OATHKEEPER_API_URL=http://oathkeeper:4456
|
- OATHKEEPER_API_URL=http://oathkeeper:4456
|
||||||
- PROFILE_CACHE_TTL="${PROFILE_CACHE_TTL:-30m}"
|
- 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
|
- SEED_TENANT_CSV_PATH=/app/seed-tenant.csv
|
||||||
ports:
|
ports:
|
||||||
- "${BACKEND_PORT:-3000}:3000"
|
- "${BACKEND_PORT:-3000}:3000"
|
||||||
|
|||||||
@@ -381,7 +381,6 @@ services:
|
|||||||
- NAVER_SENDER_PHONE_NUMBER=${NAVER_SENDER_PHONE_NUMBER}
|
- NAVER_SENDER_PHONE_NUMBER=${NAVER_SENDER_PHONE_NUMBER}
|
||||||
- USERFRONT_URL=${USERFRONT_URL}
|
- USERFRONT_URL=${USERFRONT_URL}
|
||||||
- REDIS_ADDR=${REDIS_ADDR}
|
- REDIS_ADDR=${REDIS_ADDR}
|
||||||
- ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=${ORGFRONT_ORGCHART_CACHE_TTL_SECONDS:-3600}
|
|
||||||
- IDP_PROVIDER=${IDP_PROVIDER:-ory}
|
- IDP_PROVIDER=${IDP_PROVIDER:-ory}
|
||||||
- KRATOS_ADMIN_URL=${KRATOS_ADMIN_URL:-http://kratos:4434}
|
- KRATOS_ADMIN_URL=${KRATOS_ADMIN_URL:-http://kratos:4434}
|
||||||
- HYDRA_ADMIN_URL=${HYDRA_ADMIN_URL:-http://hydra:4445}
|
- HYDRA_ADMIN_URL=${HYDRA_ADMIN_URL:-http://hydra:4445}
|
||||||
|
|||||||
49
docs/adminfront-flicker-trace-analysis-2026-06-15.md
Normal file
49
docs/adminfront-flicker-trace-analysis-2026-06-15.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# adminfront 깜빡임 trace 분석
|
||||||
|
|
||||||
|
작성일: 2026-06-15
|
||||||
|
|
||||||
|
## 입력 자료
|
||||||
|
|
||||||
|
- `adminfront/Trace-20260615T113806.json.gz`
|
||||||
|
|
||||||
|
## 관찰 결과
|
||||||
|
|
||||||
|
- trace 구간은 약 4.18초이다.
|
||||||
|
- DevTools screenshot은 250장 포함되어 있다.
|
||||||
|
- 화면 전체 shell이 사라지는 현상은 아니고, 본문 영역이 반복적으로 repaint되는 형태이다.
|
||||||
|
- `Animation` 이벤트에서 `enter` 애니메이션이 147회 반복 시작되었다.
|
||||||
|
- 반복 대상은 다음 nodeName으로 확인되었다.
|
||||||
|
- `DIV class='space-y-4 animate-in fade-in duration-500'`
|
||||||
|
- 같은 nodeName의 `Paint` 이벤트도 147회 발생했고, span은 약 4.1초였다.
|
||||||
|
|
||||||
|
## 원인
|
||||||
|
|
||||||
|
장기 유지되는 admin page/tab container에 `animate-in fade-in duration-500` 진입 애니메이션이 붙어 있었다.
|
||||||
|
|
||||||
|
이 클래스가 query/refetch, tab content 갱신, 렌더 상태 변화와 결합되면서 본문 영역이 반복 진입 애니메이션을 수행했고, 사용자는 이를 간헐적인 깜빡임으로 보게 된다.
|
||||||
|
|
||||||
|
## 수정
|
||||||
|
|
||||||
|
다음 장기 컨테이너에서 페이지 레벨 진입 애니메이션을 제거했다.
|
||||||
|
|
||||||
|
- `TenantWorksmobilePage` tab panels
|
||||||
|
- `GlobalOverviewPage` root container
|
||||||
|
- `DataIntegrityPage` tab panels
|
||||||
|
- `TenantDetailPage` nested outlet wrapper
|
||||||
|
|
||||||
|
Dialog, dropdown, toast처럼 짧게 열리고 닫히는 transient UI의 state-based animation은 유지한다.
|
||||||
|
|
||||||
|
## 회귀 방지
|
||||||
|
|
||||||
|
- Worksmobile tab panel에 trace 원인 클래스가 다시 들어오지 않도록 테스트를 추가했다.
|
||||||
|
- adminfront 전체 `.tsx`에서 `animate-in fade-in duration-500` 페이지 레벨 패턴이 재도입되지 않도록 정책 테스트를 추가했다.
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --dir adminfront exec vitest run src/features/coverage/adminPageAnimationPolicy.test.ts --bail 1
|
||||||
|
pnpm --dir adminfront exec vitest run src/features/tenants/routes/TenantWorksmobilePage.test.ts --bail 1
|
||||||
|
pnpm --dir adminfront exec vitest run src/features/overview/GlobalOverviewPage.test.tsx --bail 1
|
||||||
|
pnpm --dir adminfront exec vitest run src/features/integrity/DataIntegrityPage.test.tsx --bail 1
|
||||||
|
pnpm --dir adminfront exec vitest run src/features/tenants/routes/TenantDetailPage.worksmobile.test.tsx --bail 1
|
||||||
|
```
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
테스트:
|
테스트:
|
||||||
|
|
||||||
- `GOCACHE=/tmp/baron-sso-go-cache go test ./internal/service -run 'TestWorksmobileSyncServiceSkipsSoftDeletedUsersInComparison' -count=1`
|
- `GOCACHE=/tmp/baron-sso-go-cache go test ./internal/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`
|
- `BASE_URL=http://127.0.0.1:5173 npm --prefix adminfront test -- worksmobile.spec.ts --project=chromium`
|
||||||
|
|
||||||
결과:
|
결과:
|
||||||
|
|||||||
@@ -170,12 +170,35 @@ Baron Kratos identity를 Worksmobile user로 보냅니다.
|
|||||||
- `passwordConfig.password`: 구성원 생성 시 숫자, 영문, 기호를 모두 포함한 16자리 난수 초기 비밀번호를 생성합니다.
|
- `passwordConfig.password`: 구성원 생성 시 숫자, 영문, 기호를 모두 포함한 16자리 난수 초기 비밀번호를 생성합니다.
|
||||||
- `task`: Baron `jobTitle`을 우선 사용
|
- `task`: Baron `jobTitle`을 우선 사용
|
||||||
- `organizations`
|
- `organizations`
|
||||||
- 원직: 대표 tenant 또는 `additionalAppointments` 중 primary로 선택된 tenant
|
- 원직: Worksmobile 연동 제외 테넌트를 제외한 뒤 `additionalAppointments`에 가장 먼저 등록된 tenant
|
||||||
- 겸직: `metadata.additionalAppointments` 또는 Keto `joinedTenants`
|
- 겸직: `metadata.additionalAppointments` 또는 Keto `joinedTenants` 중 Worksmobile 연동 제외가 아닌 tenant
|
||||||
- `orgUnits[].orgUnitId`: `externalKey:{tenant.ID}`
|
- `orgUnits[].orgUnitId`: `externalKey:{tenant.ID}`
|
||||||
- `levelId`, `positionId`, `userTypeId`: 이번 scope에서는 External Key mapping을 사용하지 않고 사용자 정보 업데이트 필드로 최대한 커버
|
- `levelId`, `positionId`, `userTypeId`: 이번 scope에서는 External Key mapping을 사용하지 않고 사용자 정보 업데이트 필드로 최대한 커버
|
||||||
- `isManager`: `additionalAppointments[].isOwner == true` 또는 Keto owners/admins relation을 기준으로 변환
|
- `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 권한으로 제한하고 보존 기간 정책을 별도 확정해야 합니다.
|
초기 비밀번호는 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가 누락되지 않게 합니다.
|
현재 backend `CreateUser`와 `UpdateUser`는 adminfront가 보내는 top-level `additionalAppointments` 및 `metadata.additionalAppointments`를 수용합니다. 한맥가족 단건 생성에서 대표 `tenantSlug` 없이 appointment만 오는 경우에는 first/primary appointment tenant를 대표 tenant로 해석해 Ory/Keto 관계, 허용된 Backend read model, Worksmobile enqueue가 누락되지 않게 합니다.
|
||||||
|
|||||||
35
docs/worksmobile-phone-outbound-policy-2026-06-15.md
Normal file
35
docs/worksmobile-phone-outbound-policy-2026-06-15.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# WORKS 전화번호 송신 포맷 정책
|
||||||
|
|
||||||
|
작성일: 2026-06-15
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
Baron 내부 전화번호 표준과 WORKS API 송신 포맷을 분리한다.
|
||||||
|
|
||||||
|
## 정책
|
||||||
|
|
||||||
|
- Baron 내부 저장 및 비교 표준은 공백 없는 E.164 형태를 유지한다.
|
||||||
|
- 예: `+821041585840`
|
||||||
|
- WORKS 계정 생성 및 업데이트 요청으로 전화번호를 보낼 때는 국가번호와 국내 번호 사이에 공백을 둔다.
|
||||||
|
- 예: `+82 01041585840`
|
||||||
|
- WORKS 송신용 국내 번호는 `0`으로 시작해야 한다.
|
||||||
|
- 내부값 `+821041585840`은 WORKS 송신 시 `+82 01041585840`으로 변환한다.
|
||||||
|
- WORKS 응답 비교는 기존처럼 공백 포함/미포함 형식을 같은 전화번호로 정규화해서 비교한다.
|
||||||
|
|
||||||
|
## 적용 범위
|
||||||
|
|
||||||
|
- WORKS Directory 사용자 생성 요청
|
||||||
|
- WORKS Directory 사용자 업데이트 요청
|
||||||
|
- WORKS SCIM 사용자 생성 요청
|
||||||
|
|
||||||
|
## 비적용 범위
|
||||||
|
|
||||||
|
- Baron DB 저장값
|
||||||
|
- Kratos traits 저장값
|
||||||
|
- Adminfront 비교 화면의 내부 기준 표시값
|
||||||
|
|
||||||
|
## 구현 기준
|
||||||
|
|
||||||
|
- 내부 payload 생성 단계에서는 기존 E.164 정규화 값을 유지한다.
|
||||||
|
- 실제 WORKS API 송신 직전 outbound formatter에서만 `+82 0...` 형식으로 변환한다.
|
||||||
|
- 한국 번호가 아닌 값은 기존 정규화 결과를 유지한다.
|
||||||
@@ -303,6 +303,7 @@ schema_incompatible = "Fields not in target schema may be lost:"
|
|||||||
schema_missing = "Missing required fields for target tenant:"
|
schema_missing = "Missing required fields for target tenant:"
|
||||||
status_placeholder = "Select status"
|
status_placeholder = "Select status"
|
||||||
permission_placeholder = "Select permission"
|
permission_placeholder = "Select permission"
|
||||||
|
update_partial_error = "Failed to update {{count}} users."
|
||||||
update_success = "User info updated successfully."
|
update_success = "User info updated successfully."
|
||||||
|
|
||||||
[msg.admin.users.create]
|
[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]
|
[msg.admin.integrity.check.orphan_user_tenant_memberships]
|
||||||
description = "Checks whether users.tenant_id points to a missing or soft-deleted tenant."
|
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]
|
[ui.admin.integrity]
|
||||||
tab_checks = "Integrity Checks"
|
tab_checks = "Integrity Checks"
|
||||||
tab_user_projection = "User Projection"
|
|
||||||
fetch_error = "Unable to load the final integrity check result."
|
fetch_error = "Unable to load the final integrity check result."
|
||||||
kicker = "System"
|
kicker = "System"
|
||||||
loading = "Loading data integrity report..."
|
loading = "Loading data integrity report..."
|
||||||
@@ -2934,33 +2923,6 @@ rotate_secret = "Rotate secret"
|
|||||||
rotate_secret_done = "Secret rotated"
|
rotate_secret_done = "Secret rotated"
|
||||||
save_scopes = "Save scopes"
|
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]
|
[ui.admin.auth_guard]
|
||||||
subtitle = "Verify admin privileges and ReBAC relationships against the policy engine."
|
subtitle = "Verify admin privileges and ReBAC relationships against the policy engine."
|
||||||
title = "Auth Guard"
|
title = "Auth Guard"
|
||||||
|
|||||||
@@ -851,6 +851,7 @@ schema_incompatible = "대상 테넌트 스키마에 없는 필드는 유실될
|
|||||||
schema_missing = "대상 테넌트의 필수 필드가 누락되어 있습니다:"
|
schema_missing = "대상 테넌트의 필수 필드가 누락되어 있습니다:"
|
||||||
status_placeholder = "상태 선택"
|
status_placeholder = "상태 선택"
|
||||||
permission_placeholder = "권한 선택"
|
permission_placeholder = "권한 선택"
|
||||||
|
update_partial_error = "{{count}}명의 사용자 정보 수정에 실패했습니다."
|
||||||
update_success = "사용자 정보가 일괄 업데이트되었습니다."
|
update_success = "사용자 정보가 일괄 업데이트되었습니다."
|
||||||
|
|
||||||
[msg.admin.users.create]
|
[msg.admin.users.create]
|
||||||
@@ -3284,23 +3285,11 @@ description = "사용자와 로그인 ID 참조의 고아 레코드를 확인합
|
|||||||
[msg.admin.integrity]
|
[msg.admin.integrity]
|
||||||
subtitle = "정합성 상태를 확인하고 데이터 모델 전반의 검증 결과를 살펴봅니다."
|
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]
|
[msg.admin.integrity]
|
||||||
subtitle = "정합성 상태를 확인하고 데이터 모델 전반의 검증 결과를 살펴봅니다."
|
subtitle = "정합성 상태를 확인하고 데이터 모델 전반의 검증 결과를 살펴봅니다."
|
||||||
|
|
||||||
[ui.admin.integrity]
|
[ui.admin.integrity]
|
||||||
tab_checks = "정합성 검사"
|
tab_checks = "정합성 검사"
|
||||||
tab_user_projection = "사용자 동기화"
|
|
||||||
fetch_error = "정합성 최종 검증 결과를 불러오지 못했습니다."
|
fetch_error = "정합성 최종 검증 결과를 불러오지 못했습니다."
|
||||||
kicker = "시스템"
|
kicker = "시스템"
|
||||||
loading = "불러오는 중"
|
loading = "불러오는 중"
|
||||||
@@ -3382,32 +3371,6 @@ rotate_secret = "Secret 재발급"
|
|||||||
rotate_secret_done = "Secret 재발급 완료"
|
rotate_secret_done = "Secret 재발급 완료"
|
||||||
save_scopes = "권한 저장"
|
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]
|
[ui.admin.auth_guard]
|
||||||
subtitle = "관리자 권한과 ReBAC 관계를 실제 정책 엔진 기준으로 확인합니다."
|
subtitle = "관리자 권한과 ReBAC 관계를 실제 정책 엔진 기준으로 확인합니다."
|
||||||
title = "인증 가드"
|
title = "인증 가드"
|
||||||
|
|||||||
@@ -687,6 +687,7 @@ schema_incompatible = ""
|
|||||||
schema_missing = ""
|
schema_missing = ""
|
||||||
status_placeholder = ""
|
status_placeholder = ""
|
||||||
permission_placeholder = ""
|
permission_placeholder = ""
|
||||||
|
update_partial_error = ""
|
||||||
update_success = ""
|
update_success = ""
|
||||||
|
|
||||||
[msg.admin.users.create]
|
[msg.admin.users.create]
|
||||||
@@ -3134,7 +3135,6 @@ description = ""
|
|||||||
|
|
||||||
[ui.admin.integrity]
|
[ui.admin.integrity]
|
||||||
tab_checks = ""
|
tab_checks = ""
|
||||||
tab_user_projection = ""
|
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
|
|
||||||
[ui.admin.tenants.profile]
|
[ui.admin.tenants.profile]
|
||||||
@@ -3156,7 +3156,6 @@ kicker = ""
|
|||||||
loading = ""
|
loading = ""
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
tab_checks = ""
|
tab_checks = ""
|
||||||
tab_user_projection = ""
|
|
||||||
title = ""
|
title = ""
|
||||||
|
|
||||||
[ui.admin.tenants.profile]
|
[ui.admin.tenants.profile]
|
||||||
@@ -3168,17 +3167,6 @@ worksmobile_sync = ""
|
|||||||
[msg.admin.integrity.section.user_integrity]
|
[msg.admin.integrity.section.user_integrity]
|
||||||
description = ""
|
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]
|
[ui.admin.integrity]
|
||||||
fetch_error = ""
|
fetch_error = ""
|
||||||
kicker = ""
|
kicker = ""
|
||||||
@@ -3260,32 +3248,6 @@ rotate_secret = ""
|
|||||||
rotate_secret_done = ""
|
rotate_secret_done = ""
|
||||||
save_scopes = ""
|
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]
|
[ui.admin.auth_guard]
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
title = ""
|
title = ""
|
||||||
|
|||||||
@@ -618,6 +618,120 @@ describe("org chart layout", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("maps appointment memberships by tenant UUID when the stored slug is stale", () => {
|
||||||
|
const root = tenantNode("gpdtdc", "COMPANY", "GPDTDC", "gpdtdc");
|
||||||
|
const division = {
|
||||||
|
...tenantNode("division", "ORGANIZATION", "총괄기획실", "gpd"),
|
||||||
|
parentId: "gpdtdc",
|
||||||
|
};
|
||||||
|
const migratedLeaf = {
|
||||||
|
...tenantNode(
|
||||||
|
"migrated-leaf",
|
||||||
|
"ORGANIZATION",
|
||||||
|
"통합시스템",
|
||||||
|
"intigrated-system",
|
||||||
|
),
|
||||||
|
parentId: "division",
|
||||||
|
};
|
||||||
|
const rootNode = {
|
||||||
|
...root,
|
||||||
|
children: [{ ...division, children: [migratedLeaf] }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const usersMap = buildUsersMap(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
...member("migrated-user"),
|
||||||
|
companyCode: undefined,
|
||||||
|
tenantSlug: "gpdtdc",
|
||||||
|
metadata: {
|
||||||
|
additionalAppointments: [
|
||||||
|
{
|
||||||
|
tenantId: "migrated-leaf",
|
||||||
|
tenantName: "기술기획",
|
||||||
|
tenantSlug: "tech-planning",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
joinedTenants: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[rootNode],
|
||||||
|
{ activeOnly: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(usersMap.get("gpdtdc")).toBeUndefined();
|
||||||
|
expect(usersMap.get("tech-planning")).toBeUndefined();
|
||||||
|
expect(usersMap.get("intigrated-system")?.map((user) => user.id)).toEqual([
|
||||||
|
"migrated-user",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps primary and joined memberships by tenant UUID when stored slugs are stale", () => {
|
||||||
|
const root = tenantNode("root-company", "COMPANY", "Root", "root-company");
|
||||||
|
const primaryLeaf = {
|
||||||
|
...tenantNode("primary-leaf", "ORGANIZATION", "Primary", "primary-new"),
|
||||||
|
parentId: "root-company",
|
||||||
|
};
|
||||||
|
const joinedLeaf = {
|
||||||
|
...tenantNode("joined-leaf", "ORGANIZATION", "Joined", "joined-new"),
|
||||||
|
parentId: "root-company",
|
||||||
|
};
|
||||||
|
const rootNode = {
|
||||||
|
...root,
|
||||||
|
children: [primaryLeaf, joinedLeaf],
|
||||||
|
};
|
||||||
|
|
||||||
|
const usersMap = buildUsersMap(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
...member("primary-user"),
|
||||||
|
companyCode: undefined,
|
||||||
|
tenantSlug: undefined,
|
||||||
|
tenant: {
|
||||||
|
id: "primary-leaf",
|
||||||
|
type: "ORGANIZATION",
|
||||||
|
name: "Primary",
|
||||||
|
slug: "primary-old",
|
||||||
|
description: "",
|
||||||
|
status: "active",
|
||||||
|
createdAt: "2026-05-11T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-11T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
joinedTenants: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...member("joined-user"),
|
||||||
|
companyCode: undefined,
|
||||||
|
tenantSlug: undefined,
|
||||||
|
joinedTenants: [
|
||||||
|
{
|
||||||
|
id: "joined-leaf",
|
||||||
|
type: "ORGANIZATION",
|
||||||
|
name: "Joined",
|
||||||
|
slug: "joined-old",
|
||||||
|
description: "",
|
||||||
|
status: "active",
|
||||||
|
createdAt: "2026-05-11T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-11T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[rootNode],
|
||||||
|
{ activeOnly: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(usersMap.get("primary-new")?.map((user) => user.id)).toEqual([
|
||||||
|
"primary-user",
|
||||||
|
]);
|
||||||
|
expect(usersMap.get("joined-new")?.map((user) => user.id)).toEqual([
|
||||||
|
"joined-user",
|
||||||
|
]);
|
||||||
|
expect(usersMap.get("primary-old")).toBeUndefined();
|
||||||
|
expect(usersMap.get("joined-old")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("does not fall back to a visible parent for hidden leaf memberships", () => {
|
it("does not fall back to a visible parent for hidden leaf memberships", () => {
|
||||||
const gpdtdc = tenantNode("gpdtdc", "COMPANY", "GPDTDC", "gpdtdc");
|
const gpdtdc = tenantNode("gpdtdc", "COMPANY", "GPDTDC", "gpdtdc");
|
||||||
const internalLeaf = {
|
const internalLeaf = {
|
||||||
|
|||||||
@@ -1270,14 +1270,27 @@ function getUserOrgAppointmentRefs(user: UserSummary): UserOrgAppointmentRef[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addTenantSlugCandidate(
|
function addTenantSlugCandidate(
|
||||||
slugs: Set<string>,
|
tenantIds: Set<string>,
|
||||||
tenantIndexes: TenantIndexes,
|
tenantIndexes: TenantIndexes,
|
||||||
slug: string,
|
slug: string,
|
||||||
) {
|
) {
|
||||||
const normalizedSlug = normalizeOrgSlug(slug);
|
const normalizedSlug = normalizeOrgSlug(slug);
|
||||||
if (!normalizedSlug) return;
|
if (!normalizedSlug) return;
|
||||||
if (!tenantIndexes.bySlug.has(normalizedSlug)) return;
|
const tenant = tenantIndexes.bySlug.get(normalizedSlug);
|
||||||
slugs.add(normalizedSlug);
|
if (!tenant) return;
|
||||||
|
tenantIds.add(tenant.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTenantIdCandidate(
|
||||||
|
tenantIds: Set<string>,
|
||||||
|
tenantIndexes: TenantIndexes,
|
||||||
|
id: unknown,
|
||||||
|
) {
|
||||||
|
if (typeof id !== "string") return;
|
||||||
|
const normalizedId = id.trim();
|
||||||
|
if (!normalizedId) return;
|
||||||
|
if (!tenantIndexes.byId.has(normalizedId)) return;
|
||||||
|
tenantIds.add(normalizedId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDescendantTenant(
|
function isDescendantTenant(
|
||||||
@@ -1298,19 +1311,19 @@ function isDescendantTenant(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLeafMembershipSlugs(
|
function getLeafMembershipIds(
|
||||||
slugs: Set<string>,
|
tenantIds: Set<string>,
|
||||||
tenantIndexes: TenantIndexes,
|
tenantIndexes: TenantIndexes,
|
||||||
) {
|
) {
|
||||||
const memberships = Array.from(slugs);
|
const memberships = Array.from(tenantIds);
|
||||||
|
|
||||||
return memberships.filter((slug) => {
|
return memberships.filter((id) => {
|
||||||
const tenant = tenantIndexes.bySlug.get(slug);
|
const tenant = tenantIndexes.byId.get(id);
|
||||||
if (!tenant) return true;
|
if (!tenant) return true;
|
||||||
|
|
||||||
return !memberships.some((otherSlug) => {
|
return !memberships.some((otherId) => {
|
||||||
if (otherSlug === slug) return false;
|
if (otherId === id) return false;
|
||||||
const otherTenant = tenantIndexes.bySlug.get(otherSlug);
|
const otherTenant = tenantIndexes.byId.get(otherId);
|
||||||
if (!otherTenant) return false;
|
if (!otherTenant) return false;
|
||||||
return isDescendantTenant(otherTenant, tenant, tenantIndexes.byId);
|
return isDescendantTenant(otherTenant, tenant, tenantIndexes.byId);
|
||||||
});
|
});
|
||||||
@@ -1332,7 +1345,7 @@ export function buildUsersMap(
|
|||||||
if (options.activeOnly && user.status !== "active") continue;
|
if (options.activeOnly && user.status !== "active") continue;
|
||||||
if (!isVisibleOrgChartUser(user)) continue;
|
if (!isVisibleOrgChartUser(user)) continue;
|
||||||
|
|
||||||
const slugs = new Set<string>();
|
const tenantIds = new Set<string>();
|
||||||
const primarySlug = normalizeOrgSlug(user.tenantSlug);
|
const primarySlug = normalizeOrgSlug(user.tenantSlug);
|
||||||
const legacyCompanySlug = normalizeOrgSlug(user.companyCode);
|
const legacyCompanySlug = normalizeOrgSlug(user.companyCode);
|
||||||
if (
|
if (
|
||||||
@@ -1344,7 +1357,7 @@ export function buildUsersMap(
|
|||||||
name: primarySlug,
|
name: primarySlug,
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
addTenantSlugCandidate(slugs, membershipTenantIndexes, primarySlug);
|
addTenantSlugCandidate(tenantIds, membershipTenantIndexes, primarySlug);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
legacyCompanySlug &&
|
legacyCompanySlug &&
|
||||||
@@ -1355,24 +1368,51 @@ export function buildUsersMap(
|
|||||||
name: legacyCompanySlug,
|
name: legacyCompanySlug,
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
addTenantSlugCandidate(slugs, membershipTenantIndexes, legacyCompanySlug);
|
addTenantSlugCandidate(
|
||||||
|
tenantIds,
|
||||||
|
membershipTenantIndexes,
|
||||||
|
legacyCompanySlug,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (user.tenant?.slug && !isSystemGlobalTenant(user.tenant)) {
|
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 || []) {
|
for (const joinedTenant of user.joinedTenants || []) {
|
||||||
if (joinedTenant.slug && !isSystemGlobalTenant(joinedTenant)) {
|
if (joinedTenant.slug && !isSystemGlobalTenant(joinedTenant)) {
|
||||||
|
addTenantIdCandidate(
|
||||||
|
tenantIds,
|
||||||
|
membershipTenantIndexes,
|
||||||
|
joinedTenant.id,
|
||||||
|
);
|
||||||
addTenantSlugCandidate(
|
addTenantSlugCandidate(
|
||||||
slugs,
|
tenantIds,
|
||||||
membershipTenantIndexes,
|
membershipTenantIndexes,
|
||||||
joinedTenant.slug,
|
joinedTenant.slug,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const appointment of getUserOrgAppointmentRefs(user)) {
|
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) {
|
if (appointment.tenantSlug) {
|
||||||
addTenantSlugCandidate(
|
addTenantSlugCandidate(
|
||||||
slugs,
|
tenantIds,
|
||||||
membershipTenantIndexes,
|
membershipTenantIndexes,
|
||||||
appointment.tenantSlug,
|
appointment.tenantSlug,
|
||||||
);
|
);
|
||||||
@@ -1383,12 +1423,14 @@ export function buildUsersMap(
|
|||||||
? membershipTenantIndexes.byId.get(appointment.tenantId)
|
? membershipTenantIndexes.byId.get(appointment.tenantId)
|
||||||
: undefined;
|
: undefined;
|
||||||
if (tenantById) {
|
if (tenantById) {
|
||||||
addTenantSlugCandidate(slugs, membershipTenantIndexes, tenantById.slug);
|
addTenantIdCandidate(tenantIds, membershipTenantIndexes, tenantById.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const slug of getLeafMembershipSlugs(slugs, membershipTenantIndexes)) {
|
for (const id of getLeafMembershipIds(tenantIds, membershipTenantIndexes)) {
|
||||||
if (!visibleTenantIndexes.bySlug.has(slug)) continue;
|
const visibleTenant = visibleTenantIndexes.byId.get(id);
|
||||||
|
if (!visibleTenant) continue;
|
||||||
|
const slug = visibleTenant.slug.toLowerCase();
|
||||||
const list = map.get(slug) || [];
|
const list = map.get(slug) || [];
|
||||||
if (!list.some((existing) => existing.id === user.id)) list.push(user);
|
if (!list.some((existing) => existing.id === user.id)) list.push(user);
|
||||||
map.set(slug, list);
|
map.set(slug, list);
|
||||||
@@ -1444,6 +1486,9 @@ export function TenantOrgChartPage() {
|
|||||||
queryKey: ["orgchart-snapshot", { cache: "redis" }],
|
queryKey: ["orgchart-snapshot", { cache: "redis" }],
|
||||||
queryFn: () => fetchOrgChartSnapshot(),
|
queryFn: () => fetchOrgChartSnapshot(),
|
||||||
enabled: !shareToken,
|
enabled: !shareToken,
|
||||||
|
staleTime: 0,
|
||||||
|
refetchOnMount: "always",
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { rootNodes, usersMap, sharedWith } = React.useMemo(() => {
|
const { rootNodes, usersMap, sharedWith } = React.useMemo(() => {
|
||||||
|
|||||||
@@ -194,6 +194,24 @@ describe("OrgPickerEmbedPage orgchart data source", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forces authenticated picker snapshots to refetch instead of trusting the in-memory query cache", async () => {
|
||||||
|
adminApiMocks.fetchOrgChartSnapshot.mockResolvedValue(snapshot);
|
||||||
|
|
||||||
|
const rendered = renderPicker("/embed/picker?select=both");
|
||||||
|
|
||||||
|
await waitForExpect(() => {
|
||||||
|
expect(adminApiMocks.fetchOrgChartSnapshot).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const query = rendered.queryClient.getQueryCache().find({
|
||||||
|
queryKey: ["org-picker-orgchart", "authenticated", undefined],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(query?.options.staleTime).toBe(0);
|
||||||
|
expect(query?.options.refetchOnMount).toBe("always");
|
||||||
|
expect(query?.options.refetchOnWindowFocus).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("uses the public orgchart snapshot when a share token is present", async () => {
|
it("uses the public orgchart snapshot when a share token is present", async () => {
|
||||||
adminApiMocks.fetchOrgChartSnapshot.mockResolvedValue(snapshot);
|
adminApiMocks.fetchOrgChartSnapshot.mockResolvedValue(snapshot);
|
||||||
adminApiMocks.fetchPublicOrgChart.mockResolvedValue(snapshot);
|
adminApiMocks.fetchPublicOrgChart.mockResolvedValue(snapshot);
|
||||||
|
|||||||
@@ -384,6 +384,9 @@ export function OrgPickerEmbedPage() {
|
|||||||
],
|
],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
shareToken ? fetchPublicOrgChart(shareToken) : fetchOrgChartSnapshot(),
|
shareToken ? fetchPublicOrgChart(shareToken) : fetchOrgChartSnapshot(),
|
||||||
|
staleTime: 0,
|
||||||
|
refetchOnMount: "always",
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ do
|
|||||||
assert_contains "$workflow" 'WORKS_ADMIN_API_BASE_URL=${{ vars.WORKS_ADMIN_API_BASE_URL }}'
|
assert_contains "$workflow" 'WORKS_ADMIN_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" 'WORKS_ADMIN_OAUTH_TOKEN_URL=${{ vars.WORKS_ADMIN_OAUTH_TOKEN_URL }}'
|
||||||
assert_contains "$workflow" 'BACKEND_PUBLIC_URL=${{ vars.BACKEND_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
|
done
|
||||||
|
|
||||||
assert_contains ".gitea/workflows/staging_release.yml" "scp adminfront/seed-tenant.csv"
|
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" "./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_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/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"
|
echo "staging workflow env checks passed"
|
||||||
|
|||||||
@@ -34,13 +34,53 @@ class AuthProxyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static Exception _error(String key, String fallback, {String? detail}) {
|
static Exception _error(String key, String fallback, {String? detail}) {
|
||||||
return Exception(
|
final params = detail != null ? {'error': detail} : null;
|
||||||
tr(
|
var message = tr(key, fallback: fallback, params: params);
|
||||||
key,
|
if (message == key && fallback.isNotEmpty) {
|
||||||
fallback: fallback,
|
message = _interpolateFallback(fallback, params);
|
||||||
params: detail != null ? {'error': detail} : null,
|
}
|
||||||
),
|
return Exception(message);
|
||||||
);
|
}
|
||||||
|
|
||||||
|
static String _interpolateFallback(
|
||||||
|
String fallback,
|
||||||
|
Map<String, String>? params,
|
||||||
|
) {
|
||||||
|
var message = fallback;
|
||||||
|
params?.forEach((key, value) {
|
||||||
|
message = message.replaceAll('{{$key}}', value);
|
||||||
|
});
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _responseErrorDetail(http.Response response) {
|
||||||
|
final body = response.body.trim();
|
||||||
|
if (body.isEmpty) {
|
||||||
|
return 'HTTP ${response.statusCode}';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(body);
|
||||||
|
if (decoded is Map<String, dynamic>) {
|
||||||
|
final value = decoded['error'] ?? decoded['message'];
|
||||||
|
if (value is String && value.trim().isNotEmpty) {
|
||||||
|
return _formatUserPolicyError(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Fall back to the raw response body.
|
||||||
|
}
|
||||||
|
return _formatUserPolicyError(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _formatUserPolicyError(String message) {
|
||||||
|
final normalized = message.toLowerCase();
|
||||||
|
if (normalized.contains(
|
||||||
|
'internal email domain cannot be assigned to personal tenant',
|
||||||
|
) ||
|
||||||
|
normalized.contains('내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다')) {
|
||||||
|
return '내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다. 대표소속을 회사 또는 조직 소속으로 지정해 주세요.';
|
||||||
|
}
|
||||||
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
static http.Client _createClient({bool withCredentials = false}) {
|
static http.Client _createClient({bool withCredentials = false}) {
|
||||||
@@ -735,8 +775,8 @@ class AuthProxyService {
|
|||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
throw _error(
|
throw _error(
|
||||||
'err.userfront.auth_proxy.user_create',
|
'err.userfront.auth_proxy.user_create',
|
||||||
'Failed to create the user: {{error}}',
|
'사용자 생성 실패: {{error}}',
|
||||||
detail: response.body,
|
detail: _responseErrorDetail(response),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -844,8 +884,8 @@ class AuthProxyService {
|
|||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
throw _error(
|
throw _error(
|
||||||
'err.userfront.auth_proxy.user_update',
|
'err.userfront.auth_proxy.user_update',
|
||||||
'Failed to update the user: {{error}}',
|
'사용자 수정 실패: {{error}}',
|
||||||
detail: response.body,
|
detail: _responseErrorDetail(response),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,18 +242,6 @@ const Map<String, String> koStrings = {
|
|||||||
"msg.admin.tenants.sub.empty": "하위 테넌트가 없습니다.",
|
"msg.admin.tenants.sub.empty": "하위 테넌트가 없습니다.",
|
||||||
"msg.admin.tenants.sub.subtitle": "현재 테넌트 하위에 생성된 조직입니다.",
|
"msg.admin.tenants.sub.subtitle": "현재 테넌트 하위에 생성된 조직입니다.",
|
||||||
"msg.admin.tenants.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_confirm": "선택한 {{count}}명의 사용자를 정말로 삭제하시겠습니까?",
|
||||||
"msg.admin.users.bulk.delete_success": "{{count}}명의 사용자가 삭제되었습니다.",
|
"msg.admin.users.bulk.delete_success": "{{count}}명의 사용자가 삭제되었습니다.",
|
||||||
"msg.admin.users.bulk.description": "CSV 파일을 통해 사용자를 일괄 등록하거나 관리합니다.",
|
"msg.admin.users.bulk.description": "CSV 파일을 통해 사용자를 일괄 등록하거나 관리합니다.",
|
||||||
@@ -1229,21 +1217,6 @@ const Map<String, String> koStrings = {
|
|||||||
"ui.admin.tenants.worksmobile.sync_user": "구성원 Sync",
|
"ui.admin.tenants.worksmobile.sync_user": "구성원 Sync",
|
||||||
"ui.admin.tenants.worksmobile.title": "Worksmobile 연동",
|
"ui.admin.tenants.worksmobile.title": "Worksmobile 연동",
|
||||||
"ui.admin.title": "Admin Control",
|
"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.acknowledge_warning": "경고를 확인했으며 계속 진행합니다.",
|
||||||
"ui.admin.users.bulk.create_missing_tenant": "신규 생성",
|
"ui.admin.users.bulk.create_missing_tenant": "신규 생성",
|
||||||
"ui.admin.users.bulk.do_move": "이동 실행",
|
"ui.admin.users.bulk.do_move": "이동 실행",
|
||||||
@@ -2583,18 +2556,6 @@ const Map<String, String> enStrings = {
|
|||||||
"Review and manage child tenants linked under this tenant.",
|
"Review and manage child tenants linked under this tenant.",
|
||||||
"msg.admin.tenants.subtitle":
|
"msg.admin.tenants.subtitle":
|
||||||
"Review registered tenants and manage their current status.",
|
"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":
|
"msg.admin.users.bulk.delete_confirm":
|
||||||
"Are you sure you want to delete the selected {{count}} users?",
|
"Are you sure you want to delete the selected {{count}} users?",
|
||||||
"msg.admin.users.bulk.delete_success": "{{count}} users have been deleted.",
|
"msg.admin.users.bulk.delete_success": "{{count}} users have been deleted.",
|
||||||
@@ -3718,23 +3679,6 @@ const Map<String, String> enStrings = {
|
|||||||
"ui.admin.tenants.worksmobile.sync_user": "User Sync",
|
"ui.admin.tenants.worksmobile.sync_user": "User Sync",
|
||||||
"ui.admin.tenants.worksmobile.title": "Worksmobile Integration",
|
"ui.admin.tenants.worksmobile.title": "Worksmobile Integration",
|
||||||
"ui.admin.title": "Admin Control",
|
"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":
|
"ui.admin.users.bulk.acknowledge_warning":
|
||||||
"I acknowledge the warning and will proceed.",
|
"I acknowledge the warning and will proceed.",
|
||||||
"ui.admin.users.bulk.create_missing_tenant": "Create new",
|
"ui.admin.users.bulk.create_missing_tenant": "Create new",
|
||||||
|
|||||||
@@ -203,6 +203,49 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test('createUser는 내부 도메인 personal 정책 오류를 한국어 안내로 표시한다', () async {
|
||||||
|
client.enqueueJson({
|
||||||
|
'error':
|
||||||
|
'internal email domain cannot be assigned to personal tenant: user@hanmaceng.co.kr',
|
||||||
|
}, statusCode: 400);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
AuthProxyService.createUser(
|
||||||
|
loginId: 'user@hanmaceng.co.kr',
|
||||||
|
adminPassword: 'admin-pass',
|
||||||
|
email: 'user@hanmaceng.co.kr',
|
||||||
|
),
|
||||||
|
throwsA(
|
||||||
|
isA<Exception>().having(
|
||||||
|
(error) => error.toString(),
|
||||||
|
'message',
|
||||||
|
contains('대표소속을 회사 또는 조직 소속으로 지정해 주세요'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateUserDetails는 내부 도메인 personal 정책 오류를 한국어 안내로 표시한다', () async {
|
||||||
|
client.enqueueJson({
|
||||||
|
'error': '내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다: user@brsw.kr',
|
||||||
|
}, statusCode: 400);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
AuthProxyService.updateUserDetails(
|
||||||
|
adminPassword: 'admin-pass',
|
||||||
|
loginId: 'user@brsw.kr',
|
||||||
|
email: 'user@brsw.kr',
|
||||||
|
),
|
||||||
|
throwsA(
|
||||||
|
isA<Exception>().having(
|
||||||
|
(error) => error.toString(),
|
||||||
|
'message',
|
||||||
|
contains('대표소속을 회사 또는 조직 소속으로 지정해 주세요'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('sendLog는 민감 정보를 제거한 client log를 전송한다', () async {
|
test('sendLog는 민감 정보를 제거한 client log를 전송한다', () async {
|
||||||
client.enqueueJson({'ok': true});
|
client.enqueueJson({'ok': true});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user