forked from baron/baron-sso
feat: update worksmobile sync and restore planning
This commit is contained in:
@@ -106,7 +106,7 @@ ensure_frontend_dependencies() {
|
||||
return 0
|
||||
fi
|
||||
if [ "$WORKSPACE_DIR" = "/workspace/common" ]; then
|
||||
(cd /workspace/common && CI=true pnpm install --filter "${APP_WORKSPACE_FILTER}..." --no-frozen-lockfile --ignore-scripts)
|
||||
(cd /workspace/common && CI=true pnpm install --filter "${APP_WORKSPACE_FILTER}..." --frozen-lockfile --ignore-scripts)
|
||||
else
|
||||
npm ci
|
||||
fi
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import * as adminApi from "../../lib/adminApi";
|
||||
import { TenantWorksmobilePage } from "../tenants/routes/TenantWorksmobilePage";
|
||||
import TenantListPage from "../tenants/routes/TenantListPage";
|
||||
import UserCreatePage from "../users/UserCreatePage";
|
||||
@@ -198,7 +205,17 @@ vi.mock("../../lib/adminApi", () => ({
|
||||
baronId: "user-2",
|
||||
baronName: "New User",
|
||||
baronEmail: "new@example.com",
|
||||
status: "baron_only",
|
||||
worksmobileJobStatus: "failed",
|
||||
worksmobileJobRetryCount: 2,
|
||||
worksmobileLastError: "worksmobile api failed",
|
||||
status: "missing_in_worksmobile",
|
||||
},
|
||||
{
|
||||
resourceType: "USER",
|
||||
baronId: "user-3",
|
||||
baronName: "Next User",
|
||||
baronEmail: "next@example.com",
|
||||
status: "missing_in_worksmobile",
|
||||
},
|
||||
],
|
||||
groups: [
|
||||
@@ -213,12 +230,55 @@ vi.mock("../../lib/adminApi", () => ({
|
||||
},
|
||||
],
|
||||
})),
|
||||
fetchWorksmobileCredentialBatches: vi.fn(async () => [
|
||||
{
|
||||
batchId: "credential-batch-1",
|
||||
operation: "worksmobile_user_sync",
|
||||
userCount: 1,
|
||||
processedCount: 1,
|
||||
failedCount: 1,
|
||||
hasPasswords: true,
|
||||
failures: [
|
||||
{
|
||||
userId: "failed-user",
|
||||
email: "failed-user@samaneng.com",
|
||||
status: "failed",
|
||||
retryCount: 2,
|
||||
lastError: "worksmobile api failed",
|
||||
updatedAt: "2026-06-01T04:05:00Z",
|
||||
},
|
||||
],
|
||||
createdAt: "2026-06-01T04:00:00Z",
|
||||
updatedAt: "2026-06-01T04:00:00Z",
|
||||
},
|
||||
{
|
||||
batchId: "credential-batch-pending",
|
||||
operation: "worksmobile_user_sync",
|
||||
userCount: 2,
|
||||
pendingCount: 1,
|
||||
processingCount: 1,
|
||||
processedCount: 0,
|
||||
failedCount: 0,
|
||||
hasPasswords: true,
|
||||
createdAt: "2026-06-01T04:10:00Z",
|
||||
updatedAt: "2026-06-01T04:10:00Z",
|
||||
},
|
||||
]),
|
||||
enqueueWorksmobileBackfillDryRun: vi.fn(async () => ({ id: "job-dry" })),
|
||||
retryWorksmobileJob: vi.fn(async () => ({ id: "job-retry" })),
|
||||
downloadWorksmobileInitialPasswordsCSV: vi.fn(async () => new Blob(["id"])),
|
||||
downloadWorksmobileInitialPasswordsCSV: vi.fn(async () => ({
|
||||
blob: new Blob(["id"]),
|
||||
filename: "worksmobile_initial_passwords.csv",
|
||||
})),
|
||||
enqueueWorksmobileOrgUnitSync: vi.fn(async () => ({ id: "job-org" })),
|
||||
enqueueWorksmobileOrgUnitDelete: vi.fn(async () => ({ id: "job-delete" })),
|
||||
enqueueWorksmobileUserSync: vi.fn(async () => ({ id: "job-user" })),
|
||||
resetWorksmobileUserPassword: vi.fn(async () => ({ id: "job-reset" })),
|
||||
deleteWorksmobileCredentialBatchPasswords: vi.fn(async () => ({
|
||||
batchId: "credential-batch-1",
|
||||
userCount: 1,
|
||||
hasPasswords: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement, entry = "/") {
|
||||
@@ -292,6 +352,165 @@ describe("adminfront large page coverage smoke", () => {
|
||||
|
||||
expect(await screen.findByText("Worksmobile 연동")).toBeInTheDocument();
|
||||
expect(screen.getByText("Baron / Works 비교")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("최근 실패: worksmobile api failed"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Backfill Dry-run")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "초기 비밀번호 CSV" })).toBeNull();
|
||||
});
|
||||
|
||||
it("does not automatically download the selected Worksmobile user credential batch after create enqueue", async () => {
|
||||
vi.spyOn(window.URL, "createObjectURL").mockReturnValue("blob:test");
|
||||
vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {});
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId/worksmobile"
|
||||
element={<TenantWorksmobilePage />}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company/worksmobile",
|
||||
);
|
||||
|
||||
await screen.findByText("New User");
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "New User 선택" }));
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "선택 구성원 WORKS에 생성" }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenCalledWith(
|
||||
"tenant-company",
|
||||
"user-2",
|
||||
expect.any(String),
|
||||
),
|
||||
);
|
||||
const credentialBatchId = vi.mocked(
|
||||
adminApi.enqueueWorksmobileUserSync,
|
||||
).mock.calls[0][2];
|
||||
expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("continues selected Worksmobile user create enqueue after one row fails", async () => {
|
||||
vi.mocked(adminApi.enqueueWorksmobileUserSync)
|
||||
.mockRejectedValueOnce(new Error("sync failed"))
|
||||
.mockResolvedValueOnce({ id: "job-user-3" } as never);
|
||||
vi.spyOn(window.URL, "createObjectURL").mockReturnValue("blob:test");
|
||||
vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {});
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId/worksmobile"
|
||||
element={<TenantWorksmobilePage />}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company/worksmobile",
|
||||
);
|
||||
|
||||
await screen.findByText("New User");
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "New User 선택" }));
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Next User 선택" }));
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "선택 구성원 WORKS에 생성" }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenCalledTimes(2),
|
||||
);
|
||||
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"tenant-company",
|
||||
"user-2",
|
||||
expect.any(String),
|
||||
);
|
||||
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"tenant-company",
|
||||
"user-3",
|
||||
expect.any(String),
|
||||
);
|
||||
expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("downloads or deletes Worksmobile credential batches from history", async () => {
|
||||
vi.spyOn(window.URL, "createObjectURL").mockReturnValue("blob:test");
|
||||
vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {});
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId/worksmobile"
|
||||
element={<TenantWorksmobilePage />}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company/worksmobile",
|
||||
);
|
||||
|
||||
await screen.findByText("credential-batch-1");
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "credential-batch-pending 비밀번호 CSV 다운로드",
|
||||
}),
|
||||
).toBeDisabled();
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "credential-batch-1 비밀번호 CSV 다운로드",
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
adminApi.downloadWorksmobileInitialPasswordsCSV,
|
||||
).toHaveBeenCalledWith("tenant-company", "credential-batch-1"),
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "credential-batch-1 비밀번호 값 삭제",
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
adminApi.deleteWorksmobileCredentialBatchPasswords,
|
||||
).toHaveBeenCalledWith("tenant-company", "credential-batch-1"),
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "credential-batch-1 실패 사유 보기",
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByText("failed-user@samaneng.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("worksmobile api failed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("enqueues Worksmobile password reset as a credential batch", async () => {
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId/worksmobile"
|
||||
element={<TenantWorksmobilePage />}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company/worksmobile",
|
||||
);
|
||||
|
||||
await screen.findByText("Worksmobile 연동");
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "양쪽 다 있음" })[0]);
|
||||
await screen.findAllByText("Engineer User");
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Engineer User 비밀번호 재설정",
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(adminApi.resetWorksmobileUserPassword).toHaveBeenCalledWith(
|
||||
"tenant-company",
|
||||
"user-1",
|
||||
expect.any(String),
|
||||
),
|
||||
);
|
||||
expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
formatWorksmobilePersonName,
|
||||
formatWorksmobileUpdateDetails,
|
||||
getDefaultGroupComparisonFilters,
|
||||
getDefaultUserComparisonFilters,
|
||||
getDefaultWorksmobileComparisonColumns,
|
||||
getWorksmobileComparisonStatusLabel,
|
||||
getWorksmobileRowSelectionKey,
|
||||
@@ -460,6 +461,17 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
||||
).toEqual([rows[0]]);
|
||||
});
|
||||
|
||||
it("shows update-needed user rows by default", () => {
|
||||
const rows = [
|
||||
{ resourceType: "USER", status: "needs_update", baronId: "user-1" },
|
||||
{ resourceType: "USER", status: "matched", baronId: "user-2" },
|
||||
];
|
||||
|
||||
expect(
|
||||
filterWorksmobileComparisonRows(rows, getDefaultUserComparisonFilters()),
|
||||
).toEqual([rows[0]]);
|
||||
});
|
||||
|
||||
it("formats update details for changed organization rows", () => {
|
||||
expect(
|
||||
formatWorksmobileUpdateDetails({
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Download,
|
||||
KeyRound,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Settings2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
@@ -38,15 +41,19 @@ import {
|
||||
} from "../../../components/ui/table";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
deleteWorksmobileCredentialBatchPasswords,
|
||||
downloadWorksmobileInitialPasswordsCSV,
|
||||
enqueueWorksmobileBackfillDryRun,
|
||||
enqueueWorksmobileOrgUnitDelete,
|
||||
enqueueWorksmobileOrgUnitSync,
|
||||
enqueueWorksmobileUserSync,
|
||||
fetchWorksmobileComparison,
|
||||
fetchWorksmobileCredentialBatches,
|
||||
fetchWorksmobileOverview,
|
||||
resetWorksmobileUserPassword,
|
||||
retryWorksmobileJob,
|
||||
type WorksmobileComparisonItem,
|
||||
type WorksmobileCredentialBatch,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import {
|
||||
@@ -61,11 +68,13 @@ import {
|
||||
formatWorksmobilePersonName,
|
||||
formatWorksmobileUpdateDetails,
|
||||
getDefaultGroupComparisonFilters,
|
||||
getDefaultUserComparisonFilters,
|
||||
getDefaultWorksmobileComparisonColumns,
|
||||
getWorksmobileComparisonStatusLabel,
|
||||
getWorksmobileRowSelectionKey,
|
||||
getWorksmobileSelectedActionIds,
|
||||
getWorksmobileSelectedWorksOnlyOrgUnitIds,
|
||||
isImmutableWorksmobileAccount,
|
||||
summarizeWorksmobileComparison,
|
||||
type WorksmobileComparisonColumnKey,
|
||||
type WorksmobileComparisonColumnVisibility,
|
||||
@@ -73,6 +82,17 @@ import {
|
||||
type WorksmobileComparisonSummary,
|
||||
} from "./worksmobileComparison";
|
||||
|
||||
type InitialPasswordDownloadVariables = {
|
||||
batchId?: string;
|
||||
};
|
||||
|
||||
export function createWorksmobileCredentialBatchId() {
|
||||
if (globalThis.crypto?.randomUUID) {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
return `worksmobile-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export function TenantWorksmobilePage() {
|
||||
const params = useParams<{ tenantId: string }>();
|
||||
const tenantId = params.tenantId ?? "";
|
||||
@@ -80,7 +100,7 @@ export function TenantWorksmobilePage() {
|
||||
const [userId, setUserId] = React.useState("");
|
||||
const [userFilters, setUserFilters] = React.useState<
|
||||
WorksmobileComparisonFilter[]
|
||||
>(["baron_only", "works_only"]);
|
||||
>(getDefaultUserComparisonFilters);
|
||||
const [groupFilters, setGroupFilters] = React.useState<
|
||||
WorksmobileComparisonFilter[]
|
||||
>(getDefaultGroupComparisonFilters);
|
||||
@@ -115,6 +135,12 @@ export function TenantWorksmobilePage() {
|
||||
enabled: tenantId.length > 0,
|
||||
});
|
||||
|
||||
const credentialBatchesQuery = useQuery({
|
||||
queryKey: ["worksmobile-credential-batches", tenantId],
|
||||
queryFn: () => fetchWorksmobileCredentialBatches(tenantId),
|
||||
enabled: tenantId.length > 0,
|
||||
});
|
||||
|
||||
const dryRunMutation = useMutation({
|
||||
mutationFn: () => enqueueWorksmobileBackfillDryRun(tenantId),
|
||||
onSuccess: () => {
|
||||
@@ -142,7 +168,8 @@ export function TenantWorksmobilePage() {
|
||||
});
|
||||
|
||||
const initialPasswordDownloadMutation = useMutation({
|
||||
mutationFn: () => downloadWorksmobileInitialPasswordsCSV(tenantId),
|
||||
mutationFn: (variables?: InitialPasswordDownloadVariables) =>
|
||||
downloadWorksmobileInitialPasswordsCSV(tenantId, variables?.batchId),
|
||||
onSuccess: ({ blob, filename }) => {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
@@ -160,6 +187,20 @@ export function TenantWorksmobilePage() {
|
||||
},
|
||||
});
|
||||
|
||||
const deleteCredentialBatchPasswordsMutation = useMutation({
|
||||
mutationFn: (batchId: string) =>
|
||||
deleteWorksmobileCredentialBatchPasswords(tenantId, batchId),
|
||||
onSuccess: () => {
|
||||
toast.success("비밀번호 값을 삭제했습니다.");
|
||||
credentialBatchesQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("비밀번호 값 삭제 실패", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const orgUnitSyncMutation = useMutation({
|
||||
mutationFn: () => enqueueWorksmobileOrgUnitSync(tenantId, orgUnitId.trim()),
|
||||
onSuccess: () => {
|
||||
@@ -194,26 +235,60 @@ export function TenantWorksmobilePage() {
|
||||
resourceKind: "users" | "groups";
|
||||
ids: string[];
|
||||
}) => {
|
||||
const credentialBatchId =
|
||||
resourceKind === "users"
|
||||
? createWorksmobileCredentialBatchId()
|
||||
: undefined;
|
||||
const failures: string[] = [];
|
||||
let successCount = 0;
|
||||
for (const id of ids) {
|
||||
if (resourceKind === "users") {
|
||||
await enqueueWorksmobileUserSync(tenantId, id);
|
||||
} else {
|
||||
await enqueueWorksmobileOrgUnitSync(tenantId, id);
|
||||
try {
|
||||
if (resourceKind === "users") {
|
||||
await enqueueWorksmobileUserSync(tenantId, id, credentialBatchId);
|
||||
} else {
|
||||
await enqueueWorksmobileOrgUnitSync(tenantId, id);
|
||||
}
|
||||
successCount += 1;
|
||||
} catch (error) {
|
||||
failures.push(`${id}: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
return { resourceKind, count: ids.length };
|
||||
|
||||
if (successCount === 0 && failures.length > 0) {
|
||||
throw new Error(failures.slice(0, 3).join("\n"));
|
||||
}
|
||||
|
||||
return {
|
||||
resourceKind,
|
||||
count: successCount,
|
||||
failureCount: failures.length,
|
||||
credentialBatchId:
|
||||
resourceKind === "users" && successCount > 0
|
||||
? credentialBatchId
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ resourceKind, count }) => {
|
||||
onSuccess: ({ resourceKind, count, failureCount }) => {
|
||||
if (resourceKind === "users") {
|
||||
setSelectedUserRowKeys([]);
|
||||
} else {
|
||||
setSelectedGroupRowKeys([]);
|
||||
}
|
||||
toast.success("WORKS 생성 작업을 등록했습니다.", {
|
||||
description: `${count}건`,
|
||||
});
|
||||
if (failureCount > 0) {
|
||||
toast.error("일부 WORKS 생성 작업 등록 실패", {
|
||||
description: `성공 ${count}건, 실패 ${failureCount}건`,
|
||||
});
|
||||
} else {
|
||||
toast.success("WORKS 생성 작업을 등록했습니다.", {
|
||||
description:
|
||||
resourceKind === "users"
|
||||
? `${count}건, 비밀번호 CSV는 배치 처리 완료 후 히스토리에서 다운로드할 수 있습니다.`
|
||||
: `${count}건`,
|
||||
});
|
||||
}
|
||||
overviewQuery.refetch();
|
||||
comparisonQuery.refetch();
|
||||
credentialBatchesQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("WORKS 생성 작업 등록 실패", {
|
||||
@@ -222,6 +297,30 @@ export function TenantWorksmobilePage() {
|
||||
},
|
||||
});
|
||||
|
||||
const resetWorksmobilePasswordMutation = useMutation({
|
||||
mutationFn: ({
|
||||
userId,
|
||||
credentialBatchId,
|
||||
}: {
|
||||
userId: string;
|
||||
credentialBatchId: string;
|
||||
}) => resetWorksmobileUserPassword(tenantId, userId, credentialBatchId),
|
||||
onSuccess: () => {
|
||||
toast.success("WORKS 비밀번호 재설정 작업을 등록했습니다.", {
|
||||
description:
|
||||
"비밀번호 CSV는 배치 처리 완료 후 히스토리에서 다운로드할 수 있습니다.",
|
||||
});
|
||||
overviewQuery.refetch();
|
||||
comparisonQuery.refetch();
|
||||
credentialBatchesQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("WORKS 비밀번호 재설정 등록 실패", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const syncSelectedOrgUnitsMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
baronIds,
|
||||
@@ -294,7 +393,10 @@ export function TenantWorksmobilePage() {
|
||||
createSelectedMutation.isPending &&
|
||||
createSelectedMutation.variables?.resourceKind === "users";
|
||||
const isSyncingGroups = syncSelectedOrgUnitsMutation.isPending;
|
||||
const isRefreshing = overviewQuery.isFetching || comparisonQuery.isFetching;
|
||||
const isRefreshing =
|
||||
overviewQuery.isFetching ||
|
||||
comparisonQuery.isFetching ||
|
||||
credentialBatchesQuery.isFetching;
|
||||
|
||||
return (
|
||||
<div className="min-w-0 max-w-full space-y-6">
|
||||
@@ -311,24 +413,13 @@ export function TenantWorksmobilePage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => initialPasswordDownloadMutation.mutate()}
|
||||
disabled={initialPasswordDownloadMutation.isPending}
|
||||
>
|
||||
<Download size={16} />
|
||||
{t(
|
||||
"ui.admin.tenants.worksmobile.initial_password_csv",
|
||||
"초기 비밀번호 CSV",
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
overviewQuery.refetch();
|
||||
comparisonQuery.refetch();
|
||||
credentialBatchesQuery.refetch();
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
@@ -346,6 +437,29 @@ export function TenantWorksmobilePage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<CredentialBatchHistory
|
||||
batches={credentialBatchesQuery.data ?? []}
|
||||
loading={credentialBatchesQuery.isLoading}
|
||||
downloadingBatchId={
|
||||
initialPasswordDownloadMutation.isPending
|
||||
? initialPasswordDownloadMutation.variables?.batchId
|
||||
: undefined
|
||||
}
|
||||
deletingBatchId={deleteCredentialBatchPasswordsMutation.variables}
|
||||
onDownload={(batchId) =>
|
||||
initialPasswordDownloadMutation.mutate({ batchId })
|
||||
}
|
||||
onDelete={(batchId) => {
|
||||
if (
|
||||
window.confirm(
|
||||
"이 배치의 실제 비밀번호 값을 삭제할까요? 생성 이력은 유지됩니다.",
|
||||
)
|
||||
) {
|
||||
deleteCredentialBatchPasswordsMutation.mutate(batchId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-3">
|
||||
<div>
|
||||
@@ -408,6 +522,23 @@ export function TenantWorksmobilePage() {
|
||||
ids,
|
||||
})
|
||||
}
|
||||
resettingPasswordUserId={
|
||||
resetWorksmobilePasswordMutation.isPending
|
||||
? resetWorksmobilePasswordMutation.variables?.userId
|
||||
: undefined
|
||||
}
|
||||
onResetUserPassword={(userId) => {
|
||||
if (
|
||||
window.confirm(
|
||||
"선택한 WORKS 계정의 비밀번호를 재설정할까요? 새 비밀번호는 배치 처리 완료 후 히스토리에서 CSV로 다운로드할 수 있습니다.",
|
||||
)
|
||||
) {
|
||||
resetWorksmobilePasswordMutation.mutate({
|
||||
userId,
|
||||
credentialBatchId: createWorksmobileCredentialBatchId(),
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ComparisonTable
|
||||
title={t(
|
||||
@@ -605,6 +736,216 @@ function getWorksmobileComparisonStatusVariant(status: string) {
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
function formatCredentialBatchDate(value?: string) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "-";
|
||||
return date.toLocaleString("ko-KR", {
|
||||
timeZone: "Asia/Seoul",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function CredentialBatchHistory({
|
||||
batches,
|
||||
loading,
|
||||
downloadingBatchId,
|
||||
deletingBatchId,
|
||||
onDownload,
|
||||
onDelete,
|
||||
}: {
|
||||
batches: WorksmobileCredentialBatch[];
|
||||
loading: boolean;
|
||||
downloadingBatchId?: string;
|
||||
deletingBatchId?: string;
|
||||
onDownload: (batchId: string) => void;
|
||||
onDelete: (batchId: string) => void;
|
||||
}) {
|
||||
const [expandedBatchIds, setExpandedBatchIds] = React.useState<string[]>([]);
|
||||
const toggleExpanded = (batchId: string) => {
|
||||
setExpandedBatchIds((current) =>
|
||||
current.includes(batchId)
|
||||
? current.filter((id) => id !== batchId)
|
||||
: [...current, batchId],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">비밀번호 파일 히스토리</CardTitle>
|
||||
<CardDescription>
|
||||
생성 배치별 CSV를 다시 받거나 전달 완료된 배치의 실제 비밀번호 값을
|
||||
삭제합니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="w-full max-w-full overflow-x-auto rounded-md border">
|
||||
<Table className="min-w-max">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-56 whitespace-nowrap">
|
||||
배치
|
||||
</TableHead>
|
||||
<TableHead className="w-24 whitespace-nowrap">사용자</TableHead>
|
||||
<TableHead className="min-w-36 whitespace-nowrap">
|
||||
상태
|
||||
</TableHead>
|
||||
<TableHead className="min-w-44 whitespace-nowrap">
|
||||
생성
|
||||
</TableHead>
|
||||
<TableHead className="min-w-44 whitespace-nowrap">
|
||||
삭제
|
||||
</TableHead>
|
||||
<TableHead className="w-24 whitespace-nowrap">관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-muted-foreground">
|
||||
불러오는 중...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!loading && batches.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-muted-foreground">
|
||||
생성된 비밀번호 배치가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{batches.map((batch) => {
|
||||
const isComplete =
|
||||
(batch.pendingCount ?? 0) === 0 &&
|
||||
(batch.processingCount ?? 0) === 0;
|
||||
const isExpanded = expandedBatchIds.includes(batch.batchId);
|
||||
const failures = batch.failures ?? [];
|
||||
return (
|
||||
<React.Fragment key={batch.batchId}>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
{failures.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={`${batch.batchId} 실패 사유 보기`}
|
||||
onClick={() => toggleExpanded(batch.batchId)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={16} />
|
||||
) : (
|
||||
<ChevronRight size={16} />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<span>{batch.batchId}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono">
|
||||
{batch.userCount}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs">
|
||||
<span className="mr-2">
|
||||
성공 {batch.processedCount ?? 0}
|
||||
</span>
|
||||
<span className="mr-2">
|
||||
대기 {batch.pendingCount ?? 0}
|
||||
</span>
|
||||
<span className="mr-2">
|
||||
처리 {batch.processingCount ?? 0}
|
||||
</span>
|
||||
<span>실패 {batch.failedCount ?? 0}</span>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs">
|
||||
{formatCredentialBatchDate(batch.createdAt)}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs">
|
||||
{batch.hasPasswords
|
||||
? "보관 중"
|
||||
: formatCredentialBatchDate(batch.deletedAt)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={`${batch.batchId} 비밀번호 CSV 다운로드`}
|
||||
disabled={
|
||||
!batch.hasPasswords ||
|
||||
!isComplete ||
|
||||
downloadingBatchId === batch.batchId
|
||||
}
|
||||
onClick={() => onDownload(batch.batchId)}
|
||||
>
|
||||
<Download size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={`${batch.batchId} 비밀번호 값 삭제`}
|
||||
disabled={
|
||||
!batch.hasPasswords ||
|
||||
deletingBatchId === batch.batchId
|
||||
}
|
||||
onClick={() => onDelete(batch.batchId)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isExpanded && failures.length > 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="bg-muted/30">
|
||||
<div className="space-y-2 text-xs">
|
||||
{failures.map((failure) => (
|
||||
<div
|
||||
key={`${failure.userId ?? failure.email}:${failure.lastError}`}
|
||||
className="grid gap-1 md:grid-cols-[minmax(12rem,1fr)_5rem_minmax(18rem,2fr)]"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{failure.email ?? failure.userId ?? "-"}
|
||||
</div>
|
||||
{failure.userId && (
|
||||
<div className="font-mono text-muted-foreground">
|
||||
{failure.userId}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{failure.status} / retry{" "}
|
||||
{failure.retryCount ?? 0}
|
||||
</div>
|
||||
<div className="break-words">
|
||||
{failure.lastError ?? "-"}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ComparisonSummary({
|
||||
title,
|
||||
summary,
|
||||
@@ -740,6 +1081,8 @@ function ComparisonTable({
|
||||
deleteActionLabel,
|
||||
deleteActionDisabled = false,
|
||||
onDeleteSelected,
|
||||
resettingPasswordUserId,
|
||||
onResetUserPassword,
|
||||
}: {
|
||||
title: string;
|
||||
rows: WorksmobileComparisonItem[];
|
||||
@@ -768,6 +1111,8 @@ function ComparisonTable({
|
||||
deleteActionLabel?: string;
|
||||
deleteActionDisabled?: boolean;
|
||||
onDeleteSelected?: (ids: string[]) => void;
|
||||
resettingPasswordUserId?: string;
|
||||
onResetUserPassword?: (userId: string) => void;
|
||||
}) {
|
||||
const [columnSettingsOpen, setColumnSettingsOpen] = React.useState(false);
|
||||
const selectableKeys = rows
|
||||
@@ -841,6 +1186,15 @@ function ComparisonTable({
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const canResetPassword = (row: WorksmobileComparisonItem) =>
|
||||
Boolean(
|
||||
onResetUserPassword &&
|
||||
row.resourceType === "USER" &&
|
||||
row.baronId &&
|
||||
row.status !== "missing_in_worksmobile" &&
|
||||
!isImmutableWorksmobileAccount(row),
|
||||
);
|
||||
|
||||
const toggleColumn = (key: WorksmobileComparisonColumnKey) => {
|
||||
onVisibleColumnsChange((current) => ({
|
||||
...current,
|
||||
@@ -1169,21 +1523,40 @@ function ComparisonTable({
|
||||
{showManageColumn && isColumnVisible("manage") && (
|
||||
<TableCell className="whitespace-nowrap">
|
||||
{row.resourceType === "USER" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={`${row.worksmobileName ?? row.baronName ?? row.worksmobileId ?? "WORKS user"} 비밀번호 관리`}
|
||||
disabled={
|
||||
!canOpenWorksmobilePasswordManage(
|
||||
row,
|
||||
passwordManageTenantId,
|
||||
)
|
||||
}
|
||||
onClick={() => openPasswordManage(row)}
|
||||
>
|
||||
<KeyRound size={16} />
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={`${row.worksmobileName ?? row.baronName ?? row.worksmobileId ?? "WORKS user"} 비밀번호 관리`}
|
||||
disabled={
|
||||
!canOpenWorksmobilePasswordManage(
|
||||
row,
|
||||
passwordManageTenantId,
|
||||
)
|
||||
}
|
||||
onClick={() => openPasswordManage(row)}
|
||||
>
|
||||
<KeyRound size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={`${row.worksmobileName ?? row.baronName ?? row.worksmobileId ?? "WORKS user"} 비밀번호 재설정`}
|
||||
disabled={
|
||||
!canResetPassword(row) ||
|
||||
resettingPasswordUserId === row.baronId
|
||||
}
|
||||
onClick={() => {
|
||||
if (row.baronId) {
|
||||
onResetUserPassword?.(row.baronId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
@@ -300,6 +300,9 @@ export function formatWorksmobileOrgDetails(row: WorksmobileComparisonItem) {
|
||||
}
|
||||
|
||||
export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
|
||||
if (row.status === "missing_in_worksmobile" && row.worksmobileLastError) {
|
||||
return [`최근 실패: ${row.worksmobileLastError}`];
|
||||
}
|
||||
if (row.status !== "needs_update") {
|
||||
return [];
|
||||
}
|
||||
@@ -310,6 +313,21 @@ export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
|
||||
if (baronName && worksmobileName && baronName !== worksmobileName) {
|
||||
details.push(`이름: ${worksmobileName} -> ${baronName}`);
|
||||
}
|
||||
if (row.resourceType === "USER") {
|
||||
const expectedExternalKey = row.baronId?.trim() ?? "";
|
||||
const actualExternalKey = row.externalKey?.trim() ?? "";
|
||||
if (expectedExternalKey && expectedExternalKey !== actualExternalKey) {
|
||||
details.push(
|
||||
`external_key: ${actualExternalKey || "없음"} -> ${expectedExternalKey}`,
|
||||
);
|
||||
}
|
||||
const expectedEmail = row.baronEmail?.trim().toLowerCase() ?? "";
|
||||
const actualEmail = row.worksmobileEmail?.trim().toLowerCase() ?? "";
|
||||
if (expectedEmail && actualEmail && expectedEmail !== actualEmail) {
|
||||
details.push(`이메일: ${actualEmail} -> ${expectedEmail}`);
|
||||
}
|
||||
return details;
|
||||
}
|
||||
|
||||
const expectedParent =
|
||||
row.baronParentWorksmobileName ??
|
||||
@@ -395,6 +413,10 @@ export const comparisonFilterOptions: Array<{
|
||||
|
||||
export const userFilterOptions = comparisonFilterOptions;
|
||||
|
||||
export function getDefaultUserComparisonFilters(): WorksmobileComparisonFilter[] {
|
||||
return ["baron_only", "needs_update", "works_only"];
|
||||
}
|
||||
|
||||
export function getDefaultGroupComparisonFilters(): WorksmobileComparisonFilter[] {
|
||||
return ["baron_only", "needs_update", "works_only"];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import UserDetailPage from "./UserDetailPage";
|
||||
|
||||
const updateUserMock = vi.hoisted(() => vi.fn());
|
||||
const profileRoleMock = vi.hoisted(() => ({ role: "super_admin" }));
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
deleteUser: vi.fn(),
|
||||
fetchAllTenants: vi.fn(async () => ({
|
||||
items: [
|
||||
{
|
||||
id: "tenant-hanmac",
|
||||
type: "COMPANY",
|
||||
name: "한맥기술",
|
||||
slug: "hanmac",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 1,
|
||||
createdAt: "2026-06-01T00:00:00Z",
|
||||
updatedAt: "2026-06-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
})),
|
||||
fetchMe: vi.fn(async () => ({
|
||||
id: "admin-user",
|
||||
role: profileRoleMock.role,
|
||||
name: "Admin",
|
||||
email: "admin@example.com",
|
||||
})),
|
||||
fetchPasswordPolicy: vi.fn(async () => ({ minLength: 12 })),
|
||||
fetchTenant: vi.fn(),
|
||||
fetchUser: vi.fn(async () => ({
|
||||
id: "user-1",
|
||||
email: "user@example.com",
|
||||
name: "사용자",
|
||||
phone: "01012345678",
|
||||
role: "user",
|
||||
status: "active",
|
||||
tenantSlug: "hanmac",
|
||||
tenant: {
|
||||
id: "tenant-hanmac",
|
||||
type: "COMPANY",
|
||||
name: "한맥기술",
|
||||
slug: "hanmac",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 1,
|
||||
createdAt: "2026-06-01T00:00:00Z",
|
||||
updatedAt: "2026-06-01T00:00:00Z",
|
||||
},
|
||||
joinedTenants: [],
|
||||
metadata: {
|
||||
employee_id: {
|
||||
"0": "h",
|
||||
"1": "j",
|
||||
"2": "k",
|
||||
"3": "w",
|
||||
"4": "o",
|
||||
"5": "n",
|
||||
},
|
||||
},
|
||||
createdAt: "2026-06-01T00:00:00Z",
|
||||
updatedAt: "2026-06-01T00:00:00Z",
|
||||
})),
|
||||
fetchUserRpHistory: vi.fn(async () => []),
|
||||
updateUser: updateUserMock,
|
||||
}));
|
||||
|
||||
function renderUserDetailPage() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={["/users/user-1"]}>
|
||||
<Routes>
|
||||
<Route path="/users/:id" element={<UserDetailPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("UserDetailPage Worksmobile employee number", () => {
|
||||
beforeEach(() => {
|
||||
updateUserMock.mockReset();
|
||||
updateUserMock.mockResolvedValue({});
|
||||
profileRoleMock.role = "super_admin";
|
||||
});
|
||||
|
||||
it("shows and saves metadata employee_id from the user edit form", async () => {
|
||||
renderUserDetailPage();
|
||||
|
||||
const employeeInput = await screen.findByLabelText("사번");
|
||||
|
||||
expect(employeeInput).toHaveValue("hjkwon");
|
||||
|
||||
fireEvent.change(employeeInput, { target: { value: "EMP001" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /저장하기/ }));
|
||||
|
||||
await waitFor(() => expect(updateUserMock).toHaveBeenCalled());
|
||||
expect(updateUserMock).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({ employee_id: "EMP001" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows super admin to save a changed email", async () => {
|
||||
renderUserDetailPage();
|
||||
|
||||
const emailInput = await screen.findByLabelText("이메일");
|
||||
fireEvent.change(emailInput, { target: { value: "changed@example.com" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /저장하기/ }));
|
||||
|
||||
await waitFor(() => expect(updateUserMock).toHaveBeenCalled());
|
||||
expect(updateUserMock).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
expect.objectContaining({
|
||||
email: "changed@example.com",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps email read-only for non-super admin", async () => {
|
||||
profileRoleMock.role = "tenant_admin";
|
||||
renderUserDetailPage();
|
||||
|
||||
const emailInput = await screen.findByLabelText("이메일");
|
||||
|
||||
expect(emailInput).toBeDisabled();
|
||||
});
|
||||
|
||||
it("removes metadata employee_id when the field is cleared", async () => {
|
||||
renderUserDetailPage();
|
||||
|
||||
const employeeInput = await screen.findByLabelText("사번");
|
||||
|
||||
fireEvent.change(employeeInput, { target: { value: "" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /저장하기/ }));
|
||||
|
||||
await waitFor(() => expect(updateUserMock).toHaveBeenCalled());
|
||||
const payload = updateUserMock.mock.calls[0][1];
|
||||
expect(payload.metadata).not.toHaveProperty("employee_id");
|
||||
});
|
||||
});
|
||||
@@ -95,6 +95,7 @@ import { resolvePersonalTenant } from "./utils/personalTenant";
|
||||
type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
|
||||
email: string;
|
||||
metadata: Record<string, unknown> & {
|
||||
employee_id?: string;
|
||||
sub_email?: string | string[];
|
||||
};
|
||||
};
|
||||
@@ -130,6 +131,29 @@ function cleanMetadataValue(value: unknown): unknown {
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeEmployeeIDMetadataValue(value: unknown) {
|
||||
if (typeof value === "string" || typeof value === "number") {
|
||||
return String(value).trim();
|
||||
}
|
||||
if (!isMetadataRecord(value)) {
|
||||
return "";
|
||||
}
|
||||
const entries = Object.entries(value)
|
||||
.map(([key, fieldValue]) => ({
|
||||
index: Number(key),
|
||||
value: typeof fieldValue === "string" ? fieldValue : "",
|
||||
}))
|
||||
.filter((entry) => Number.isInteger(entry.index) && entry.value.length > 0)
|
||||
.sort((a, b) => a.index - b.index);
|
||||
if (entries.length === 0) {
|
||||
return "";
|
||||
}
|
||||
return entries
|
||||
.map((entry) => entry.value)
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeSubEmails(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
@@ -699,6 +723,9 @@ function UserDetailPage() {
|
||||
string,
|
||||
Record<string, string | number | boolean>
|
||||
>) || {}),
|
||||
employee_id: normalizeEmployeeIDMetadataValue(
|
||||
user.metadata?.employee_id,
|
||||
),
|
||||
sub_email: Array.isArray(user.metadata?.sub_email)
|
||||
? user.metadata.sub_email
|
||||
: typeof user.metadata?.sub_email === "string"
|
||||
@@ -837,15 +864,22 @@ function UserDetailPage() {
|
||||
...safeMetadata,
|
||||
...(subEmail.length > 0 ? { sub_email: subEmail } : { sub_email: [] }),
|
||||
};
|
||||
const employeeID = String(data.metadata?.employee_id ?? "").trim();
|
||||
if (employeeID) {
|
||||
metadata.employee_id = employeeID;
|
||||
} else {
|
||||
delete metadata.employee_id;
|
||||
}
|
||||
|
||||
const payload: UserUpdateRequest = {
|
||||
...data,
|
||||
metadata,
|
||||
};
|
||||
// email cannot be updated directly via this API in current backend implementation,
|
||||
// so we delete it from payload if it spread
|
||||
// @ts-expect-error
|
||||
delete payload.email;
|
||||
if (profileRole !== "super_admin") {
|
||||
delete payload.email;
|
||||
} else {
|
||||
payload.email = data.email.trim();
|
||||
}
|
||||
payload.role = undefined;
|
||||
|
||||
if (userCategory === "personal") {
|
||||
@@ -1107,9 +1141,19 @@ function UserDetailPage() {
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
value={user.email}
|
||||
disabled
|
||||
className="bg-muted/50 border-none font-medium h-11"
|
||||
type="email"
|
||||
disabled={profileRole !== "super_admin"}
|
||||
{...register("email", {
|
||||
required: t(
|
||||
"msg.admin.users.detail.email_required",
|
||||
"이메일을 입력하세요.",
|
||||
),
|
||||
})}
|
||||
className={
|
||||
profileRole === "super_admin"
|
||||
? "h-11 shadow-sm"
|
||||
: "bg-muted/50 border-none font-medium h-11"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -1146,6 +1190,37 @@ function UserDetailPage() {
|
||||
className="h-11 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="metadata_employee_id"
|
||||
className="text-xs font-bold uppercase text-muted-foreground"
|
||||
>
|
||||
사번
|
||||
</Label>
|
||||
<Input
|
||||
id="metadata_employee_id"
|
||||
maxLength={20}
|
||||
{...register("metadata.employee_id", {
|
||||
setValueAs: (value) =>
|
||||
typeof value === "string" ? value.trim() : value,
|
||||
maxLength: {
|
||||
value: 20,
|
||||
message:
|
||||
"Worksmobile 사번은 20자 이하로 입력해야 합니다.",
|
||||
},
|
||||
})}
|
||||
className="h-11 shadow-sm"
|
||||
/>
|
||||
{errors.metadata?.employee_id && (
|
||||
<p className="text-xs text-destructive">
|
||||
{String(errors.metadata.employee_id.message)}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
Worksmobile employeeNumber로 전송됩니다. 1~20자만
|
||||
허용됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-2 pt-6 border-t border-dashed">
|
||||
|
||||
@@ -115,6 +115,13 @@ function hanmacEmailStatusLabel(preview?: HanmacImportEmailPreview) {
|
||||
return "";
|
||||
}
|
||||
|
||||
function userImportErrorLabel(user: BulkUserItem) {
|
||||
if (!user.importErrors?.includes("duplicateEmail")) {
|
||||
return "";
|
||||
}
|
||||
return "중복 이메일";
|
||||
}
|
||||
|
||||
function hanmacEmailStatusClass(preview?: HanmacImportEmailPreview) {
|
||||
if (!preview) return "text-muted-foreground";
|
||||
if (preview.status === "blockingError") return "text-destructive";
|
||||
@@ -355,6 +362,9 @@ export function UserBulkUploadModal({
|
||||
const hasBlockingHanmacEmailRows = hanmacEmailPreviews.some(
|
||||
(preview) => preview?.status === "blockingError",
|
||||
);
|
||||
const hasBlockingImportRows = previewData.some(
|
||||
(user) => (user.importErrors?.length ?? 0) > 0,
|
||||
);
|
||||
|
||||
const triggerProps = {
|
||||
disabled: mutation.isPending,
|
||||
@@ -576,11 +586,22 @@ export function UserBulkUploadModal({
|
||||
<td className="p-2">{u.name}</td>
|
||||
<td className="p-2">{u.tenantSlug || "-"}</td>
|
||||
<td
|
||||
className={`p-2 text-xs ${hanmacEmailStatusClass(
|
||||
hanmacEmailPreviews[index],
|
||||
)}`}
|
||||
className={`p-2 text-xs ${
|
||||
u.importErrors?.length
|
||||
? "text-destructive"
|
||||
: hanmacEmailStatusClass(
|
||||
hanmacEmailPreviews[index],
|
||||
)
|
||||
}`}
|
||||
>
|
||||
{hanmacEmailStatusLabel(hanmacEmailPreviews[index])}
|
||||
{u.importErrors?.length
|
||||
? "오류"
|
||||
: hanmacEmailStatusLabel(
|
||||
hanmacEmailPreviews[index],
|
||||
)}
|
||||
{u.importErrors?.length ? (
|
||||
<div>{userImportErrorLabel(u)}</div>
|
||||
) : null}
|
||||
{hanmacEmailPreviews[index]?.reason && (
|
||||
<div>{hanmacEmailPreviews[index]?.reason}</div>
|
||||
)}
|
||||
@@ -665,7 +686,8 @@ export function UserBulkUploadModal({
|
||||
previewData.length === 0 ||
|
||||
mutation.isPending ||
|
||||
preparing ||
|
||||
hasBlockingHanmacEmailRows
|
||||
hasBlockingHanmacEmailRows ||
|
||||
hasBlockingImportRows
|
||||
}
|
||||
className="w-full sm:w-auto"
|
||||
data-testid="bulk-start-btn"
|
||||
|
||||
@@ -97,6 +97,22 @@ test@test.com,Test,local-tenant-id,missing-slug,Missing Tenant,COMPANY,parent-sl
|
||||
});
|
||||
});
|
||||
|
||||
it("should preserve exported user_id for UUID based restore", () => {
|
||||
const csv = `user_id,email,name,tenant_id,tenant_slug
|
||||
9f8cc1b1-af8d-45d4-946c-924a529c2556,restore@test.com,Restore User,tenant-id,restore-tenant`;
|
||||
|
||||
const result = parseUserCSV(csv);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
userId: "9f8cc1b1-af8d-45d4-946c-924a529c2556",
|
||||
email: "restore@test.com",
|
||||
name: "Restore User",
|
||||
tenantId: "tenant-id",
|
||||
tenantSlug: "restore-tenant",
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse one nullable additional appointment from numbered columns", () => {
|
||||
const csv = `email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1
|
||||
dual@test.com,Dual User,010-0000-0000,user,primary-tenant,개발팀,책임,팀장,Backend,EMP001,second-tenant,센터,수석,,Architecture,EMP002
|
||||
@@ -146,4 +162,28 @@ primary@samaneng.com,Primary User,rnd-saman,EMP001,secondary@hanmaceng.co.kr`;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should mark duplicate bulk alias emails as blocking import errors", () => {
|
||||
const csv = `email,name,tenant_slug,sub_email
|
||||
user1@samaneng.com,User One,rnd-saman,shared@hanmaceng.co.kr
|
||||
user2@samaneng.com,User Two,rnd-saman,shared@hanmaceng.co.kr`;
|
||||
|
||||
const result = parseUserCSV(csv);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].importErrors).toContain("duplicateEmail");
|
||||
expect(result[1].importErrors).toContain("duplicateEmail");
|
||||
});
|
||||
|
||||
it("should mark a primary email reused as a sub email as a blocking import error", () => {
|
||||
const csv = `email,name,tenant_slug,sub_email
|
||||
user1@samaneng.com,User One,rnd-saman,user2@samaneng.com
|
||||
user2@samaneng.com,User Two,rnd-saman,alias@hanmaceng.co.kr`;
|
||||
|
||||
const result = parseUserCSV(csv);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].importErrors).toContain("duplicateEmail");
|
||||
expect(result[1].importErrors).toContain("duplicateEmail");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,7 +28,9 @@ export function parseUserCSV(text: string): BulkUserItem[] {
|
||||
const value = values[index];
|
||||
if (value === undefined || value === "") continue;
|
||||
|
||||
if (header === "email") {
|
||||
if (header === "user_id") {
|
||||
item.userId = value;
|
||||
} else if (header === "email") {
|
||||
item.email = value;
|
||||
} else if (header === "name") {
|
||||
item.name = value;
|
||||
@@ -186,7 +188,7 @@ export function parseUserCSV(text: string): BulkUserItem[] {
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
return markBulkEmailDuplicateErrors(data);
|
||||
}
|
||||
|
||||
function cleanAdditionalAppointment(
|
||||
@@ -335,6 +337,66 @@ function uniqueEmails(values: string[]) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function bulkUserImportErrorList(user: BulkUserItem) {
|
||||
return Array.isArray(user.importErrors) ? user.importErrors : [];
|
||||
}
|
||||
|
||||
function withBulkUserImportError(user: BulkUserItem, error: string) {
|
||||
const errors = Array.from(new Set([...bulkUserImportErrorList(user), error]));
|
||||
return { ...user, importErrors: errors };
|
||||
}
|
||||
|
||||
function bulkUserAliasEmails(user: BulkUserItem) {
|
||||
return uniqueEmails([
|
||||
...metadataEmailList(user.metadata.sub_email),
|
||||
...metadataEmailList(user.metadata.aliasEmails),
|
||||
...metadataEmailList(user.metadata.secondary_emails),
|
||||
...metadataEmailList(user.metadata.worksmobileAliasEmails),
|
||||
]);
|
||||
}
|
||||
|
||||
function markBulkEmailDuplicateErrors(users: BulkUserItem[]) {
|
||||
const duplicateIndexes = new Set<number>();
|
||||
const owners = new Map<string, Set<number>>();
|
||||
|
||||
users.forEach((user, index) => {
|
||||
const primaryEmail = user.email.trim().toLowerCase();
|
||||
const aliases = bulkUserAliasEmails(user);
|
||||
const rowEmails = new Set<string>();
|
||||
|
||||
if (primaryEmail) {
|
||||
rowEmails.add(primaryEmail);
|
||||
}
|
||||
for (const alias of aliases) {
|
||||
if (primaryEmail && alias === primaryEmail) {
|
||||
duplicateIndexes.add(index);
|
||||
}
|
||||
rowEmails.add(alias);
|
||||
}
|
||||
|
||||
for (const email of rowEmails) {
|
||||
const existing = owners.get(email) ?? new Set<number>();
|
||||
existing.add(index);
|
||||
owners.set(email, existing);
|
||||
}
|
||||
});
|
||||
|
||||
for (const indexes of owners.values()) {
|
||||
if (indexes.size < 2) {
|
||||
continue;
|
||||
}
|
||||
for (const index of indexes) {
|
||||
duplicateIndexes.add(index);
|
||||
}
|
||||
}
|
||||
|
||||
return users.map((user, index) =>
|
||||
duplicateIndexes.has(index)
|
||||
? withBulkUserImportError(user, "duplicateEmail")
|
||||
: user,
|
||||
);
|
||||
}
|
||||
|
||||
function addWorksmobileAliasEmails(
|
||||
item: Partial<BulkUserItem> & { metadata: Record<string, unknown> },
|
||||
emails: string[],
|
||||
|
||||
@@ -73,7 +73,12 @@ describe("adminApi endpoint contracts", () => {
|
||||
await adminApi.fetchUser("user-1");
|
||||
await adminApi.fetchWorksmobileOverview("tenant-1");
|
||||
await adminApi.fetchWorksmobileComparison("tenant-1", true);
|
||||
await adminApi.fetchWorksmobileCredentialBatches("tenant-1");
|
||||
await adminApi.downloadWorksmobileInitialPasswordsCSV("tenant-1");
|
||||
await adminApi.downloadWorksmobileInitialPasswordsCSV(
|
||||
"tenant-1",
|
||||
"credential-batch-1",
|
||||
);
|
||||
await adminApi.fetchPasswordPolicy();
|
||||
await adminApi.fetchUserRpHistory("user-1");
|
||||
await adminApi.fetchMe();
|
||||
@@ -104,6 +109,16 @@ describe("adminApi endpoint contracts", () => {
|
||||
"/v1/admin/tenants/tenant-1/worksmobile/comparison",
|
||||
{ params: { includeMatched: true } },
|
||||
);
|
||||
expect(apiClient.get).toHaveBeenCalledWith(
|
||||
"/v1/admin/tenants/tenant-1/worksmobile/credential-batches",
|
||||
);
|
||||
expect(apiClient.get).toHaveBeenCalledWith(
|
||||
"/v1/admin/tenants/tenant-1/worksmobile/initial-passwords.csv",
|
||||
{
|
||||
params: { batchId: "credential-batch-1" },
|
||||
responseType: "blob",
|
||||
},
|
||||
);
|
||||
expect(await adminApi.exportTenantsCSV(true, "parent-1")).toMatchObject({
|
||||
filename: "export.csv",
|
||||
});
|
||||
@@ -148,6 +163,20 @@ describe("adminApi endpoint contracts", () => {
|
||||
await adminApi.enqueueWorksmobileOrgUnitSync("tenant-1", "org/unit");
|
||||
await adminApi.enqueueWorksmobileOrgUnitDelete("tenant-1", "org/unit");
|
||||
await adminApi.enqueueWorksmobileUserSync("tenant-1", "user-1");
|
||||
await adminApi.enqueueWorksmobileUserSync(
|
||||
"tenant-1",
|
||||
"user-2",
|
||||
"credential-batch-1",
|
||||
);
|
||||
await adminApi.resetWorksmobileUserPassword(
|
||||
"tenant-1",
|
||||
"user-2",
|
||||
"credential-batch-2",
|
||||
);
|
||||
await adminApi.deleteWorksmobileCredentialBatchPasswords(
|
||||
"tenant-1",
|
||||
"credential-batch-1",
|
||||
);
|
||||
await adminApi.retryWorksmobileJob("tenant-1", "job-1");
|
||||
await adminApi.bulkUpdateUsers({ userIds: ["user-1"], status: "inactive" });
|
||||
await adminApi.bulkDeleteUsers(["user-1"]);
|
||||
@@ -178,6 +207,17 @@ describe("adminApi endpoint contracts", () => {
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
"/v1/admin/tenants/tenant-1/worksmobile/orgunits/org%2Funit/sync",
|
||||
);
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
"/v1/admin/tenants/tenant-1/worksmobile/users/user-2/sync",
|
||||
{ credentialBatchId: "credential-batch-1" },
|
||||
);
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
"/v1/admin/tenants/tenant-1/worksmobile/users/user-2/password/reset",
|
||||
{ credentialBatchId: "credential-batch-2" },
|
||||
);
|
||||
expect(apiClient.delete).toHaveBeenCalledWith(
|
||||
"/v1/admin/tenants/tenant-1/worksmobile/credential-batches/credential-batch-1/passwords",
|
||||
);
|
||||
expect(apiClient.delete).toHaveBeenCalledWith(
|
||||
"/v1/admin/relying-parties/client-1/owners/User:user-1",
|
||||
);
|
||||
|
||||
@@ -676,6 +676,7 @@ export type UserCreateResponse = UserSummary & {
|
||||
};
|
||||
|
||||
export type UserUpdateRequest = {
|
||||
email?: string;
|
||||
loginId?: string;
|
||||
password?: string;
|
||||
name?: string;
|
||||
@@ -725,6 +726,7 @@ export type BulkUserAppointment = {
|
||||
};
|
||||
|
||||
export type BulkUserItem = {
|
||||
userId?: string;
|
||||
email: string;
|
||||
loginId?: string;
|
||||
name: string;
|
||||
@@ -750,6 +752,7 @@ export type BulkUserItem = {
|
||||
emailDomain?: string;
|
||||
};
|
||||
metadata: Record<string, unknown>;
|
||||
importErrors?: string[];
|
||||
};
|
||||
|
||||
export type BulkUserResult = {
|
||||
@@ -790,6 +793,30 @@ export type WorksmobileOverview = {
|
||||
recentJobs: WorksmobileOutboxItem[];
|
||||
};
|
||||
|
||||
export type WorksmobileCredentialBatch = {
|
||||
batchId: string;
|
||||
operation?: string;
|
||||
userCount: number;
|
||||
pendingCount?: number;
|
||||
processingCount?: number;
|
||||
processedCount?: number;
|
||||
failedCount?: number;
|
||||
hasPasswords: boolean;
|
||||
deletedAt?: string;
|
||||
failures?: WorksmobileCredentialBatchFailure[];
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type WorksmobileCredentialBatchFailure = {
|
||||
userId?: string;
|
||||
email?: string;
|
||||
status: string;
|
||||
retryCount: number;
|
||||
lastError?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type WorksmobileComparisonItem = {
|
||||
resourceType: string;
|
||||
baronId?: string;
|
||||
@@ -823,6 +850,10 @@ export type WorksmobileComparisonItem = {
|
||||
worksmobileParentName?: string;
|
||||
worksmobileParentEmail?: string;
|
||||
worksmobileParentExternalKey?: string;
|
||||
worksmobileJobStatus?: string;
|
||||
worksmobileJobRetryCount?: number;
|
||||
worksmobileLastError?: string;
|
||||
worksmobileLastAttemptAt?: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
@@ -906,10 +937,22 @@ export async function fetchWorksmobileComparison(
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function downloadWorksmobileInitialPasswordsCSV(tenantId: string) {
|
||||
export async function fetchWorksmobileCredentialBatches(tenantId: string) {
|
||||
const { data } = await apiClient.get<WorksmobileCredentialBatch[]>(
|
||||
`/v1/admin/tenants/${tenantId}/worksmobile/credential-batches`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function downloadWorksmobileInitialPasswordsCSV(
|
||||
tenantId: string,
|
||||
batchId?: string,
|
||||
) {
|
||||
const trimmedBatchId = batchId?.trim();
|
||||
const response = await apiClient.get<Blob>(
|
||||
`/v1/admin/tenants/${tenantId}/worksmobile/initial-passwords.csv`,
|
||||
{
|
||||
...(trimmedBatchId ? { params: { batchId: trimmedBatchId } } : {}),
|
||||
responseType: "blob",
|
||||
},
|
||||
);
|
||||
@@ -924,6 +967,16 @@ export async function downloadWorksmobileInitialPasswordsCSV(tenantId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteWorksmobileCredentialBatchPasswords(
|
||||
tenantId: string,
|
||||
batchId: string,
|
||||
) {
|
||||
const { data } = await apiClient.delete<WorksmobileCredentialBatch>(
|
||||
`/v1/admin/tenants/${tenantId}/worksmobile/credential-batches/${encodeURIComponent(batchId)}/passwords`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function enqueueWorksmobileBackfillDryRun(tenantId: string) {
|
||||
const { data } = await apiClient.post(
|
||||
`/v1/admin/tenants/${tenantId}/worksmobile/backfill/dry-run`,
|
||||
@@ -954,10 +1007,30 @@ export async function enqueueWorksmobileOrgUnitDelete(
|
||||
export async function enqueueWorksmobileUserSync(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
credentialBatchId?: string,
|
||||
) {
|
||||
const { data } = await apiClient.post<WorksmobileOutboxItem>(
|
||||
`/v1/admin/tenants/${tenantId}/worksmobile/users/${userId}/sync`,
|
||||
);
|
||||
const trimmedBatchId = credentialBatchId?.trim();
|
||||
const path = `/v1/admin/tenants/${tenantId}/worksmobile/users/${userId}/sync`;
|
||||
const { data } = trimmedBatchId
|
||||
? await apiClient.post<WorksmobileOutboxItem>(path, {
|
||||
credentialBatchId: trimmedBatchId,
|
||||
})
|
||||
: await apiClient.post<WorksmobileOutboxItem>(path);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function resetWorksmobileUserPassword(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
credentialBatchId?: string,
|
||||
) {
|
||||
const trimmedBatchId = credentialBatchId?.trim();
|
||||
const path = `/v1/admin/tenants/${tenantId}/worksmobile/users/${userId}/password/reset`;
|
||||
const { data } = trimmedBatchId
|
||||
? await apiClient.post<WorksmobileOutboxItem>(path, {
|
||||
credentialBatchId: trimmedBatchId,
|
||||
})
|
||||
: await apiClient.post<WorksmobileOutboxItem>(path);
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -750,11 +750,14 @@ func main() {
|
||||
|
||||
admin.Get("/tenants/:tenantId/worksmobile", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.GetOverview)
|
||||
admin.Get("/tenants/:tenantId/worksmobile/comparison", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.GetComparison)
|
||||
admin.Get("/tenants/:tenantId/worksmobile/credential-batches", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.ListCredentialBatches)
|
||||
admin.Delete("/tenants/:tenantId/worksmobile/credential-batches/:batchId/passwords", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DeleteCredentialBatchPasswords)
|
||||
admin.Get("/tenants/:tenantId/worksmobile/initial-passwords.csv", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DownloadInitialPasswordsCSV)
|
||||
admin.Post("/tenants/:tenantId/worksmobile/backfill/dry-run", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.BackfillDryRun)
|
||||
admin.Post("/tenants/:tenantId/worksmobile/orgunits/:orgUnitId/sync", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.SyncOrgUnit)
|
||||
admin.Post("/tenants/:tenantId/worksmobile/orgunits/:orgUnitId/delete", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DeleteOrgUnit)
|
||||
admin.Post("/tenants/:tenantId/worksmobile/users/:userId/sync", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.SyncUser)
|
||||
admin.Post("/tenants/:tenantId/worksmobile/users/:userId/password/reset", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.ResetUserPassword)
|
||||
admin.Post("/tenants/:tenantId/worksmobile/jobs/:jobId/retry", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.RetryJob)
|
||||
|
||||
// Organization & Org-Chart Management (Tenant Admin/Super Admin)
|
||||
|
||||
@@ -20,10 +20,11 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
WorksmobileActionUpsert = "UPSERT"
|
||||
WorksmobileActionDelete = "DELETE"
|
||||
WorksmobileActionDryRun = "DRY_RUN"
|
||||
WorksmobileActionSuspend = "SUSPEND"
|
||||
WorksmobileActionUpsert = "UPSERT"
|
||||
WorksmobileActionDelete = "DELETE"
|
||||
WorksmobileActionDryRun = "DRY_RUN"
|
||||
WorksmobileActionSuspend = "SUSPEND"
|
||||
WorksmobileActionPasswordReset = "PASSWORD_RESET"
|
||||
)
|
||||
|
||||
type WorksmobileOutbox struct {
|
||||
|
||||
@@ -13,8 +13,10 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -850,6 +852,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
type bulkUserItem struct {
|
||||
UserID string `json:"userId"`
|
||||
Email string `json:"email"`
|
||||
LoginID string `json:"loginId"`
|
||||
Name string `json:"name"`
|
||||
@@ -906,6 +909,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
var hanmacScope *hanmacEmailScope
|
||||
var hanmacLocalParts map[string]bool
|
||||
hanmacScopeLoaded := false
|
||||
bulkEmailErrors := validateBulkUserEmailUniqueness(req.Users)
|
||||
|
||||
// Pre-fetch tenant data to avoid redundant DB calls
|
||||
type tenantCacheItem struct {
|
||||
@@ -1011,7 +1015,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
return cacheTenantItem(buildTenantCacheItem(tenant)), nil
|
||||
}
|
||||
|
||||
for _, item := range req.Users {
|
||||
for index, item := range req.Users {
|
||||
email := strings.TrimSpace(item.Email)
|
||||
name := strings.TrimSpace(item.Name)
|
||||
tenantID := strings.TrimSpace(item.TenantID)
|
||||
@@ -1026,6 +1030,10 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
results = append(results, bulkUserResult{Email: email, Success: false, Message: tenantSlugErr.Error()})
|
||||
continue
|
||||
}
|
||||
if message, exists := bulkEmailErrors[index]; exists {
|
||||
results = append(results, bulkUserResult{Email: email, Success: false, Status: "blockingError", Message: message})
|
||||
continue
|
||||
}
|
||||
|
||||
var tItem tenantCacheItem
|
||||
var err error
|
||||
@@ -1192,6 +1200,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
}
|
||||
item.Metadata["additionalAppointments"] = resolvedAppointments
|
||||
}
|
||||
normalizeBulkUserAliasMetadata(item.Metadata)
|
||||
item.Metadata = sanitizeUserMetadata(item.Metadata)
|
||||
|
||||
password, _ := utils.GeneratePasswordWithPolicy(policy)
|
||||
@@ -1252,6 +1261,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
identityID, err := h.OryProvider.CreateUser(&domain.BrokerUser{
|
||||
ID: strings.TrimSpace(item.UserID),
|
||||
Email: userEmail,
|
||||
Name: item.Name,
|
||||
PhoneNumber: userPhone,
|
||||
@@ -1845,6 +1855,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Email *string `json:"email"`
|
||||
LoginID *string `json:"loginId"`
|
||||
Password *string `json:"password"`
|
||||
Name *string `json:"name"`
|
||||
@@ -1948,6 +1959,31 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
if traits == nil {
|
||||
traits = map[string]interface{}{}
|
||||
}
|
||||
if req.Email != nil {
|
||||
currentEmail := strings.TrimSpace(extractTraitString(traits, "email"))
|
||||
nextEmail := strings.ToLower(strings.TrimSpace(*req.Email))
|
||||
if nextEmail == "" {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "email is required")
|
||||
}
|
||||
parsed, parseErr := mail.ParseAddress(nextEmail)
|
||||
if parseErr != nil || !strings.EqualFold(parsed.Address, nextEmail) {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid email")
|
||||
}
|
||||
if !strings.EqualFold(currentEmail, nextEmail) {
|
||||
if requester == nil || domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can change user email")
|
||||
}
|
||||
if h.UserRepo != nil {
|
||||
if existing, err := h.UserRepo.FindByEmail(c.Context(), nextEmail); err == nil && existing != nil && existing.ID != userID {
|
||||
return errorJSON(c, fiber.StatusConflict, "email is already used by another user")
|
||||
}
|
||||
if taken, err := h.UserRepo.IsLoginIDTaken(c.Context(), nextEmail); err == nil && taken {
|
||||
return errorJSON(c, fiber.StatusConflict, "email is already used as a login ID")
|
||||
}
|
||||
}
|
||||
traits["email"] = nextEmail
|
||||
}
|
||||
}
|
||||
delete(traits, "hanmacFamily")
|
||||
delete(traits, "userType")
|
||||
|
||||
@@ -2048,6 +2084,13 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
if subEmailRaw, exists := req.Metadata["sub_email"]; exists {
|
||||
subEmails := normalizeUserSubEmailValues(subEmailRaw)
|
||||
traits["sub_email"] = subEmails
|
||||
traits["aliasEmails"] = subEmails
|
||||
traits["secondary_emails"] = subEmails
|
||||
traits["worksmobileAliasEmails"] = subEmails
|
||||
}
|
||||
|
||||
// [LoginID Sync based on Tenant Settings]
|
||||
// Perform sync AFTER metadata merge to ensure traits contains current values
|
||||
@@ -2860,6 +2903,156 @@ func normalizeCustomLoginIDsTrait(traits map[string]interface{}) {
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeUserSubEmailValues(raw any) []interface{} {
|
||||
values := make([]string, 0)
|
||||
switch typed := raw.(type) {
|
||||
case []string:
|
||||
values = append(values, typed...)
|
||||
case []interface{}:
|
||||
for _, item := range typed {
|
||||
values = append(values, fmt.Sprint(item))
|
||||
}
|
||||
case string:
|
||||
values = append(values, typed)
|
||||
default:
|
||||
if raw != nil {
|
||||
values = append(values, fmt.Sprint(raw))
|
||||
}
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
result := make([]interface{}, 0, len(values))
|
||||
for _, value := range values {
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
normalized := strings.ToLower(strings.TrimSpace(part))
|
||||
if normalized == "" || seen[normalized] {
|
||||
continue
|
||||
}
|
||||
seen[normalized] = true
|
||||
result = append(result, normalized)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func validateBulkUserEmailUniqueness(users []bulkUserItem) map[int]string {
|
||||
owners := map[string]map[int]bool{}
|
||||
errorsByIndex := map[int]string{}
|
||||
|
||||
for index, user := range users {
|
||||
primaryEmail := normalizeBulkUserEmail(user.Email)
|
||||
aliases := bulkUserAliasEmailSet(user.Metadata)
|
||||
rowEmails := map[string]bool{}
|
||||
if primaryEmail != "" {
|
||||
rowEmails[primaryEmail] = true
|
||||
}
|
||||
for alias := range aliases {
|
||||
if primaryEmail != "" && alias == primaryEmail {
|
||||
errorsByIndex[index] = "duplicate email in bulk request: " + alias
|
||||
}
|
||||
rowEmails[alias] = true
|
||||
}
|
||||
for email := range rowEmails {
|
||||
if owners[email] == nil {
|
||||
owners[email] = map[int]bool{}
|
||||
}
|
||||
owners[email][index] = true
|
||||
}
|
||||
}
|
||||
|
||||
for email, indexes := range owners {
|
||||
if len(indexes) < 2 {
|
||||
continue
|
||||
}
|
||||
for index := range indexes {
|
||||
errorsByIndex[index] = "duplicate email in bulk request: " + email
|
||||
}
|
||||
}
|
||||
|
||||
return errorsByIndex
|
||||
}
|
||||
|
||||
func normalizeBulkUserAliasMetadata(metadata map[string]any) {
|
||||
if metadata == nil {
|
||||
return
|
||||
}
|
||||
aliases := bulkUserAliasEmailSet(metadata)
|
||||
hasAliasField := false
|
||||
for _, key := range []string{"sub_email", "aliasEmails", "secondary_emails", "worksmobileAliasEmails"} {
|
||||
if _, exists := metadata[key]; exists {
|
||||
hasAliasField = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasAliasField {
|
||||
return
|
||||
}
|
||||
values := make([]interface{}, 0, len(aliases))
|
||||
for alias := range aliases {
|
||||
values = append(values, alias)
|
||||
}
|
||||
sort.Slice(values, func(i, j int) bool {
|
||||
return fmt.Sprint(values[i]) < fmt.Sprint(values[j])
|
||||
})
|
||||
metadata["sub_email"] = values
|
||||
metadata["aliasEmails"] = values
|
||||
metadata["secondary_emails"] = values
|
||||
metadata["worksmobileAliasEmails"] = values
|
||||
}
|
||||
|
||||
func bulkUserAliasEmailSet(metadata map[string]any) map[string]bool {
|
||||
aliases := map[string]bool{}
|
||||
for _, key := range []string{"sub_email", "aliasEmails", "secondary_emails", "worksmobileAliasEmails"} {
|
||||
for _, value := range bulkUserEmailValues(metadata[key]) {
|
||||
aliases[value] = true
|
||||
}
|
||||
}
|
||||
return aliases
|
||||
}
|
||||
|
||||
func bulkUserEmailValues(raw any) []string {
|
||||
values := make([]string, 0)
|
||||
switch typed := raw.(type) {
|
||||
case []string:
|
||||
values = append(values, typed...)
|
||||
case []interface{}:
|
||||
for _, item := range typed {
|
||||
values = append(values, fmt.Sprint(item))
|
||||
}
|
||||
case string:
|
||||
values = append(values, typed)
|
||||
default:
|
||||
if raw != nil {
|
||||
values = append(values, fmt.Sprint(raw))
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
for _, token := range strings.FieldsFunc(value, func(r rune) bool {
|
||||
return r == ',' || r == ';' || r == '\n' || r == '\r' || r == '\t'
|
||||
}) {
|
||||
email := normalizeBulkUserEmail(token)
|
||||
if email != "" {
|
||||
result = append(result, email)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeBulkUserEmail(value string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
if normalized == "" {
|
||||
return ""
|
||||
}
|
||||
parsed, err := mail.ParseAddress(normalized)
|
||||
if err != nil {
|
||||
return normalized
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(parsed.Address))
|
||||
}
|
||||
|
||||
func formatTime(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return ""
|
||||
|
||||
@@ -600,6 +600,171 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestUserHandler_BulkCreateUsersPreservesRequestedUserID(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockOry := new(MockOryProvider)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
const requestedUserID = "9f8cc1b1-af8d-45d4-946c-924a529c2556"
|
||||
|
||||
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("GetTenant", mock.Anything, "tenant-123").Return(&domain.Tenant{
|
||||
ID: "tenant-123",
|
||||
Slug: "restore-tenant",
|
||||
Config: domain.JSONMap{},
|
||||
}, nil).Once()
|
||||
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
|
||||
return user != nil && user.ID == requestedUserID && user.Email == "restore@test.com"
|
||||
}), mock.Anything).Return(requestedUserID, nil).Once()
|
||||
|
||||
payload := map[string]any{
|
||||
"users": []map[string]any{
|
||||
{
|
||||
"userId": requestedUserID,
|
||||
"email": "restore@test.com",
|
||||
"name": "Restore User",
|
||||
"tenantId": "tenant-123",
|
||||
"tenantSlug": "restore-tenant",
|
||||
"metadata": map[string]any{},
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, _ := app.Test(req)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]any
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
|
||||
results := result["results"].([]any)
|
||||
require.Len(t, results, 1)
|
||||
row := results[0].(map[string]any)
|
||||
assert.True(t, row["success"].(bool))
|
||||
assert.Equal(t, requestedUserID, row["userId"])
|
||||
mockOry.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_BulkCreateUsersRejectsDuplicateAliasEmailsInBatch(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).Once()
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"users": []map[string]interface{}{
|
||||
{
|
||||
"email": "user1@samaneng.com",
|
||||
"name": "User One",
|
||||
"tenantSlug": "rnd-saman",
|
||||
"metadata": map[string]interface{}{
|
||||
"sub_email": []interface{}{"shared@hanmaceng.co.kr"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"email": "user2@samaneng.com",
|
||||
"name": "User Two",
|
||||
"tenantSlug": "rnd-saman",
|
||||
"metadata": map[string]interface{}{
|
||||
"worksmobileAliasEmails": []interface{}{"shared@hanmaceng.co.kr"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest(http.MethodPost, "/users/bulk", bytes.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 result map[string]interface{}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
|
||||
results := result["results"].([]interface{})
|
||||
require.Len(t, results, 2)
|
||||
for _, item := range results {
|
||||
row := item.(map[string]interface{})
|
||||
require.False(t, row["success"].(bool))
|
||||
require.Equal(t, "blockingError", row["status"])
|
||||
require.Contains(t, row["message"].(string), "duplicate email")
|
||||
}
|
||||
mockOry.AssertExpectations(t)
|
||||
mockOry.AssertNotCalled(t, "CreateUser", mock.Anything, mock.Anything)
|
||||
mockTenant.AssertNotCalled(t, "GetTenantBySlug", mock.Anything, mock.Anything)
|
||||
}
|
||||
|
||||
func TestUserHandler_BulkCreateUsersRejectsPrimaryEmailUsedAsSubEmail(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).Once()
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"users": []map[string]interface{}{
|
||||
{
|
||||
"email": "user1@samaneng.com",
|
||||
"name": "User One",
|
||||
"tenantSlug": "rnd-saman",
|
||||
"metadata": map[string]interface{}{
|
||||
"sub_email": []interface{}{"user2@samaneng.com"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"email": "user2@samaneng.com",
|
||||
"name": "User Two",
|
||||
"tenantSlug": "rnd-saman",
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest(http.MethodPost, "/users/bulk", bytes.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 result map[string]interface{}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
|
||||
results := result["results"].([]interface{})
|
||||
require.Len(t, results, 2)
|
||||
for _, item := range results {
|
||||
row := item.(map[string]interface{})
|
||||
require.False(t, row["success"].(bool))
|
||||
require.Equal(t, "blockingError", row["status"])
|
||||
require.Contains(t, row["message"].(string), "duplicate email")
|
||||
}
|
||||
mockOry.AssertExpectations(t)
|
||||
mockOry.AssertNotCalled(t, "CreateUser", mock.Anything, mock.Anything)
|
||||
mockTenant.AssertNotCalled(t, "GetTenantBySlug", mock.Anything, mock.Anything)
|
||||
}
|
||||
|
||||
func TestUserHandler_BulkCreateUsers_ResolvesAdditionalAppointment(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
@@ -1429,6 +1594,138 @@ func TestUserHandler_UpdateUser_RejectsDeprecatedAdminRoles(t *testing.T) {
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUser_AllowsSuperAdminEmailChange(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
h := &UserHandler{KratosAdmin: mockKratos}
|
||||
app.Put("/users/:id", func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin-1", Role: domain.RoleSuperAdmin})
|
||||
return h.UpdateUser(c)
|
||||
})
|
||||
|
||||
userID := "u-1"
|
||||
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
|
||||
ID: userID,
|
||||
Traits: map[string]interface{}{
|
||||
"email": "old@example.com",
|
||||
"name": "사용자",
|
||||
"role": domain.RoleUser,
|
||||
},
|
||||
State: "active",
|
||||
}, nil).Once()
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool {
|
||||
return traits["email"] == "new@example.com"
|
||||
}), "").Return(&service.KratosIdentity{
|
||||
ID: userID,
|
||||
Traits: map[string]interface{}{
|
||||
"email": "new@example.com",
|
||||
"name": "사용자",
|
||||
"role": domain.RoleUser,
|
||||
},
|
||||
State: "active",
|
||||
}, nil).Once()
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"email": "new@example.com"})
|
||||
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.StatusOK, resp.StatusCode)
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUserClearsWorksmobileAliasMetadataWhenSubEmailIsCleared(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
h := &UserHandler{KratosAdmin: mockKratos}
|
||||
app.Put("/users/:id", func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin-1", Role: domain.RoleSuperAdmin})
|
||||
return h.UpdateUser(c)
|
||||
})
|
||||
|
||||
userID := "u-1"
|
||||
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
|
||||
ID: userID,
|
||||
Traits: map[string]interface{}{
|
||||
"email": "user@example.com",
|
||||
"name": "사용자",
|
||||
"role": domain.RoleUser,
|
||||
"sub_email": []interface{}{"alias@hanmaceng.co.kr"},
|
||||
"aliasEmails": []interface{}{"alias@hanmaceng.co.kr"},
|
||||
"secondary_emails": []interface{}{"alias@hanmaceng.co.kr"},
|
||||
"worksmobileAliasEmails": []interface{}{"alias@hanmaceng.co.kr"},
|
||||
},
|
||||
State: "active",
|
||||
}, nil).Once()
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool {
|
||||
for _, key := range []string{"sub_email", "aliasEmails", "secondary_emails", "worksmobileAliasEmails"} {
|
||||
values, ok := traits[key].([]interface{})
|
||||
if !ok || len(values) != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}), "").Return(&service.KratosIdentity{
|
||||
ID: userID,
|
||||
Traits: map[string]interface{}{
|
||||
"email": "user@example.com",
|
||||
"name": "사용자",
|
||||
"role": domain.RoleUser,
|
||||
"sub_email": []interface{}{},
|
||||
"aliasEmails": []interface{}{},
|
||||
"secondary_emails": []interface{}{},
|
||||
"worksmobileAliasEmails": []interface{}{},
|
||||
},
|
||||
State: "active",
|
||||
}, nil).Once()
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"metadata": map[string]interface{}{
|
||||
"sub_email": []interface{}{},
|
||||
},
|
||||
})
|
||||
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.StatusOK, resp.StatusCode)
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUser_RejectsNonSuperAdminEmailChange(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
h := &UserHandler{KratosAdmin: mockKratos}
|
||||
app.Put("/users/:id", func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-2", Role: domain.RoleUser})
|
||||
return h.UpdateUser(c)
|
||||
})
|
||||
|
||||
userID := "u-1"
|
||||
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
|
||||
ID: userID,
|
||||
Traits: map[string]interface{}{
|
||||
"email": "old@example.com",
|
||||
"name": "사용자",
|
||||
"role": domain.RoleUser,
|
||||
},
|
||||
State: "active",
|
||||
}, nil).Once()
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"email": "new@example.com"})
|
||||
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.StatusForbidden, resp.StatusCode)
|
||||
mockKratos.AssertExpectations(t)
|
||||
mockKratos.AssertNotCalled(t, "UpdateIdentity", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
}
|
||||
|
||||
func TestSyncCustomLoginIDs_IgnoresFlatMetadataMaps(t *testing.T) {
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
tenantID := "tenant-uuid"
|
||||
|
||||
@@ -72,13 +72,30 @@ func (h *WorksmobileHandler) DeleteOrgUnit(c *fiber.Ctx) error {
|
||||
|
||||
func (h *WorksmobileHandler) SyncUser(c *fiber.Ctx) error {
|
||||
userID := strings.TrimSpace(c.Params("userId"))
|
||||
job, err := h.Service.EnqueueUserSync(c.Context(), strings.TrimSpace(c.Params("tenantId")), userID)
|
||||
credentialBatchID, err := parseWorksmobileCredentialBatchID(c)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
job, err := h.Service.EnqueueUserSync(c.Context(), strings.TrimSpace(c.Params("tenantId")), userID, credentialBatchID)
|
||||
if err != nil {
|
||||
return worksmobileGuardError(c, err, "sync_user", "user_id", userID)
|
||||
}
|
||||
return c.Status(fiber.StatusAccepted).JSON(job)
|
||||
}
|
||||
|
||||
func (h *WorksmobileHandler) ResetUserPassword(c *fiber.Ctx) error {
|
||||
userID := strings.TrimSpace(c.Params("userId"))
|
||||
credentialBatchID, err := parseWorksmobileCredentialBatchID(c)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
job, err := h.Service.EnqueueUserPasswordReset(c.Context(), strings.TrimSpace(c.Params("tenantId")), userID, credentialBatchID)
|
||||
if err != nil {
|
||||
return worksmobileGuardError(c, err, "reset_user_password", "user_id", userID)
|
||||
}
|
||||
return c.Status(fiber.StatusAccepted).JSON(job)
|
||||
}
|
||||
|
||||
func (h *WorksmobileHandler) RetryJob(c *fiber.Ctx) error {
|
||||
jobID := strings.TrimSpace(c.Params("jobId"))
|
||||
job, err := h.Service.RetryJob(c.Context(), strings.TrimSpace(c.Params("tenantId")), jobID)
|
||||
@@ -89,18 +106,18 @@ func (h *WorksmobileHandler) RetryJob(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
func (h *WorksmobileHandler) DownloadInitialPasswordsCSV(c *fiber.Ctx) error {
|
||||
credentials, err := h.Service.ListInitialPasswordCredentials(c.Context(), strings.TrimSpace(c.Params("tenantId")))
|
||||
credentials, err := h.Service.ListInitialPasswordCredentials(c.Context(), strings.TrimSpace(c.Params("tenantId")), strings.TrimSpace(c.Query("batchId")))
|
||||
if err != nil {
|
||||
return worksmobileGuardError(c, err, "download_initial_passwords")
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer := csv.NewWriter(&buf)
|
||||
if err := writer.Write([]string{"email", "initialPassword", "status", "lastError"}); err != nil {
|
||||
if err := writer.Write([]string{"email", "name", "primaryLeafOrgName", "initialPassword", "status", "lastError"}); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
for _, credential := range credentials {
|
||||
if err := writer.Write([]string{credential.Email, credential.InitialPassword, credential.Status, credential.LastError}); err != nil {
|
||||
if err := writer.Write([]string{credential.Email, credential.Name, credential.PrimaryLeafOrgName, credential.InitialPassword, credential.Status, credential.LastError}); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
@@ -114,6 +131,42 @@ func (h *WorksmobileHandler) DownloadInitialPasswordsCSV(c *fiber.Ctx) error {
|
||||
return c.Send(buf.Bytes())
|
||||
}
|
||||
|
||||
func (h *WorksmobileHandler) ListCredentialBatches(c *fiber.Ctx) error {
|
||||
batches, err := h.Service.ListCredentialBatches(c.Context(), strings.TrimSpace(c.Params("tenantId")))
|
||||
if err != nil {
|
||||
return worksmobileGuardError(c, err, "list_credential_batches")
|
||||
}
|
||||
return c.JSON(batches)
|
||||
}
|
||||
|
||||
func (h *WorksmobileHandler) DeleteCredentialBatchPasswords(c *fiber.Ctx) error {
|
||||
batchID := strings.TrimSpace(c.Params("batchId"))
|
||||
batch, err := h.Service.DeleteCredentialBatchPasswords(c.Context(), strings.TrimSpace(c.Params("tenantId")), batchID)
|
||||
if err != nil {
|
||||
return worksmobileGuardError(c, err, "delete_credential_batch_passwords", "batch_id", batchID)
|
||||
}
|
||||
return c.JSON(batch)
|
||||
}
|
||||
|
||||
type worksmobileCredentialBatchRequest struct {
|
||||
CredentialBatchID string `json:"credentialBatchId"`
|
||||
}
|
||||
|
||||
func parseWorksmobileCredentialBatchID(c *fiber.Ctx) (string, error) {
|
||||
batchID := strings.TrimSpace(c.Query("credentialBatchId"))
|
||||
if len(bytes.TrimSpace(c.Body())) == 0 {
|
||||
return batchID, nil
|
||||
}
|
||||
var req worksmobileCredentialBatchRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if bodyBatchID := strings.TrimSpace(req.CredentialBatchID); bodyBatchID != "" {
|
||||
return bodyBatchID, nil
|
||||
}
|
||||
return batchID, nil
|
||||
}
|
||||
|
||||
func worksmobileOverviewAllowed(overview service.WorksmobileTenantOverview) bool {
|
||||
return overview.Tenant.Slug == service.HanmacFamilyTenantSlug && overview.Tenant.ParentID == nil
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
@@ -51,7 +52,13 @@ func TestWorksmobileHandlerReturnsOverviewForHanmacTenant(t *testing.T) {
|
||||
func TestWorksmobileHandlerDownloadsInitialPasswordCSV(t *testing.T) {
|
||||
h := NewWorksmobileHandler(&fakeWorksmobileAdminService{
|
||||
credentials: []service.WorksmobileInitialPasswordCredential{
|
||||
{Email: "user@hanmaceng.co.kr", InitialPassword: "Aa1!Aa1!Aa1!Aa1!", Status: "processed"},
|
||||
{
|
||||
Email: "user@hanmaceng.co.kr",
|
||||
Name: "홍길동",
|
||||
PrimaryLeafOrgName: "인재성장",
|
||||
InitialPassword: "Aa1!Aa1!Aa1!Aa1!",
|
||||
Status: "processed",
|
||||
},
|
||||
},
|
||||
})
|
||||
app := fiber.New()
|
||||
@@ -63,8 +70,87 @@ func TestWorksmobileHandlerDownloadsInitialPasswordCSV(t *testing.T) {
|
||||
require.Contains(t, resp.Header.Get("Content-Disposition"), "worksmobile_initial_passwords.csv")
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(body), "email,initialPassword,status,lastError")
|
||||
require.Contains(t, string(body), "user@hanmaceng.co.kr,Aa1!Aa1!Aa1!Aa1!,processed,")
|
||||
require.Contains(t, string(body), "email,name,primaryLeafOrgName,initialPassword,status,lastError")
|
||||
require.Contains(t, string(body), "user@hanmaceng.co.kr,홍길동,인재성장,Aa1!Aa1!Aa1!Aa1!,processed,")
|
||||
}
|
||||
|
||||
func TestWorksmobileHandlerPassesInitialPasswordBatchID(t *testing.T) {
|
||||
fakeService := &fakeWorksmobileAdminService{
|
||||
credentials: []service.WorksmobileInitialPasswordCredential{
|
||||
{Email: "batch-user@hanmaceng.co.kr", InitialPassword: "BatchPass1!", Status: "pending"},
|
||||
},
|
||||
}
|
||||
h := NewWorksmobileHandler(fakeService)
|
||||
app := fiber.New()
|
||||
app.Get("/tenants/:tenantId/worksmobile/initial-passwords.csv", h.DownloadInitialPasswordsCSV)
|
||||
|
||||
resp, err := app.Test(httptest.NewRequest("GET", "/tenants/hanmac-id/worksmobile/initial-passwords.csv?batchId=batch-1", nil))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fiber.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, "batch-1", fakeService.downloadCredentialBatchID)
|
||||
}
|
||||
|
||||
func TestWorksmobileHandlerPassesSyncUserCredentialBatchID(t *testing.T) {
|
||||
fakeService := &fakeWorksmobileAdminService{}
|
||||
h := NewWorksmobileHandler(fakeService)
|
||||
app := fiber.New()
|
||||
app.Post("/tenants/:tenantId/worksmobile/users/:userId/sync", h.SyncUser)
|
||||
|
||||
req := httptest.NewRequest("POST", "/tenants/hanmac-id/worksmobile/users/user-1/sync", strings.NewReader(`{"credentialBatchId":"batch-1"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fiber.StatusAccepted, resp.StatusCode)
|
||||
require.Equal(t, "batch-1", fakeService.syncUserCredentialBatchID)
|
||||
}
|
||||
|
||||
func TestWorksmobileHandlerPassesPasswordResetCredentialBatchID(t *testing.T) {
|
||||
fakeService := &fakeWorksmobileAdminService{}
|
||||
h := NewWorksmobileHandler(fakeService)
|
||||
app := fiber.New()
|
||||
app.Post("/tenants/:tenantId/worksmobile/users/:userId/password/reset", h.ResetUserPassword)
|
||||
|
||||
req := httptest.NewRequest("POST", "/tenants/hanmac-id/worksmobile/users/user-1/password/reset", strings.NewReader(`{"credentialBatchId":"batch-1"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fiber.StatusAccepted, resp.StatusCode)
|
||||
require.Equal(t, "batch-1", fakeService.resetPasswordCredentialBatchID)
|
||||
}
|
||||
|
||||
func TestWorksmobileHandlerReturnsCredentialBatchHistory(t *testing.T) {
|
||||
h := NewWorksmobileHandler(&fakeWorksmobileAdminService{
|
||||
credentialBatches: []service.WorksmobileCredentialBatch{
|
||||
{BatchID: "batch-1", UserCount: 2, HasPasswords: true},
|
||||
},
|
||||
})
|
||||
app := fiber.New()
|
||||
app.Get("/tenants/:tenantId/worksmobile/credential-batches", h.ListCredentialBatches)
|
||||
|
||||
resp, err := app.Test(httptest.NewRequest("GET", "/tenants/hanmac-id/worksmobile/credential-batches", nil))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fiber.StatusOK, resp.StatusCode)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(body), `"batchId":"batch-1"`)
|
||||
require.Contains(t, string(body), `"userCount":2`)
|
||||
}
|
||||
|
||||
func TestWorksmobileHandlerDeletesCredentialBatchPasswords(t *testing.T) {
|
||||
fakeService := &fakeWorksmobileAdminService{}
|
||||
h := NewWorksmobileHandler(fakeService)
|
||||
app := fiber.New()
|
||||
app.Delete("/tenants/:tenantId/worksmobile/credential-batches/:batchId/passwords", h.DeleteCredentialBatchPasswords)
|
||||
|
||||
resp, err := app.Test(httptest.NewRequest("DELETE", "/tenants/hanmac-id/worksmobile/credential-batches/batch-1/passwords", nil))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fiber.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, "batch-1", fakeService.deletedCredentialBatchID)
|
||||
}
|
||||
|
||||
func TestWorksmobileHandlerLogsActionFailures(t *testing.T) {
|
||||
@@ -91,9 +177,14 @@ func TestWorksmobileHandlerLogsActionFailures(t *testing.T) {
|
||||
}
|
||||
|
||||
type fakeWorksmobileAdminService struct {
|
||||
overview service.WorksmobileTenantOverview
|
||||
credentials []service.WorksmobileInitialPasswordCredential
|
||||
syncUserErr error
|
||||
overview service.WorksmobileTenantOverview
|
||||
credentials []service.WorksmobileInitialPasswordCredential
|
||||
syncUserErr error
|
||||
syncUserCredentialBatchID string
|
||||
resetPasswordCredentialBatchID string
|
||||
downloadCredentialBatchID string
|
||||
deletedCredentialBatchID string
|
||||
credentialBatches []service.WorksmobileCredentialBatch
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileAdminService) GetTenantOverview(ctx context.Context, tenantID string) (service.WorksmobileTenantOverview, error) {
|
||||
@@ -116,17 +207,33 @@ func (f *fakeWorksmobileAdminService) EnqueueOrgUnitDelete(ctx context.Context,
|
||||
return &domain.WorksmobileOutbox{ID: "job-orgunit-delete", ResourceID: orgUnitID, Action: domain.WorksmobileActionDelete}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileAdminService) EnqueueUserSync(ctx context.Context, tenantID, userID string) (*domain.WorksmobileOutbox, error) {
|
||||
func (f *fakeWorksmobileAdminService) EnqueueUserSync(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) {
|
||||
f.syncUserCredentialBatchID = credentialBatchID
|
||||
if f.syncUserErr != nil {
|
||||
return nil, f.syncUserErr
|
||||
}
|
||||
return &domain.WorksmobileOutbox{ID: "job-user", ResourceID: userID}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileAdminService) EnqueueUserPasswordReset(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) {
|
||||
f.resetPasswordCredentialBatchID = credentialBatchID
|
||||
return &domain.WorksmobileOutbox{ID: "job-user-password-reset", ResourceID: userID}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileAdminService) RetryJob(ctx context.Context, tenantID, jobID string) (*domain.WorksmobileOutbox, error) {
|
||||
return &domain.WorksmobileOutbox{ID: jobID}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileAdminService) ListInitialPasswordCredentials(ctx context.Context, tenantID string) ([]service.WorksmobileInitialPasswordCredential, error) {
|
||||
func (f *fakeWorksmobileAdminService) ListInitialPasswordCredentials(ctx context.Context, tenantID, credentialBatchID string) ([]service.WorksmobileInitialPasswordCredential, error) {
|
||||
f.downloadCredentialBatchID = credentialBatchID
|
||||
return f.credentials, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileAdminService) ListCredentialBatches(ctx context.Context, tenantID string) ([]service.WorksmobileCredentialBatch, error) {
|
||||
return f.credentialBatches, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileAdminService) DeleteCredentialBatchPasswords(ctx context.Context, tenantID, credentialBatchID string) (service.WorksmobileCredentialBatch, error) {
|
||||
f.deletedCredentialBatchID = credentialBatchID
|
||||
return service.WorksmobileCredentialBatch{BatchID: credentialBatchID}, nil
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
type WorksmobileOutboxRepository interface {
|
||||
Create(ctx context.Context, item *domain.WorksmobileOutbox) error
|
||||
ListRecent(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error)
|
||||
ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error)
|
||||
UpdatePayload(ctx context.Context, id string, payload domain.JSONMap) error
|
||||
ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error)
|
||||
FindByID(ctx context.Context, id string) (*domain.WorksmobileOutbox, error)
|
||||
MarkRetry(ctx context.Context, id string) error
|
||||
@@ -56,6 +58,24 @@ func (r *worksmobileOutboxRepository) ListRecent(ctx context.Context, limit int)
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (r *worksmobileOutboxRepository) ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error) {
|
||||
query := r.db.WithContext(ctx).
|
||||
Where("resource_type = ? AND payload ->> 'tenantRootId' = ? AND coalesce(payload ->> 'credentialBatchId', '') <> ?", domain.WorksmobileResourceUser, tenantRootID, "")
|
||||
if credentialBatchID != "" {
|
||||
query = query.Where("payload ->> 'credentialBatchId' = ?", credentialBatchID)
|
||||
}
|
||||
var rows []domain.WorksmobileOutbox
|
||||
err := query.Order("created_at desc").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (r *worksmobileOutboxRepository) UpdatePayload(ctx context.Context, id string, payload domain.JSONMap) error {
|
||||
return r.db.WithContext(ctx).Model(&domain.WorksmobileOutbox{}).Where("id = ?", id).Updates(map[string]any{
|
||||
"payload": payload,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *worksmobileOutboxRepository) ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
|
||||
@@ -290,6 +290,9 @@ func (s *kratosAdminService) CreateUser(ctx context.Context, user *domain.Broker
|
||||
},
|
||||
"state": "active",
|
||||
}
|
||||
if requestedID := strings.TrimSpace(user.ID); requestedID != "" {
|
||||
payload["id"] = requestedID
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
endpoint := strings.TrimRight(s.AdminURL, "/") + "/admin/identities"
|
||||
@@ -316,6 +319,9 @@ func (s *kratosAdminService) CreateUser(ctx context.Context, user *domain.Broker
|
||||
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if requestedID := strings.TrimSpace(user.ID); requestedID != "" && created.ID != requestedID {
|
||||
return "", fmt.Errorf("kratos admin: requested identity id was not preserved requested=%s actual=%s", requestedID, created.ID)
|
||||
}
|
||||
|
||||
return created.ID, nil
|
||||
}
|
||||
|
||||
@@ -134,6 +134,9 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
|
||||
},
|
||||
},
|
||||
}
|
||||
if requestedID := strings.TrimSpace(user.ID); requestedID != "" {
|
||||
payload["id"] = requestedID
|
||||
}
|
||||
verifiable := []map[string]interface{}{
|
||||
{
|
||||
"value": user.Email,
|
||||
@@ -179,6 +182,9 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
|
||||
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
|
||||
return "", fmt.Errorf("ory provider: decode create identity response failed: %w", err)
|
||||
}
|
||||
if requestedID := strings.TrimSpace(user.ID); requestedID != "" && created.ID != requestedID {
|
||||
return "", fmt.Errorf("ory provider: requested identity id was not preserved requested=%s actual=%s", requestedID, created.ID)
|
||||
}
|
||||
|
||||
slog.Info("Ory identity created", "identity_id", created.ID, "email", user.Email)
|
||||
return created.ID, nil
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
@@ -35,6 +36,76 @@ type roundTripperFunc func(req *http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
|
||||
|
||||
func TestCreateUserSendsRequestedIdentityID(t *testing.T) {
|
||||
const requestedID = "9f8cc1b1-af8d-45d4-946c-924a529c2556"
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/admin/identities" && r.Method == http.MethodGet:
|
||||
_ = json.NewEncoder(w).Encode([]map[string]string{})
|
||||
return
|
||||
case r.URL.Path == "/admin/identities" && r.Method == http.MethodPost:
|
||||
var payload map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("failed to decode payload: %v", err)
|
||||
}
|
||||
if payload["id"] != requestedID {
|
||||
t.Fatalf("expected id=%s, got=%v", requestedID, payload["id"])
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"id": requestedID})
|
||||
return
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
|
||||
}
|
||||
})
|
||||
|
||||
provider := &OryProvider{
|
||||
KratosAdminURL: "http://kratos-admin.local",
|
||||
HTTPClient: clientForHandler(handler),
|
||||
}
|
||||
|
||||
id, err := provider.CreateUser(&domain.BrokerUser{
|
||||
ID: requestedID,
|
||||
Email: "restore@test.com",
|
||||
Name: "Restore User",
|
||||
}, "Sup3rStr0ng!Pass#2026")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser returned error: %v", err)
|
||||
}
|
||||
if id != requestedID {
|
||||
t.Fatalf("expected %s, got %s", requestedID, id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUserRejectsRequestedIdentityIDMismatch(t *testing.T) {
|
||||
const requestedID = "9f8cc1b1-af8d-45d4-946c-924a529c2556"
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/admin/identities" && r.Method == http.MethodGet:
|
||||
_ = json.NewEncoder(w).Encode([]map[string]string{})
|
||||
return
|
||||
case r.URL.Path == "/admin/identities" && r.Method == http.MethodPost:
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"id": "generated-id"})
|
||||
return
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
|
||||
}
|
||||
})
|
||||
|
||||
provider := &OryProvider{
|
||||
KratosAdminURL: "http://kratos-admin.local",
|
||||
HTTPClient: clientForHandler(handler),
|
||||
}
|
||||
|
||||
_, err := provider.CreateUser(&domain.BrokerUser{
|
||||
ID: requestedID,
|
||||
Email: "restore@test.com",
|
||||
Name: "Restore User",
|
||||
}, "Sup3rStr0ng!Pass#2026")
|
||||
if err == nil || !strings.Contains(err.Error(), "requested identity id was not preserved") {
|
||||
t.Fatalf("expected requested identity id mismatch error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserPassword_Success(t *testing.T) {
|
||||
const (
|
||||
loginID = "user@example.com"
|
||||
|
||||
@@ -30,6 +30,8 @@ type WorksmobileDirectoryClient interface {
|
||||
DeleteOrgUnit(ctx context.Context, orgUnitID string) error
|
||||
CreateUser(ctx context.Context, payload WorksmobileUserPayload) error
|
||||
UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error
|
||||
AddUserAliasEmail(ctx context.Context, userID string, email string) error
|
||||
ResetUserPassword(ctx context.Context, userID string, password string) error
|
||||
DeleteUser(ctx context.Context, userID string) error
|
||||
SetUserActive(ctx context.Context, userID string, active bool) error
|
||||
ListUsers(ctx context.Context) ([]WorksmobileRemoteUser, error)
|
||||
@@ -283,6 +285,45 @@ func (c *WorksmobileHTTPClient) UpsertUser(ctx context.Context, payload Worksmob
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) AddUserAliasEmail(ctx context.Context, userID string, email string) error {
|
||||
userID = strings.TrimSpace(userID)
|
||||
email = strings.TrimSpace(email)
|
||||
if userID == "" {
|
||||
return fmt.Errorf("worksmobile user id is required")
|
||||
}
|
||||
if email == "" {
|
||||
return fmt.Errorf("worksmobile alias email is required")
|
||||
}
|
||||
err := c.sendDirectoryJSON(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
"/v1.0/users/"+url.PathEscape(userID)+"/alias-emails/"+url.PathEscape(email),
|
||||
nil,
|
||||
)
|
||||
if apiErr, ok := err.(WorksmobileHTTPError); ok && apiErr.StatusCode == http.StatusConflict {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) ResetUserPassword(ctx context.Context, userID string, password string) error {
|
||||
userID = strings.TrimSpace(userID)
|
||||
password = strings.TrimSpace(password)
|
||||
if userID == "" {
|
||||
return fmt.Errorf("worksmobile user id is required")
|
||||
}
|
||||
if password == "" {
|
||||
return fmt.Errorf("worksmobile password is required")
|
||||
}
|
||||
payload := map[string]any{
|
||||
"passwordConfig": WorksmobilePasswordConfig{
|
||||
PasswordCreationType: "ADMIN",
|
||||
Password: password,
|
||||
},
|
||||
}
|
||||
return c.sendDirectoryJSON(ctx, http.MethodPatch, "/v1.0/users/"+url.PathEscape(userID), payload)
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) PatchUser(ctx context.Context, identifier string, payload WorksmobileUserPatchPayload) error {
|
||||
identifier = strings.TrimSpace(identifier)
|
||||
if identifier == "" {
|
||||
@@ -756,22 +797,23 @@ type WorksmobileOrgUnitPatchPayload struct {
|
||||
}
|
||||
|
||||
type WorksmobileRemoteUser struct {
|
||||
ID string `json:"id"`
|
||||
ExternalID string `json:"externalId"`
|
||||
UserName string `json:"userName"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
LevelID string `json:"levelId"`
|
||||
LevelName string `json:"levelName"`
|
||||
Task string `json:"task"`
|
||||
DomainID int64 `json:"domainId"`
|
||||
DomainName string `json:"domainName"`
|
||||
PrimaryOrgUnitID string `json:"primaryOrgUnitId"`
|
||||
PrimaryOrgUnitName string `json:"primaryOrgUnitName"`
|
||||
PrimaryOrgUnitPositionID string `json:"primaryOrgUnitPositionId"`
|
||||
PrimaryOrgUnitPositionName string `json:"primaryOrgUnitPositionName"`
|
||||
PrimaryOrgUnitIsManager *bool `json:"primaryOrgUnitIsManager,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
ID string `json:"id"`
|
||||
ExternalID string `json:"externalId"`
|
||||
UserName string `json:"userName"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
LevelID string `json:"levelId"`
|
||||
LevelName string `json:"levelName"`
|
||||
Task string `json:"task"`
|
||||
DomainID int64 `json:"domainId"`
|
||||
DomainName string `json:"domainName"`
|
||||
PrimaryOrgUnitID string `json:"primaryOrgUnitId"`
|
||||
PrimaryOrgUnitName string `json:"primaryOrgUnitName"`
|
||||
PrimaryOrgUnitPositionID string `json:"primaryOrgUnitPositionId"`
|
||||
PrimaryOrgUnitPositionName string `json:"primaryOrgUnitPositionName"`
|
||||
PrimaryOrgUnitIsManager *bool `json:"primaryOrgUnitIsManager,omitempty"`
|
||||
OrgUnitManagers map[string]*bool `json:"orgUnitManagers,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
type WorksmobileRemoteGroup struct {
|
||||
@@ -907,6 +949,7 @@ func parseWorksmobileDirectoryUser(resource map[string]any) WorksmobileRemoteUse
|
||||
user.PrimaryOrgUnitPositionID = primaryOrgUnit.PositionID
|
||||
user.PrimaryOrgUnitPositionName = primaryOrgUnit.PositionName
|
||||
user.PrimaryOrgUnitIsManager = primaryOrgUnit.IsManager
|
||||
user.OrgUnitManagers = parseWorksmobileOrgUnitManagers(resource)
|
||||
return user
|
||||
}
|
||||
|
||||
@@ -1029,6 +1072,43 @@ func parseWorksmobilePrimaryOrgUnitDetail(resource map[string]any) worksmobileOr
|
||||
return worksmobileOrgUnitDetail{}
|
||||
}
|
||||
|
||||
func parseWorksmobileOrgUnitManagers(resource map[string]any) map[string]*bool {
|
||||
result := map[string]*bool{}
|
||||
collectWorksmobileOrgUnitManagers(resource["organizations"], result)
|
||||
collectWorksmobileOrgUnitManagers(resource["orgUnits"], result)
|
||||
for key, raw := range resource {
|
||||
if !strings.Contains(strings.ToLower(key), "works") {
|
||||
continue
|
||||
}
|
||||
if values, ok := raw.(map[string]any); ok {
|
||||
collectWorksmobileOrgUnitManagers(values["organizations"], result)
|
||||
collectWorksmobileOrgUnitManagers(values["orgUnits"], result)
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func collectWorksmobileOrgUnitManagers(raw any, result map[string]*bool) {
|
||||
values, ok := raw.([]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, item := range values {
|
||||
orgUnit, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if id := firstStringFromMap(orgUnit, "orgUnitId", "id", "value"); id != "" {
|
||||
result[id] = boolPointerFromMap(orgUnit, "isManager", "manager")
|
||||
}
|
||||
collectWorksmobileOrgUnitManagers(orgUnit["organizations"], result)
|
||||
collectWorksmobileOrgUnitManagers(orgUnit["orgUnits"], result)
|
||||
}
|
||||
}
|
||||
|
||||
func parseWorksmobileParentOrgUnit(resource map[string]any) (string, string) {
|
||||
id := firstStringFromMap(resource, "parentOrgUnitId", "parentId")
|
||||
name := firstStringFromMap(resource, "parentOrgUnitName", "parentName")
|
||||
|
||||
@@ -113,6 +113,50 @@ func TestWorksmobileHTTPClientUpsertUserPatchesOnCreateConflictWithoutPasswordOr
|
||||
require.Equal(t, "user-1", patchPayload["userExternalKey"])
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientAddUserAliasEmailPostsDirectoryAliasEndpoint(t *testing.T) {
|
||||
transport := &captureRoundTripper{
|
||||
statusCode: http.StatusCreated,
|
||||
body: `{}`,
|
||||
}
|
||||
client := &WorksmobileHTTPClient{
|
||||
BaseURL: "https://works.example.test",
|
||||
DirectoryToken: "directory-token-1",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
}
|
||||
|
||||
err := client.AddUserAliasEmail(context.Background(), "ypshim@samaneng.com", "ypshim@hanmaceng.co.kr")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, transport.request)
|
||||
require.Equal(t, http.MethodPost, transport.request.Method)
|
||||
require.Equal(t, "/v1.0/users/ypshim@samaneng.com/alias-emails/ypshim@hanmaceng.co.kr", transport.request.URL.Path)
|
||||
require.Equal(t, "Bearer directory-token-1", transport.request.Header.Get("Authorization"))
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientResetUserPasswordPatchesPasswordConfig(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.ResetUserPassword(context.Background(), "target@samaneng.com", "Aa1!Aa1!Aa1!Aa1!")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, transport.request)
|
||||
require.Equal(t, http.MethodPatch, transport.request.Method)
|
||||
require.Equal(t, "/v1.0/users/target@samaneng.com", transport.request.URL.Path)
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(transport.requestBody, &payload))
|
||||
passwordConfig := payload["passwordConfig"].(map[string]any)
|
||||
require.Equal(t, "ADMIN", passwordConfig["passwordCreationType"])
|
||||
require.Equal(t, "Aa1!Aa1!Aa1!Aa1!", passwordConfig["password"])
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientCreateUserRequiresDirectoryToken(t *testing.T) {
|
||||
client := &WorksmobileHTTPClient{
|
||||
BaseURL: "https://works.example.test",
|
||||
@@ -472,6 +516,71 @@ func TestWorksmobileRelayWorkerProcessesUserCreateAndMarksProcessed(t *testing.T
|
||||
require.Equal(t, "tester@samaneng.com", client.createdUsers[0].Email)
|
||||
}
|
||||
|
||||
func TestWorksmobileRelayWorkerRegistersAliasEmailsAfterUserUpsert(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: "ypshim@samaneng.com",
|
||||
UserExternalKey: "user-1",
|
||||
AliasEmails: []string{"ypshim@hanmaceng.co.kr"},
|
||||
PasswordConfig: WorksmobilePasswordConfig{
|
||||
PasswordCreationType: "ADMIN",
|
||||
Password: "Aa1!Aa1!Aa1!Aa1!",
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
client := &fakeWorksmobileDirectoryClient{}
|
||||
worker := NewWorksmobileRelayWorker(repo, client)
|
||||
|
||||
err := worker.ProcessOnce(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"job-1"}, repo.processedIDs)
|
||||
require.Equal(t, "ypshim@samaneng.com", client.createdUsers[0].Email)
|
||||
require.Empty(t, client.createdUsers[0].AliasEmails)
|
||||
require.Equal(t, []string{"ypshim@samaneng.com:ypshim@hanmaceng.co.kr"}, client.aliasEmails)
|
||||
}
|
||||
|
||||
func TestWorksmobileRelayWorkerProcessesUserPasswordResetAndMarksProcessed(t *testing.T) {
|
||||
repo := &fakeWorksmobileOutboxRepo{
|
||||
ready: []domain.WorksmobileOutbox{
|
||||
{
|
||||
ID: "job-reset",
|
||||
ResourceType: domain.WorksmobileResourceUser,
|
||||
ResourceID: "user-1",
|
||||
Action: domain.WorksmobileActionPasswordReset,
|
||||
Status: domain.WorksmobileOutboxStatusPending,
|
||||
Payload: domain.JSONMap{
|
||||
"loginEmail": "target@samaneng.com",
|
||||
"request": map[string]any{
|
||||
"email": "target@samaneng.com",
|
||||
"passwordConfig": map[string]any{
|
||||
"passwordCreationType": "ADMIN",
|
||||
"password": "Aa1!Aa1!Aa1!Aa1!",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
client := &fakeWorksmobileDirectoryClient{}
|
||||
worker := NewWorksmobileRelayWorker(repo, client)
|
||||
|
||||
err := worker.ProcessOnce(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"job-reset"}, repo.processedIDs)
|
||||
require.Equal(t, []string{"target@samaneng.com:Aa1!Aa1!Aa1!Aa1!"}, client.passwordResets)
|
||||
}
|
||||
|
||||
func TestWorksmobileRelayWorkerProcessesUserSuspendAndMarksProcessed(t *testing.T) {
|
||||
repo := &fakeWorksmobileOutboxRepo{
|
||||
ready: []domain.WorksmobileOutbox{
|
||||
@@ -615,7 +724,7 @@ func TestCompareWorksmobileUsersIncludesBaronAndWorksPrimaryOrg(t *testing.T) {
|
||||
require.Equal(t, "WORKS 기술기획", items[0].WorksmobilePrimaryOrgName)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersMatchesByEmailWhenDirectoryAPIOmitsExternalID(t *testing.T) {
|
||||
func TestCompareWorksmobileUsersMarksEmailMatchWithoutExternalIDNeedsUpdate(t *testing.T) {
|
||||
localUsers := []domain.User{
|
||||
{ID: "user-1", Email: "tester@samaneng.com", Name: "Tester"},
|
||||
}
|
||||
@@ -626,13 +735,37 @@ func TestCompareWorksmobileUsersMatchesByEmailWhenDirectoryAPIOmitsExternalID(t
|
||||
diffOnly := compareWorksmobileUsers(localUsers, remoteUsers, false, nil)
|
||||
all := compareWorksmobileUsers(localUsers, remoteUsers, true, nil)
|
||||
|
||||
require.Empty(t, diffOnly)
|
||||
require.Len(t, diffOnly, 1)
|
||||
require.Equal(t, "needs_update", diffOnly[0].Status)
|
||||
require.Len(t, all, 1)
|
||||
require.Equal(t, "matched", all[0].Status)
|
||||
require.Equal(t, "needs_update", all[0].Status)
|
||||
require.Equal(t, "works-1", all[0].WorksmobileID)
|
||||
require.Empty(t, all[0].ExternalKey)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersIncludesRecentFailedJobForMissingUser(t *testing.T) {
|
||||
localUsers := []domain.User{
|
||||
{ID: "user-1", Email: "missing@samaneng.com", Name: "Missing"},
|
||||
}
|
||||
jobSummaries := map[string]worksmobileUserJobSummary{
|
||||
"user-1": {
|
||||
Status: domain.WorksmobileOutboxStatusFailed,
|
||||
RetryCount: 3,
|
||||
LastError: "worksmobile api failed",
|
||||
LastAttemptAt: "2026-06-01T05:00:00Z",
|
||||
},
|
||||
}
|
||||
|
||||
items := compareWorksmobileUsers(localUsers, nil, false, nil, jobSummaries)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "missing_in_worksmobile", items[0].Status)
|
||||
require.Equal(t, domain.WorksmobileOutboxStatusFailed, items[0].WorksmobileJobStatus)
|
||||
require.Equal(t, 3, items[0].WorksmobileJobRetryCount)
|
||||
require.Equal(t, "worksmobile api failed", items[0].WorksmobileLastError)
|
||||
require.Equal(t, "2026-06-01T05:00:00Z", items[0].WorksmobileLastAttemptAt)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersIncludesWorksOnlyRowsWithoutExternalIDWhenIncludingMatched(t *testing.T) {
|
||||
remoteUsers := []WorksmobileRemoteUser{
|
||||
{ID: "works-1", ExternalID: "", Email: "works-only@samaneng.com", DisplayName: "Works Only"},
|
||||
@@ -894,6 +1027,41 @@ func TestParseWorksmobileDirectoryUserIncludesFullNameLevelAndOrgRole(t *testing
|
||||
require.Equal(t, "팀장", user.PrimaryOrgUnitPositionName)
|
||||
require.NotNil(t, user.PrimaryOrgUnitIsManager)
|
||||
require.True(t, *user.PrimaryOrgUnitIsManager)
|
||||
require.NotNil(t, user.OrgUnitManagers["works-org-1"])
|
||||
require.True(t, *user.OrgUnitManagers["works-org-1"])
|
||||
}
|
||||
|
||||
func TestParseWorksmobileDirectoryUserIncludesAllOrgUnitManagerFlags(t *testing.T) {
|
||||
user := parseWorksmobileDirectoryUser(map[string]any{
|
||||
"userId": "works-user",
|
||||
"email": "tester@samaneng.com",
|
||||
"userName": map[string]any{
|
||||
"lastName": "홍길동",
|
||||
},
|
||||
"organizations": []any{
|
||||
map[string]any{
|
||||
"primary": true,
|
||||
"orgUnits": []any{
|
||||
map[string]any{
|
||||
"orgUnitId": "externalKey:primary-org",
|
||||
"primary": true,
|
||||
"isManager": false,
|
||||
},
|
||||
map[string]any{
|
||||
"orgUnitId": "externalKey:secondary-org",
|
||||
"primary": false,
|
||||
"isManager": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
require.Len(t, user.OrgUnitManagers, 2)
|
||||
require.NotNil(t, user.OrgUnitManagers["externalKey:primary-org"])
|
||||
require.False(t, *user.OrgUnitManagers["externalKey:primary-org"])
|
||||
require.NotNil(t, user.OrgUnitManagers["externalKey:secondary-org"])
|
||||
require.True(t, *user.OrgUnitManagers["externalKey:secondary-org"])
|
||||
}
|
||||
|
||||
func TestParseWorksmobileDirectoryGroupExtractsMailLocalPart(t *testing.T) {
|
||||
@@ -908,11 +1076,14 @@ func TestParseWorksmobileDirectoryGroupExtractsMailLocalPart(t *testing.T) {
|
||||
}
|
||||
|
||||
type fakeWorksmobileOutboxRepo struct {
|
||||
ready []domain.WorksmobileOutbox
|
||||
created []domain.WorksmobileOutbox
|
||||
processingIDs []string
|
||||
processedIDs []string
|
||||
failedIDs []string
|
||||
recent []domain.WorksmobileOutbox
|
||||
ready []domain.WorksmobileOutbox
|
||||
created []domain.WorksmobileOutbox
|
||||
credentialBatchJobs []domain.WorksmobileOutbox
|
||||
payloadUpdates []domain.JSONMap
|
||||
processingIDs []string
|
||||
processedIDs []string
|
||||
failedIDs []string
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileOutboxRepo) Create(ctx context.Context, item *domain.WorksmobileOutbox) error {
|
||||
@@ -921,7 +1092,31 @@ func (f *fakeWorksmobileOutboxRepo) Create(ctx context.Context, item *domain.Wor
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileOutboxRepo) ListRecent(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) {
|
||||
return nil, nil
|
||||
return f.recent, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileOutboxRepo) ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error) {
|
||||
rows := make([]domain.WorksmobileOutbox, 0)
|
||||
for _, row := range f.credentialBatchJobs {
|
||||
if stringValue(row.Payload["tenantRootId"]) != tenantRootID {
|
||||
continue
|
||||
}
|
||||
if credentialBatchID != "" && stringValue(row.Payload["credentialBatchId"]) != credentialBatchID {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileOutboxRepo) UpdatePayload(ctx context.Context, id string, payload domain.JSONMap) error {
|
||||
f.payloadUpdates = append(f.payloadUpdates, payload)
|
||||
for i := range f.credentialBatchJobs {
|
||||
if f.credentialBatchJobs[i].ID == id {
|
||||
f.credentialBatchJobs[i].Payload = payload
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileOutboxRepo) ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) {
|
||||
@@ -958,6 +1153,8 @@ type fakeWorksmobileDirectoryClient struct {
|
||||
deletedUsers []string
|
||||
activeUsers []string
|
||||
suspendedUsers []string
|
||||
aliasEmails []string
|
||||
passwordResets []string
|
||||
users []WorksmobileRemoteUser
|
||||
orgUnitMatchKeys []string
|
||||
groups []WorksmobileRemoteGroup
|
||||
@@ -1062,6 +1259,16 @@ func (f *fakeWorksmobileDirectoryClient) UpsertUser(ctx context.Context, payload
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileDirectoryClient) AddUserAliasEmail(ctx context.Context, userID string, email string) error {
|
||||
f.aliasEmails = append(f.aliasEmails, userID+":"+email)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileDirectoryClient) ResetUserPassword(ctx context.Context, userID string, password string) error {
|
||||
f.passwordResets = append(f.passwordResets, userID+":"+password)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileDirectoryClient) DeleteUser(ctx context.Context, userID string) error {
|
||||
f.deletedUsers = append(f.deletedUsers, userID)
|
||||
return nil
|
||||
|
||||
@@ -56,7 +56,7 @@ func TestWorksmobileLiveSamanUsersDirectoryProvisioning(t *testing.T) {
|
||||
require.NoError(t, outboxRepo.MarkProcessed(ctx, job.ID))
|
||||
continue
|
||||
}
|
||||
item, err := syncService.EnqueueUserSync(ctx, root.ID, user.ID)
|
||||
item, err := syncService.EnqueueUserSync(ctx, root.ID, user.ID, "")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, item)
|
||||
require.NoError(t, outboxRepo.MarkRetry(ctx, job.ID))
|
||||
@@ -70,7 +70,7 @@ func TestWorksmobileLiveSamanUsersDirectoryProvisioning(t *testing.T) {
|
||||
require.Equal(t, domain.WorksmobileOutboxStatusProcessed, processed.Status)
|
||||
}
|
||||
|
||||
credentials, err := syncService.ListInitialPasswordCredentials(ctx, root.ID)
|
||||
credentials, err := syncService.ListInitialPasswordCredentials(ctx, root.ID, "")
|
||||
require.NoError(t, err)
|
||||
seen := map[string]bool{}
|
||||
for _, credential := range credentials {
|
||||
|
||||
@@ -51,15 +51,21 @@ type WorksmobilePasswordConfig struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type WorksmobilePasswordResetPayload struct {
|
||||
Email string `json:"email"`
|
||||
PasswordConfig WorksmobilePasswordConfig `json:"passwordConfig"`
|
||||
}
|
||||
|
||||
type WorksmobileUserOrganization struct {
|
||||
DomainID int64 `json:"domainId,omitempty"`
|
||||
Primary bool `json:"primary,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Primary bool `json:"primary"`
|
||||
OrgUnits []WorksmobileUserOrgUnit `json:"orgUnits"`
|
||||
}
|
||||
|
||||
type WorksmobileUserOrgUnit struct {
|
||||
OrgUnitID string `json:"orgUnitId"`
|
||||
Primary bool `json:"primary,omitempty"`
|
||||
Primary bool `json:"primary"`
|
||||
PositionID string `json:"positionId,omitempty"`
|
||||
IsManager *bool `json:"isManager,omitempty"`
|
||||
}
|
||||
@@ -156,12 +162,11 @@ func BuildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain
|
||||
tenantByID = map[string]domain.Tenant{}
|
||||
}
|
||||
tenantByID[tenant.ID] = tenant
|
||||
domainTenant := worksmobileDomainClassificationTenant(tenant, tenantByID)
|
||||
domainID, err := ResolveWorksmobileDomainIDFromTenant(domainTenant, rootConfig)
|
||||
domainID, err := ResolveWorksmobileAccountDomainIDFromEmail(user.Email, tenant, rootConfig)
|
||||
if err != nil {
|
||||
return WorksmobileUserPayload{}, err
|
||||
}
|
||||
employeeNumber := metadataString(user.Metadata, "employee_id", "employeeNumber", "employee_number")
|
||||
employeeNumber := metadataEmployeeNumber(user.Metadata)
|
||||
organizations, task, err := buildWorksmobileUserOrganizations(user, tenant, tenantByID, rootConfig)
|
||||
if err != nil {
|
||||
return WorksmobileUserPayload{}, err
|
||||
@@ -202,28 +207,19 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
|
||||
if len(appointments) == 0 {
|
||||
appointments = []worksmobileAppointment{{TenantID: tenant.ID, IsPrimary: true}}
|
||||
}
|
||||
primaryTenantID := metadataString(user.Metadata, "primaryTenantId", "primary_tenant_id")
|
||||
if primaryTenantID == "" && user.TenantID != nil {
|
||||
primaryTenantID = *user.TenantID
|
||||
}
|
||||
hasPrimary := false
|
||||
for i := range appointments {
|
||||
if appointments[i].TenantID == primaryTenantID || appointments[i].IsPrimary {
|
||||
appointments[i].IsPrimary = true
|
||||
hasPrimary = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasPrimary {
|
||||
for i := range appointments {
|
||||
if appointments[i].TenantID == tenant.ID {
|
||||
appointments[i].IsPrimary = true
|
||||
break
|
||||
}
|
||||
}
|
||||
accountDomainTenant := worksmobileAccountDomainTenantFromEmail(user.Email, tenant, tenantByID)
|
||||
accountDomainEnvKey := worksmobileTenantDomainIDEnvKey(accountDomainTenant)
|
||||
if !worksmobileAppointmentsContainDomain(appointments, tenantByID, accountDomainEnvKey) && accountDomainTenant.ID != "" {
|
||||
appointments = append([]worksmobileAppointment{{
|
||||
TenantID: accountDomainTenant.ID,
|
||||
IsPrimary: true,
|
||||
JobTitle: strings.TrimSpace(user.JobTitle),
|
||||
PositionID: metadataString(user.Metadata, "worksmobilePositionId", "positionId", "position_id"),
|
||||
}}, appointments...)
|
||||
}
|
||||
|
||||
organizations := make([]WorksmobileUserOrganization, 0, len(appointments))
|
||||
organizations := make([]WorksmobileUserOrganization, 0)
|
||||
organizationIndexByDomainID := map[int64]int{}
|
||||
seen := map[string]bool{}
|
||||
task := ""
|
||||
for _, appointment := range appointments {
|
||||
@@ -242,21 +238,34 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
isAccountDomain := worksmobileTenantDomainIDEnvKey(domainTenant) == accountDomainEnvKey
|
||||
isPrimaryOrganization := isAccountDomain && !worksmobileOrganizationsHavePrimary(organizations)
|
||||
organizationIndex, organizationExists := organizationIndexByDomainID[domainID]
|
||||
orgUnit := WorksmobileUserOrgUnit{
|
||||
OrgUnitID: "externalKey:" + appointmentTenant.ID,
|
||||
Primary: appointment.IsPrimary,
|
||||
Primary: !organizationExists,
|
||||
PositionID: appointment.PositionID,
|
||||
}
|
||||
if appointment.HasManager {
|
||||
isManager := appointment.IsManager
|
||||
orgUnit.IsManager = &isManager
|
||||
}
|
||||
organizations = append(organizations, WorksmobileUserOrganization{
|
||||
DomainID: domainID,
|
||||
Primary: appointment.IsPrimary,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{orgUnit},
|
||||
})
|
||||
if appointment.IsPrimary && strings.TrimSpace(appointment.JobTitle) != "" {
|
||||
if organizationExists {
|
||||
if isPrimaryOrganization {
|
||||
organizations[organizationIndex].Primary = true
|
||||
organizations[organizationIndex].Email = worksmobileOrganizationEmail(user, domainTenant)
|
||||
}
|
||||
organizations[organizationIndex].OrgUnits = append(organizations[organizationIndex].OrgUnits, orgUnit)
|
||||
} else {
|
||||
organizationIndexByDomainID[domainID] = len(organizations)
|
||||
organizations = append(organizations, WorksmobileUserOrganization{
|
||||
DomainID: domainID,
|
||||
Email: worksmobileOrganizationEmail(user, domainTenant),
|
||||
Primary: isPrimaryOrganization,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{orgUnit},
|
||||
})
|
||||
}
|
||||
if isPrimaryOrganization && strings.TrimSpace(appointment.JobTitle) != "" {
|
||||
task = strings.TrimSpace(appointment.JobTitle)
|
||||
}
|
||||
seen[appointment.TenantID] = true
|
||||
@@ -264,10 +273,39 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
|
||||
if len(organizations) == 0 {
|
||||
return nil, "", errors.New("no valid worksmobile organization")
|
||||
}
|
||||
if !worksmobileOrganizationsHavePrimary(organizations) {
|
||||
organizations[0].Primary = true
|
||||
if len(organizations[0].OrgUnits) > 0 {
|
||||
organizations[0].OrgUnits[0].Primary = true
|
||||
}
|
||||
}
|
||||
sortWorksmobileOrganizations(organizations)
|
||||
return organizations, task, nil
|
||||
}
|
||||
|
||||
func worksmobileAppointmentsContainDomain(appointments []worksmobileAppointment, tenantByID map[string]domain.Tenant, envKey string) bool {
|
||||
for _, appointment := range appointments {
|
||||
tenant, ok := tenantByID[appointment.TenantID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
domainTenant := worksmobileDomainClassificationTenant(tenant, tenantByID)
|
||||
if worksmobileTenantDomainIDEnvKey(domainTenant) == envKey {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func worksmobileOrganizationsHavePrimary(organizations []WorksmobileUserOrganization) bool {
|
||||
for _, organization := range organizations {
|
||||
if organization.Primary {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func worksmobileAppointmentsFromMetadata(metadata domain.JSONMap) []worksmobileAppointment {
|
||||
rawAppointments, ok := metadata["additionalAppointments"].([]any)
|
||||
if !ok {
|
||||
@@ -326,7 +364,7 @@ func BuildWorksmobileAliasEmails(user domain.User, tenant domain.Tenant) []strin
|
||||
} {
|
||||
candidates = append(candidates, metadataStringList(user.Metadata, key)...)
|
||||
}
|
||||
employeeNumber := metadataString(user.Metadata, "employee_id", "employeeNumber", "employee_number")
|
||||
employeeNumber := metadataEmployeeNumber(user.Metadata)
|
||||
if isHanmacWorksmobileTenant(tenant) && employeeNumber != "" {
|
||||
candidates = append(candidates, employeeNumber+"@hanmaceng.co.kr")
|
||||
}
|
||||
@@ -351,26 +389,21 @@ func normalizeWorksmobileAliasEmails(primaryEmail string, candidates []string) [
|
||||
return result
|
||||
}
|
||||
|
||||
func ValidateWorksmobileAliasLocalParts(primaryEmail string, aliasEmails []string, existingLocalParts map[string]string) error {
|
||||
seen := map[string]string{}
|
||||
primaryLocalPart, err := domain.ExtractNormalizedEmailLocalPart(primaryEmail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seen[primaryLocalPart] = primaryEmail
|
||||
func ValidateWorksmobileAliasEmails(primaryEmail string, aliasEmails []string, existingEmails map[string]string) error {
|
||||
seen := map[string]string{strings.ToLower(strings.TrimSpace(primaryEmail)): primaryEmail}
|
||||
|
||||
for _, aliasEmail := range aliasEmails {
|
||||
localPart, err := domain.ExtractNormalizedEmailLocalPart(aliasEmail)
|
||||
if err != nil {
|
||||
normalized := strings.ToLower(strings.TrimSpace(aliasEmail))
|
||||
if _, err := mail.ParseAddress(normalized); err != nil {
|
||||
return err
|
||||
}
|
||||
if previous, ok := seen[localPart]; ok {
|
||||
return fmt.Errorf("worksmobile alias local-part duplicates %s: %s and %s", localPart, previous, aliasEmail)
|
||||
if previous, ok := seen[normalized]; ok {
|
||||
return fmt.Errorf("worksmobile alias email duplicates: %s and %s", previous, aliasEmail)
|
||||
}
|
||||
if owner, ok := existingLocalParts[localPart]; ok {
|
||||
return fmt.Errorf("worksmobile alias local-part %s는 이미 사용 중입니다: %s", localPart, owner)
|
||||
if owner, ok := existingEmails[normalized]; ok {
|
||||
return fmt.Errorf("worksmobile alias email %s는 이미 사용 중입니다: %s", normalized, owner)
|
||||
}
|
||||
seen[localPart] = aliasEmail
|
||||
seen[normalized] = aliasEmail
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -446,6 +479,91 @@ func ResolveWorksmobileDomainIDFromTenant(tenant domain.Tenant, _ domain.JSONMap
|
||||
return 0, fmt.Errorf("worksmobile domain id env is missing for tenant: %s", envKey)
|
||||
}
|
||||
|
||||
func ResolveWorksmobileAccountDomainIDFromEmail(email string, fallbackTenant domain.Tenant, rootConfig domain.JSONMap) (int64, error) {
|
||||
switch worksmobileEmailDomainName(email) {
|
||||
case "samaneng.com":
|
||||
if domainID, ok := worksmobileDomainIDFromEnv("SAMAN_DOMAIN_ID"); ok {
|
||||
return domainID, nil
|
||||
}
|
||||
case "hanmaceng.co.kr":
|
||||
if domainID, ok := worksmobileDomainIDFromEnv("HANMAC_DOMAIN_ID"); ok {
|
||||
return domainID, nil
|
||||
}
|
||||
case "baroncs.co.kr":
|
||||
if domainID, ok := worksmobileDomainIDFromEnv("GPDTDC_DOMAIN_ID"); ok {
|
||||
return domainID, nil
|
||||
}
|
||||
case "brsw.kr":
|
||||
if domainID, ok := worksmobileDomainIDFromEnv("BARONGROUP_DOMAIN_ID"); ok {
|
||||
return domainID, nil
|
||||
}
|
||||
}
|
||||
return ResolveWorksmobileDomainIDFromTenant(fallbackTenant, rootConfig)
|
||||
}
|
||||
|
||||
func worksmobileAccountDomainTenantFromEmail(email string, fallbackTenant domain.Tenant, tenantByID map[string]domain.Tenant) domain.Tenant {
|
||||
envKey := worksmobileDomainIDEnvKeyFromEmail(email)
|
||||
for _, tenant := range tenantByID {
|
||||
if isWorksmobileDomainRootTenant(tenant) && worksmobileTenantDomainIDEnvKey(tenant) == envKey {
|
||||
return tenant
|
||||
}
|
||||
}
|
||||
for _, tenant := range tenantByID {
|
||||
if worksmobileTenantDomainIDEnvKey(tenant) == envKey {
|
||||
return worksmobileDomainClassificationTenant(tenant, tenantByID)
|
||||
}
|
||||
}
|
||||
return worksmobileDomainClassificationTenant(fallbackTenant, tenantByID)
|
||||
}
|
||||
|
||||
func worksmobileDomainIDEnvKeyFromEmail(email string) string {
|
||||
switch worksmobileEmailDomainName(email) {
|
||||
case "samaneng.com":
|
||||
return "SAMAN_DOMAIN_ID"
|
||||
case "hanmaceng.co.kr":
|
||||
return "HANMAC_DOMAIN_ID"
|
||||
case "baroncs.co.kr":
|
||||
return "GPDTDC_DOMAIN_ID"
|
||||
case "brsw.kr":
|
||||
return "BARONGROUP_DOMAIN_ID"
|
||||
default:
|
||||
return worksmobileTenantDomainIDEnvKey(domain.Tenant{})
|
||||
}
|
||||
}
|
||||
|
||||
func worksmobileEmailDomainName(email string) string {
|
||||
address, err := mail.ParseAddress(strings.TrimSpace(email))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(address.Address, "@")
|
||||
if len(parts) != 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(parts[1]))
|
||||
}
|
||||
|
||||
func worksmobileOrganizationEmail(user domain.User, domainTenant domain.Tenant) string {
|
||||
domainName := worksmobileTenantMailDomain(domainTenant)
|
||||
if domainName == "" {
|
||||
return ""
|
||||
}
|
||||
primaryEmail := strings.ToLower(strings.TrimSpace(user.Email))
|
||||
if worksmobileEmailDomainName(primaryEmail) == domainName {
|
||||
return primaryEmail
|
||||
}
|
||||
for _, alias := range BuildWorksmobileAliasEmails(user, domainTenant) {
|
||||
if worksmobileEmailDomainName(alias) == domainName {
|
||||
return alias
|
||||
}
|
||||
}
|
||||
localPart, err := domain.ExtractNormalizedEmailLocalPart(primaryEmail)
|
||||
if err != nil || localPart == "" {
|
||||
return ""
|
||||
}
|
||||
return localPart + "@" + domainName
|
||||
}
|
||||
|
||||
func worksmobileTenantDomainIDEnvKey(tenant domain.Tenant) string {
|
||||
if tenantHasDomain(tenant, "samaneng.com") || tenantMatchesAny(tenant, "saman", "삼안") {
|
||||
return "SAMAN_DOMAIN_ID"
|
||||
@@ -597,6 +715,70 @@ func metadataString(metadata domain.JSONMap, keys ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func metadataEmployeeNumber(metadata domain.JSONMap) string {
|
||||
for _, key := range []string{"employee_id", "employeeNumber", "employee_number"} {
|
||||
value, ok := metadata[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if normalized := normalizeMetadataEmployeeNumber(value); normalized != "" {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeMetadataEmployeeNumber(value any) string {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(v)
|
||||
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
|
||||
return strings.TrimSpace(fmt.Sprint(v))
|
||||
case map[string]any:
|
||||
return normalizeMetadataCharacterMap(v)
|
||||
case domain.JSONMap:
|
||||
return normalizeMetadataCharacterMap(map[string]any(v))
|
||||
case map[string]string:
|
||||
converted := make(map[string]any, len(v))
|
||||
for key, value := range v {
|
||||
converted[key] = value
|
||||
}
|
||||
return normalizeMetadataCharacterMap(converted)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeMetadataCharacterMap(value map[string]any) string {
|
||||
type characterEntry struct {
|
||||
index int
|
||||
value string
|
||||
}
|
||||
entries := make([]characterEntry, 0, len(value))
|
||||
for key, raw := range value {
|
||||
index, err := strconv.Atoi(key)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
part, ok := raw.(string)
|
||||
if !ok || part == "" {
|
||||
return ""
|
||||
}
|
||||
entries = append(entries, characterEntry{index: index, value: part})
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return ""
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].index < entries[j].index
|
||||
})
|
||||
var builder strings.Builder
|
||||
for _, entry := range entries {
|
||||
builder.WriteString(entry.value)
|
||||
}
|
||||
return strings.TrimSpace(builder.String())
|
||||
}
|
||||
|
||||
func metadataBool(metadata domain.JSONMap, keys ...string) bool {
|
||||
value, _ := metadataOptionalBool(metadata, keys...)
|
||||
return value
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -134,6 +135,36 @@ func TestBuildWorksmobileUserPayloadMapsBaronUserAndPrimaryTenant(t *testing.T)
|
||||
require.Equal(t, "externalKey:"+tenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
|
||||
}
|
||||
|
||||
func TestBuildWorksmobileUserPayloadNormalizesLegacyCharacterMapEmployeeID(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
tenantID := "33333333-3333-3333-3333-333333333333"
|
||||
user := domain.User{
|
||||
ID: "44444444-4444-4444-4444-444444444444",
|
||||
Email: "john1@samaneng.com",
|
||||
Name: "John Doe",
|
||||
TenantID: &tenantID,
|
||||
Metadata: domain.JSONMap{
|
||||
"employee_id": map[string]any{
|
||||
"0": "j",
|
||||
"1": "o",
|
||||
"2": "h",
|
||||
"3": "n",
|
||||
},
|
||||
},
|
||||
}
|
||||
tenant := domain.Tenant{
|
||||
ID: tenantID,
|
||||
Slug: "saman",
|
||||
Name: "Saman",
|
||||
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||
}
|
||||
|
||||
payload, err := BuildWorksmobileUserPayload(user, tenant, nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "john", payload.EmployeeNumber)
|
||||
}
|
||||
|
||||
func TestBuildWorksmobileUserPayloadMapsAdditionalAppointmentsToOrgUnits(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
t.Setenv("HANMAC_DOMAIN_ID", "1002")
|
||||
@@ -198,7 +229,7 @@ func TestBuildWorksmobileUserPayloadMapsAdditionalAppointmentsToOrgUnits(t *test
|
||||
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.False(t, payload.Organizations[1].OrgUnits[0].Primary)
|
||||
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)
|
||||
}
|
||||
@@ -259,7 +290,7 @@ func TestBuildWorksmobileUserPayloadKeepsFirstAffiliationPrimaryWhenBaronReprese
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1003), payload.DomainID)
|
||||
require.Equal(t, int64(1001), payload.DomainID)
|
||||
require.Equal(t, "First affiliation task", payload.Task)
|
||||
require.Len(t, payload.Organizations, 2)
|
||||
require.Equal(t, int64(1001), payload.Organizations[0].DomainID)
|
||||
@@ -269,7 +300,99 @@ func TestBuildWorksmobileUserPayloadKeepsFirstAffiliationPrimaryWhenBaronReprese
|
||||
require.Equal(t, int64(1003), payload.Organizations[1].DomainID)
|
||||
require.False(t, payload.Organizations[1].Primary)
|
||||
require.Equal(t, "externalKey:"+secondTenantID, payload.Organizations[1].OrgUnits[0].OrgUnitID)
|
||||
require.False(t, payload.Organizations[1].OrgUnits[0].Primary)
|
||||
require.True(t, payload.Organizations[1].OrgUnits[0].Primary)
|
||||
}
|
||||
|
||||
func TestBuildWorksmobileUserPayloadUsesEmailDomainForAccountDomainWhenPrimaryOrgIsGPDTDC(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
||||
samanID := "11111111-1111-1111-1111-111111111111"
|
||||
gpdtdcID := "5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee"
|
||||
leafTenantID := "52f06c97-9d6f-4819-971b-43303062e193"
|
||||
user := domain.User{
|
||||
ID: "44444444-4444-4444-4444-444444444444",
|
||||
Email: "dhlee@samaneng.com",
|
||||
Name: "GPDTDC Saman User",
|
||||
TenantID: &leafTenantID,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": leafTenantID,
|
||||
"isPrimary": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
samanTenant := domain.Tenant{
|
||||
ID: samanID,
|
||||
Slug: "saman",
|
||||
Name: "삼안",
|
||||
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||
}
|
||||
gpdtdcTenant := domain.Tenant{
|
||||
ID: gpdtdcID,
|
||||
Slug: "gpdtdc",
|
||||
Name: "총괄기획&기술개발센터",
|
||||
}
|
||||
leafTenant := domain.Tenant{
|
||||
ID: leafTenantID,
|
||||
Slug: "infra-bim2",
|
||||
Name: "인프라 BIM2",
|
||||
ParentID: &gpdtdcID,
|
||||
}
|
||||
|
||||
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
|
||||
user,
|
||||
leafTenant,
|
||||
map[string]domain.Tenant{
|
||||
samanID: samanTenant,
|
||||
gpdtdcID: gpdtdcTenant,
|
||||
leafTenantID: leafTenant,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1001), payload.DomainID)
|
||||
require.Len(t, payload.Organizations, 2)
|
||||
require.Equal(t, int64(1001), payload.Organizations[0].DomainID)
|
||||
require.True(t, payload.Organizations[0].Primary)
|
||||
require.Equal(t, "dhlee@samaneng.com", payload.Organizations[0].Email)
|
||||
require.Equal(t, "externalKey:"+samanID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
|
||||
require.True(t, payload.Organizations[0].OrgUnits[0].Primary)
|
||||
require.Equal(t, int64(1003), payload.Organizations[1].DomainID)
|
||||
require.False(t, payload.Organizations[1].Primary)
|
||||
require.Equal(t, "dhlee@baroncs.co.kr", payload.Organizations[1].Email)
|
||||
require.Equal(t, "externalKey:"+leafTenantID, payload.Organizations[1].OrgUnits[0].OrgUnitID)
|
||||
require.True(t, payload.Organizations[1].OrgUnits[0].Primary)
|
||||
}
|
||||
|
||||
func TestWorksmobileUserPayloadJSONIncludesFalsePrimaryFields(t *testing.T) {
|
||||
payload := WorksmobileUserPayload{
|
||||
Email: "user@samaneng.com",
|
||||
Organizations: []WorksmobileUserOrganization{
|
||||
{
|
||||
DomainID: 1001,
|
||||
Primary: true,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{
|
||||
{OrgUnitID: "externalKey:primary", Primary: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
DomainID: 1003,
|
||||
Primary: false,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{
|
||||
{OrgUnitID: "externalKey:secondary", Primary: false},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(payload)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(data), `"primary":false`)
|
||||
require.Contains(t, string(data), `"orgUnitId":"externalKey:secondary","primary":false`)
|
||||
}
|
||||
|
||||
func TestResolveWorksmobileDomainIDFromTenantIgnoresRootDomainMappings(t *testing.T) {
|
||||
@@ -441,19 +564,51 @@ func TestBuildWorksmobileUserPayloadAddsSubEmailMetadataAlias(t *testing.T) {
|
||||
require.Equal(t, []string{"alias1@hanmaceng.co.kr", "alias2@hanmaceng.co.kr"}, payload.AliasEmails)
|
||||
}
|
||||
|
||||
func TestValidateWorksmobileAliasLocalPartsRejectsPrimaryAndAliasCollisions(t *testing.T) {
|
||||
err := ValidateWorksmobileAliasLocalParts(
|
||||
func TestBuildWorksmobileUserPayloadKeepsSubEmailAliasWithPrimaryLocalPart(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
tenantID := "33333333-3333-3333-3333-333333333333"
|
||||
user := domain.User{
|
||||
ID: "44444444-4444-4444-4444-444444444444",
|
||||
Email: "ypshim@samaneng.com",
|
||||
Name: "Saman User",
|
||||
TenantID: &tenantID,
|
||||
Metadata: domain.JSONMap{
|
||||
"sub_email": "ypshim@hanmaceng.co.kr",
|
||||
},
|
||||
}
|
||||
tenant := domain.Tenant{
|
||||
ID: tenantID,
|
||||
Slug: "saman",
|
||||
Name: "삼안",
|
||||
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||
}
|
||||
|
||||
payload, err := BuildWorksmobileUserPayload(user, tenant, nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"ypshim@hanmaceng.co.kr"}, payload.AliasEmails)
|
||||
}
|
||||
|
||||
func TestValidateWorksmobileAliasEmailsAllowsSameLocalPartOnDifferentDomains(t *testing.T) {
|
||||
err := ValidateWorksmobileAliasEmails(
|
||||
"main@samaneng.com",
|
||||
[]string{"main@hanmaceng.co.kr"},
|
||||
map[string]string{},
|
||||
)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "local-part")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = ValidateWorksmobileAliasLocalParts(
|
||||
err = ValidateWorksmobileAliasEmails(
|
||||
"main@samaneng.com",
|
||||
[]string{"main@samaneng.com"},
|
||||
map[string]string{},
|
||||
)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "duplicates")
|
||||
|
||||
err = ValidateWorksmobileAliasEmails(
|
||||
"main@samaneng.com",
|
||||
[]string{"alias@hanmaceng.co.kr"},
|
||||
map[string]string{"alias": "existing-user"},
|
||||
map[string]string{"alias@hanmaceng.co.kr": "existing-user"},
|
||||
)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "이미 사용 중")
|
||||
|
||||
@@ -100,9 +100,16 @@ func (w *WorksmobileRelayWorker) dispatch(ctx context.Context, job domain.Worksm
|
||||
if err := decodeWorksmobileRequest(job.Payload, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
aliasEmails := append([]string(nil), payload.AliasEmails...)
|
||||
payload.AliasEmails = nil
|
||||
if err := w.client.UpsertUser(ctx, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, aliasEmail := range aliasEmails {
|
||||
if err := w.client.AddUserAliasEmail(ctx, payload.Email, aliasEmail); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if stringValue(job.Payload["baronStatus"]) == domain.UserStatusActive {
|
||||
return w.client.SetUserActive(ctx, worksmobileOutboxUserIdentifier(job), true)
|
||||
}
|
||||
@@ -111,6 +118,16 @@ func (w *WorksmobileRelayWorker) dispatch(ctx context.Context, job domain.Worksm
|
||||
return w.client.DeleteUser(ctx, worksmobileOutboxUserIdentifier(job))
|
||||
case domain.WorksmobileActionSuspend:
|
||||
return w.client.SetUserActive(ctx, worksmobileOutboxUserIdentifier(job), false)
|
||||
case domain.WorksmobileActionPasswordReset:
|
||||
var payload WorksmobilePasswordResetPayload
|
||||
if err := decodeWorksmobileRequest(job.Payload, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
identifier := strings.TrimSpace(payload.Email)
|
||||
if identifier == "" {
|
||||
identifier = worksmobileOutboxUserIdentifier(job)
|
||||
}
|
||||
return w.client.ResetUserPassword(ctx, identifier, payload.PasswordConfig.Password)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
"baron-sso-backend/internal/repository"
|
||||
"context"
|
||||
"errors"
|
||||
"net/mail"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const HanmacFamilyTenantSlug = "hanmac-family"
|
||||
@@ -25,9 +27,12 @@ type WorksmobileAdminService interface {
|
||||
EnqueueBackfillDryRun(ctx context.Context, tenantID string) (WorksmobileBackfillDryRun, error)
|
||||
EnqueueOrgUnitSync(ctx context.Context, tenantID, orgUnitID string) (*domain.WorksmobileOutbox, error)
|
||||
EnqueueOrgUnitDelete(ctx context.Context, tenantID, worksmobileOrgUnitID string) (*domain.WorksmobileOutbox, error)
|
||||
EnqueueUserSync(ctx context.Context, tenantID, userID string) (*domain.WorksmobileOutbox, error)
|
||||
EnqueueUserSync(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error)
|
||||
EnqueueUserPasswordReset(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error)
|
||||
RetryJob(ctx context.Context, tenantID, jobID string) (*domain.WorksmobileOutbox, error)
|
||||
ListInitialPasswordCredentials(ctx context.Context, tenantID string) ([]WorksmobileInitialPasswordCredential, error)
|
||||
ListInitialPasswordCredentials(ctx context.Context, tenantID, credentialBatchID string) ([]WorksmobileInitialPasswordCredential, error)
|
||||
ListCredentialBatches(ctx context.Context, tenantID string) ([]WorksmobileCredentialBatch, error)
|
||||
DeleteCredentialBatchPasswords(ctx context.Context, tenantID, credentialBatchID string) (WorksmobileCredentialBatch, error)
|
||||
}
|
||||
|
||||
type WorksmobileConfigSummary struct {
|
||||
@@ -49,10 +54,36 @@ type WorksmobileBackfillDryRun struct {
|
||||
}
|
||||
|
||||
type WorksmobileInitialPasswordCredential struct {
|
||||
Email string `json:"email"`
|
||||
InitialPassword string `json:"initialPassword"`
|
||||
Status string `json:"status"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name,omitempty"`
|
||||
PrimaryLeafOrgName string `json:"primaryLeafOrgName,omitempty"`
|
||||
InitialPassword string `json:"initialPassword"`
|
||||
Status string `json:"status"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
}
|
||||
|
||||
type WorksmobileCredentialBatch struct {
|
||||
BatchID string `json:"batchId"`
|
||||
Operation string `json:"operation,omitempty"`
|
||||
UserCount int `json:"userCount"`
|
||||
PendingCount int `json:"pendingCount"`
|
||||
ProcessingCount int `json:"processingCount"`
|
||||
ProcessedCount int `json:"processedCount"`
|
||||
FailedCount int `json:"failedCount"`
|
||||
HasPasswords bool `json:"hasPasswords"`
|
||||
DeletedAt string `json:"deletedAt,omitempty"`
|
||||
Failures []WorksmobileCredentialBatchFailure `json:"failures,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type WorksmobileCredentialBatchFailure struct {
|
||||
UserID string `json:"userId,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Status string `json:"status"`
|
||||
RetryCount int `json:"retryCount"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
type WorksmobileComparison struct {
|
||||
@@ -93,6 +124,10 @@ type WorksmobileComparisonItem struct {
|
||||
WorksmobileParentName string `json:"worksmobileParentName,omitempty"`
|
||||
WorksmobileParentEmail string `json:"worksmobileParentEmail,omitempty"`
|
||||
WorksmobileParentExternalKey string `json:"worksmobileParentExternalKey,omitempty"`
|
||||
WorksmobileJobStatus string `json:"worksmobileJobStatus,omitempty"`
|
||||
WorksmobileJobRetryCount int `json:"worksmobileJobRetryCount,omitempty"`
|
||||
WorksmobileLastError string `json:"worksmobileLastError,omitempty"`
|
||||
WorksmobileLastAttemptAt string `json:"worksmobileLastAttemptAt,omitempty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
@@ -185,8 +220,10 @@ func (s *worksmobileSyncService) GetComparison(ctx context.Context, tenantID str
|
||||
return WorksmobileComparison{}, err
|
||||
}
|
||||
|
||||
recentJobs, _ := s.outboxRepo.ListRecent(ctx, 1000)
|
||||
|
||||
return WorksmobileComparison{
|
||||
Users: compareWorksmobileUsers(users, remoteUsers, includeMatched, tenantByID),
|
||||
Users: compareWorksmobileUsers(users, remoteUsers, includeMatched, tenantByID, worksmobileUserJobSummaries(recentJobs)),
|
||||
Groups: compareWorksmobileGroups(append([]domain.Tenant{*root}, tenants...), remoteGroups, includeMatched),
|
||||
}, nil
|
||||
}
|
||||
@@ -340,7 +377,7 @@ func (s *worksmobileSyncService) EnqueueOrgUnitDelete(ctx context.Context, tenan
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID, userID string) (*domain.WorksmobileOutbox, error) {
|
||||
func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) {
|
||||
root, err := s.hanmacRoot(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -394,18 +431,104 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
|
||||
DedupeKey: "user:" + strings.ToLower(action) + ":" + user.ID,
|
||||
Payload: worksmobileUserOutboxPayload(root.ID, payload, user.Status),
|
||||
}
|
||||
item.Payload["displayName"] = strings.TrimSpace(user.Name)
|
||||
item.Payload["primaryLeafOrgName"] = worksmobileUserPrimaryOrgName(*user, tenantByID)
|
||||
if batchID := strings.TrimSpace(credentialBatchID); batchID != "" {
|
||||
item.Payload["credentialBatchId"] = batchID
|
||||
item.Payload["credentialOperation"] = "worksmobile_user_sync"
|
||||
item.Payload["credentialBatchCreatedAt"] = time.Now().UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
if err := s.outboxRepo.Create(ctx, item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) ListInitialPasswordCredentials(ctx context.Context, tenantID string) ([]WorksmobileInitialPasswordCredential, error) {
|
||||
func (s *worksmobileSyncService) EnqueueUserPasswordReset(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) {
|
||||
root, err := s.hanmacRoot(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jobs, err := s.outboxRepo.ListRecent(ctx, 1000)
|
||||
user, err := s.userRepo.FindByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user.TenantID == nil {
|
||||
return nil, errors.New("target user has no tenant")
|
||||
}
|
||||
tenant, err := s.tenantService.GetTenant(ctx, *user.TenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tenantRoot, ok, err := s.rootForTenant(ctx, *tenant)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok || tenantRoot.ID != root.ID {
|
||||
return nil, errors.New("target user is outside hanmac-family subtree")
|
||||
}
|
||||
if !domain.IsWorksProvisionedUserStatus(user.Status) {
|
||||
return nil, errors.New("target user status is excluded from Worksmobile password reset")
|
||||
}
|
||||
scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
||||
payload, err := BuildWorksmobileUserPayloadForDomainTenants(*user, *tenant, tenantByID, root.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
password := GenerateWorksmobileInitialPassword()
|
||||
request := WorksmobilePasswordResetPayload{
|
||||
Email: strings.TrimSpace(payload.Email),
|
||||
PasswordConfig: WorksmobilePasswordConfig{
|
||||
PasswordCreationType: "ADMIN",
|
||||
Password: password,
|
||||
},
|
||||
}
|
||||
batchID := strings.TrimSpace(credentialBatchID)
|
||||
batchCreatedAt := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
dedupeSuffix := batchID
|
||||
if dedupeSuffix == "" {
|
||||
dedupeSuffix = batchCreatedAt
|
||||
}
|
||||
item := &domain.WorksmobileOutbox{
|
||||
ResourceType: domain.WorksmobileResourceUser,
|
||||
ResourceID: user.ID,
|
||||
Action: domain.WorksmobileActionPasswordReset,
|
||||
DedupeKey: "user:password-reset:" + user.ID + ":" + dedupeSuffix,
|
||||
Payload: domain.JSONMap{
|
||||
"tenantRootId": root.ID,
|
||||
"loginEmail": request.Email,
|
||||
"userExternalKey": user.ID,
|
||||
"initialPassword": password,
|
||||
"displayName": strings.TrimSpace(user.Name),
|
||||
"primaryLeafOrgName": worksmobileUserPrimaryOrgName(*user, tenantByID),
|
||||
"credentialBatchId": batchID,
|
||||
"credentialOperation": "worksmobile_password_reset",
|
||||
"credentialBatchCreatedAt": batchCreatedAt,
|
||||
"request": request,
|
||||
},
|
||||
}
|
||||
if err := s.outboxRepo.Create(ctx, item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) ListInitialPasswordCredentials(ctx context.Context, tenantID, credentialBatchID string) ([]WorksmobileInitialPasswordCredential, error) {
|
||||
root, err := s.hanmacRoot(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
credentialBatchID = strings.TrimSpace(credentialBatchID)
|
||||
var jobs []domain.WorksmobileOutbox
|
||||
if credentialBatchID != "" {
|
||||
jobs, err = s.outboxRepo.ListCredentialBatchJobs(ctx, root.ID, credentialBatchID)
|
||||
} else {
|
||||
jobs, err = s.outboxRepo.ListRecent(ctx, 1000)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -418,6 +541,9 @@ func (s *worksmobileSyncService) ListInitialPasswordCredentials(ctx context.Cont
|
||||
if stringValue(job.Payload["tenantRootId"]) != root.ID {
|
||||
continue
|
||||
}
|
||||
if credentialBatchID != "" && stringValue(job.Payload["credentialBatchId"]) != credentialBatchID {
|
||||
continue
|
||||
}
|
||||
email := stringValue(job.Payload["loginEmail"])
|
||||
password := stringValue(job.Payload["initialPassword"])
|
||||
if email == "" || password == "" || seen[email] {
|
||||
@@ -425,15 +551,60 @@ func (s *worksmobileSyncService) ListInitialPasswordCredentials(ctx context.Cont
|
||||
}
|
||||
seen[email] = true
|
||||
credentials = append(credentials, WorksmobileInitialPasswordCredential{
|
||||
Email: email,
|
||||
InitialPassword: password,
|
||||
Status: job.Status,
|
||||
LastError: job.LastError,
|
||||
Email: email,
|
||||
Name: stringValue(job.Payload["displayName"]),
|
||||
PrimaryLeafOrgName: stringValue(job.Payload["primaryLeafOrgName"]),
|
||||
InitialPassword: password,
|
||||
Status: job.Status,
|
||||
LastError: job.LastError,
|
||||
})
|
||||
}
|
||||
return credentials, nil
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) ListCredentialBatches(ctx context.Context, tenantID string) ([]WorksmobileCredentialBatch, error) {
|
||||
root, err := s.hanmacRoot(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jobs, err := s.outboxRepo.ListCredentialBatchJobs(ctx, root.ID, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return aggregateWorksmobileCredentialBatches(jobs), nil
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) DeleteCredentialBatchPasswords(ctx context.Context, tenantID, credentialBatchID string) (WorksmobileCredentialBatch, error) {
|
||||
root, err := s.hanmacRoot(ctx, tenantID)
|
||||
if err != nil {
|
||||
return WorksmobileCredentialBatch{}, err
|
||||
}
|
||||
credentialBatchID = strings.TrimSpace(credentialBatchID)
|
||||
if credentialBatchID == "" {
|
||||
return WorksmobileCredentialBatch{}, errors.New("credential batch id is required")
|
||||
}
|
||||
jobs, err := s.outboxRepo.ListCredentialBatchJobs(ctx, root.ID, credentialBatchID)
|
||||
if err != nil {
|
||||
return WorksmobileCredentialBatch{}, err
|
||||
}
|
||||
if len(jobs) == 0 {
|
||||
return WorksmobileCredentialBatch{}, errors.New("credential batch not found")
|
||||
}
|
||||
deletedAt := time.Now().UTC().Format(time.RFC3339)
|
||||
for i := range jobs {
|
||||
nextPayload := scrubWorksmobileCredentialPayload(jobs[i].Payload, deletedAt)
|
||||
if err := s.outboxRepo.UpdatePayload(ctx, jobs[i].ID, nextPayload); err != nil {
|
||||
return WorksmobileCredentialBatch{}, err
|
||||
}
|
||||
jobs[i].Payload = nextPayload
|
||||
}
|
||||
batches := aggregateWorksmobileCredentialBatches(jobs)
|
||||
if len(batches) == 0 {
|
||||
return WorksmobileCredentialBatch{}, errors.New("credential batch not found")
|
||||
}
|
||||
return batches[0], nil
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) RetryJob(ctx context.Context, tenantID, jobID string) (*domain.WorksmobileOutbox, error) {
|
||||
if _, err := s.hanmacRoot(ctx, tenantID); err != nil {
|
||||
return nil, err
|
||||
@@ -663,7 +834,7 @@ func (s *worksmobileSyncService) validateUserAliasLocalParts(ctx context.Context
|
||||
if existingUser.ID == user.ID {
|
||||
continue
|
||||
}
|
||||
addWorksmobileLocalPart(existing, existingUser.Email, existingUser.ID)
|
||||
addWorksmobileEmail(existing, existingUser.Email, existingUser.ID)
|
||||
if existingUser.TenantID == nil {
|
||||
continue
|
||||
}
|
||||
@@ -672,16 +843,16 @@ func (s *worksmobileSyncService) validateUserAliasLocalParts(ctx context.Context
|
||||
continue
|
||||
}
|
||||
for _, alias := range BuildWorksmobileAliasEmails(existingUser, tenant) {
|
||||
addWorksmobileLocalPart(existing, alias, existingUser.ID)
|
||||
addWorksmobileEmail(existing, alias, existingUser.ID)
|
||||
}
|
||||
}
|
||||
return ValidateWorksmobileAliasLocalParts(payload.Email, payload.AliasEmails, existing)
|
||||
return ValidateWorksmobileAliasEmails(payload.Email, payload.AliasEmails, existing)
|
||||
}
|
||||
|
||||
func addWorksmobileLocalPart(target map[string]string, email string, owner string) {
|
||||
localPart, err := domain.ExtractNormalizedEmailLocalPart(email)
|
||||
if err == nil && localPart != "" {
|
||||
target[localPart] = owner
|
||||
func addWorksmobileEmail(target map[string]string, email string, owner string) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(email))
|
||||
if _, err := mail.ParseAddress(normalized); err == nil && normalized != "" {
|
||||
target[normalized] = owner
|
||||
}
|
||||
}
|
||||
|
||||
@@ -833,6 +1004,196 @@ func worksmobileUserOutboxPayload(rootID string, payload WorksmobileUserPayload,
|
||||
return outboxPayload
|
||||
}
|
||||
|
||||
func aggregateWorksmobileCredentialBatches(jobs []domain.WorksmobileOutbox) []WorksmobileCredentialBatch {
|
||||
byBatchID := map[string]*WorksmobileCredentialBatch{}
|
||||
for _, job := range jobs {
|
||||
batchID := stringValue(job.Payload["credentialBatchId"])
|
||||
if batchID == "" {
|
||||
continue
|
||||
}
|
||||
batch, ok := byBatchID[batchID]
|
||||
if !ok {
|
||||
createdAt := worksmobileCredentialBatchCreatedAt(job)
|
||||
batch = &WorksmobileCredentialBatch{
|
||||
BatchID: batchID,
|
||||
Operation: stringValue(job.Payload["credentialOperation"]),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: job.UpdatedAt,
|
||||
}
|
||||
byBatchID[batchID] = batch
|
||||
}
|
||||
batch.UserCount++
|
||||
if batch.Operation == "" {
|
||||
batch.Operation = stringValue(job.Payload["credentialOperation"])
|
||||
}
|
||||
jobBatchCreatedAt := worksmobileCredentialBatchCreatedAt(job)
|
||||
if jobBatchCreatedAt.Before(batch.CreatedAt) || batch.CreatedAt.IsZero() {
|
||||
batch.CreatedAt = jobBatchCreatedAt
|
||||
}
|
||||
if job.UpdatedAt.After(batch.UpdatedAt) {
|
||||
batch.UpdatedAt = job.UpdatedAt
|
||||
}
|
||||
switch job.Status {
|
||||
case domain.WorksmobileOutboxStatusPending:
|
||||
batch.PendingCount++
|
||||
case domain.WorksmobileOutboxStatusProcessing:
|
||||
batch.ProcessingCount++
|
||||
case domain.WorksmobileOutboxStatusProcessed:
|
||||
batch.ProcessedCount++
|
||||
case domain.WorksmobileOutboxStatusFailed:
|
||||
batch.FailedCount++
|
||||
batch.Failures = append(batch.Failures, WorksmobileCredentialBatchFailure{
|
||||
UserID: job.ResourceID,
|
||||
Email: worksmobileCredentialJobEmail(job),
|
||||
Status: job.Status,
|
||||
RetryCount: job.RetryCount,
|
||||
LastError: strings.TrimSpace(job.LastError),
|
||||
UpdatedAt: job.UpdatedAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
if worksmobilePayloadHasPassword(job.Payload) {
|
||||
batch.HasPasswords = true
|
||||
}
|
||||
if deletedAt := stringValue(job.Payload["credentialDeletedAt"]); deletedAt != "" {
|
||||
batch.DeletedAt = deletedAt
|
||||
}
|
||||
}
|
||||
batches := make([]WorksmobileCredentialBatch, 0, len(byBatchID))
|
||||
for _, batch := range byBatchID {
|
||||
batches = append(batches, *batch)
|
||||
}
|
||||
sort.Slice(batches, func(i, j int) bool {
|
||||
return batches[i].CreatedAt.After(batches[j].CreatedAt)
|
||||
})
|
||||
return batches
|
||||
}
|
||||
|
||||
func worksmobileCredentialBatchCreatedAt(job domain.WorksmobileOutbox) time.Time {
|
||||
if value := stringValue(job.Payload["credentialBatchCreatedAt"]); value != "" {
|
||||
if parsed, err := time.Parse(time.RFC3339Nano, value); err == nil {
|
||||
return parsed.UTC()
|
||||
}
|
||||
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
|
||||
return parsed.UTC()
|
||||
}
|
||||
}
|
||||
if !job.UpdatedAt.IsZero() && !job.CreatedAt.IsZero() && job.UpdatedAt.After(job.CreatedAt) {
|
||||
return job.UpdatedAt.UTC()
|
||||
}
|
||||
return job.CreatedAt.UTC()
|
||||
}
|
||||
|
||||
func worksmobileCredentialJobEmail(job domain.WorksmobileOutbox) string {
|
||||
if email := stringValue(job.Payload["loginEmail"]); email != "" {
|
||||
return email
|
||||
}
|
||||
switch request := job.Payload["request"].(type) {
|
||||
case WorksmobileUserPayload:
|
||||
return strings.TrimSpace(request.Email)
|
||||
case WorksmobilePasswordResetPayload:
|
||||
return strings.TrimSpace(request.Email)
|
||||
case map[string]any:
|
||||
return stringValue(request["email"])
|
||||
case domain.JSONMap:
|
||||
return stringValue(request["email"])
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func scrubWorksmobileCredentialPayload(payload domain.JSONMap, deletedAt string) domain.JSONMap {
|
||||
nextPayload := make(domain.JSONMap, len(payload)+1)
|
||||
for key, value := range payload {
|
||||
nextPayload[key] = value
|
||||
}
|
||||
delete(nextPayload, "initialPassword")
|
||||
nextPayload["credentialDeletedAt"] = deletedAt
|
||||
nextPayload["request"] = scrubWorksmobileRequestPassword(nextPayload["request"])
|
||||
return nextPayload
|
||||
}
|
||||
|
||||
func scrubWorksmobileRequestPassword(request any) any {
|
||||
switch v := request.(type) {
|
||||
case WorksmobileUserPayload:
|
||||
v.PasswordConfig.Password = ""
|
||||
return v
|
||||
case WorksmobilePasswordResetPayload:
|
||||
v.PasswordConfig.Password = ""
|
||||
return v
|
||||
case map[string]any:
|
||||
next := make(map[string]any, len(v))
|
||||
for key, value := range v {
|
||||
next[key] = value
|
||||
}
|
||||
next["passwordConfig"] = scrubWorksmobilePasswordConfig(next["passwordConfig"])
|
||||
return next
|
||||
case domain.JSONMap:
|
||||
next := make(domain.JSONMap, len(v))
|
||||
for key, value := range v {
|
||||
next[key] = value
|
||||
}
|
||||
next["passwordConfig"] = scrubWorksmobilePasswordConfig(next["passwordConfig"])
|
||||
return next
|
||||
default:
|
||||
return request
|
||||
}
|
||||
}
|
||||
|
||||
func scrubWorksmobilePasswordConfig(config any) any {
|
||||
switch v := config.(type) {
|
||||
case WorksmobilePasswordConfig:
|
||||
v.Password = ""
|
||||
return v
|
||||
case map[string]any:
|
||||
next := make(map[string]any, len(v))
|
||||
for key, value := range v {
|
||||
next[key] = value
|
||||
}
|
||||
next["password"] = ""
|
||||
return next
|
||||
case domain.JSONMap:
|
||||
next := make(domain.JSONMap, len(v))
|
||||
for key, value := range v {
|
||||
next[key] = value
|
||||
}
|
||||
next["password"] = ""
|
||||
return next
|
||||
default:
|
||||
return config
|
||||
}
|
||||
}
|
||||
|
||||
func worksmobilePayloadHasPassword(payload domain.JSONMap) bool {
|
||||
if stringValue(payload["initialPassword"]) != "" {
|
||||
return true
|
||||
}
|
||||
switch request := payload["request"].(type) {
|
||||
case WorksmobileUserPayload:
|
||||
return strings.TrimSpace(request.PasswordConfig.Password) != ""
|
||||
case WorksmobilePasswordResetPayload:
|
||||
return strings.TrimSpace(request.PasswordConfig.Password) != ""
|
||||
case map[string]any:
|
||||
return worksmobilePasswordConfigHasPassword(request["passwordConfig"])
|
||||
case domain.JSONMap:
|
||||
return worksmobilePasswordConfigHasPassword(request["passwordConfig"])
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func worksmobilePasswordConfigHasPassword(config any) bool {
|
||||
switch v := config.(type) {
|
||||
case WorksmobilePasswordConfig:
|
||||
return strings.TrimSpace(v.Password) != ""
|
||||
case map[string]any:
|
||||
return stringValue(v["password"]) != ""
|
||||
case domain.JSONMap:
|
||||
return stringValue(v["password"]) != ""
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func stringValue(value any) string {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
@@ -842,7 +1203,40 @@ func stringValue(value any) string {
|
||||
}
|
||||
}
|
||||
|
||||
func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []WorksmobileRemoteUser, includeMatched bool, localTenants map[string]domain.Tenant) []WorksmobileComparisonItem {
|
||||
type worksmobileUserJobSummary struct {
|
||||
Status string
|
||||
RetryCount int
|
||||
LastError string
|
||||
LastAttemptAt string
|
||||
}
|
||||
|
||||
func worksmobileUserJobSummaries(jobs []domain.WorksmobileOutbox) map[string]worksmobileUserJobSummary {
|
||||
result := map[string]worksmobileUserJobSummary{}
|
||||
for _, job := range jobs {
|
||||
if job.ResourceType != domain.WorksmobileResourceUser {
|
||||
continue
|
||||
}
|
||||
if job.ResourceID == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := result[job.ResourceID]; exists {
|
||||
continue
|
||||
}
|
||||
result[job.ResourceID] = worksmobileUserJobSummary{
|
||||
Status: job.Status,
|
||||
RetryCount: job.RetryCount,
|
||||
LastError: strings.TrimSpace(job.LastError),
|
||||
LastAttemptAt: job.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []WorksmobileRemoteUser, includeMatched bool, localTenants map[string]domain.Tenant, jobSummaries ...map[string]worksmobileUserJobSummary) []WorksmobileComparisonItem {
|
||||
jobSummaryByUserID := map[string]worksmobileUserJobSummary{}
|
||||
if len(jobSummaries) > 0 && jobSummaries[0] != nil {
|
||||
jobSummaryByUserID = jobSummaries[0]
|
||||
}
|
||||
remoteByExternalID := map[string]WorksmobileRemoteUser{}
|
||||
remoteByEmail := map[string]WorksmobileRemoteUser{}
|
||||
for _, remote := range remoteUsers {
|
||||
@@ -872,7 +1266,8 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
|
||||
if !matched {
|
||||
remote, matched = remoteByEmail[strings.ToLower(strings.TrimSpace(user.Email))]
|
||||
}
|
||||
if matched && !includeMatched {
|
||||
needsUpdate := matched && worksmobileUserNeedsUpdate(user, remote)
|
||||
if matched && !includeMatched && !needsUpdate {
|
||||
matchedRemoteIDs[remote.ID] = true
|
||||
continue
|
||||
}
|
||||
@@ -886,8 +1281,19 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
|
||||
BaronPrimaryOrgName: worksmobileUserPrimaryOrgName(user, localTenants),
|
||||
Status: "missing_in_worksmobile",
|
||||
}
|
||||
if summary, ok := jobSummaryByUserID[user.ID]; ok {
|
||||
item.WorksmobileJobStatus = summary.Status
|
||||
item.WorksmobileJobRetryCount = summary.RetryCount
|
||||
item.WorksmobileLastAttemptAt = summary.LastAttemptAt
|
||||
if summary.Status == domain.WorksmobileOutboxStatusFailed {
|
||||
item.WorksmobileLastError = summary.LastError
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
item.Status = "matched"
|
||||
if needsUpdate {
|
||||
item.Status = "needs_update"
|
||||
}
|
||||
item.WorksmobileID = remote.ID
|
||||
item.ExternalKey = remote.ExternalID
|
||||
item.WorksmobileName = remote.DisplayName
|
||||
@@ -958,6 +1364,62 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
|
||||
return result
|
||||
}
|
||||
|
||||
func worksmobileUserNeedsUpdate(user domain.User, remote WorksmobileRemoteUser) bool {
|
||||
if strings.TrimSpace(remote.ExternalID) != strings.TrimSpace(user.ID) {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(remote.DisplayName) != strings.TrimSpace(user.Name) {
|
||||
return true
|
||||
}
|
||||
if strings.ToLower(strings.TrimSpace(remote.Email)) != strings.ToLower(strings.TrimSpace(user.Email)) {
|
||||
return true
|
||||
}
|
||||
if worksmobileUserManagerNeedsUpdate(user, remote) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func worksmobileUserManagerNeedsUpdate(user domain.User, remote WorksmobileRemoteUser) bool {
|
||||
localManagers := worksmobileUserExplicitOrgUnitManagers(user)
|
||||
if len(localManagers) == 0 {
|
||||
return false
|
||||
}
|
||||
remoteManagers := remote.OrgUnitManagers
|
||||
if len(remoteManagers) == 0 && remote.PrimaryOrgUnitID != "" {
|
||||
remoteManagers = map[string]*bool{remote.PrimaryOrgUnitID: remote.PrimaryOrgUnitIsManager}
|
||||
}
|
||||
for remoteOrgUnitID, remoteManager := range remoteManagers {
|
||||
if remoteManager == nil {
|
||||
continue
|
||||
}
|
||||
localManager, ok := localManagers[worksmobileOrgUnitLocalExternalKey(remoteOrgUnitID)]
|
||||
if ok && localManager != *remoteManager {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func worksmobileUserExplicitOrgUnitManagers(user domain.User) map[string]bool {
|
||||
managers := map[string]bool{}
|
||||
for _, appointment := range worksmobileAppointmentsFromMetadata(user.Metadata) {
|
||||
if appointment.TenantID == "" || !appointment.HasManager {
|
||||
continue
|
||||
}
|
||||
managers[appointment.TenantID] = appointment.IsManager
|
||||
}
|
||||
return managers
|
||||
}
|
||||
|
||||
func worksmobileOrgUnitLocalExternalKey(orgUnitID string) string {
|
||||
normalized := strings.TrimSpace(orgUnitID)
|
||||
if after, ok := strings.CutPrefix(normalized, "externalKey:"); ok {
|
||||
return strings.TrimSpace(after)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func worksmobileUserPrimaryOrgID(user domain.User) string {
|
||||
if user.TenantID == nil {
|
||||
return ""
|
||||
|
||||
@@ -4,11 +4,12 @@ import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWorksmobileSyncServiceRejectsAliasLocalPartAlreadyUsedByOtherUser(t *testing.T) {
|
||||
func TestWorksmobileSyncServiceRejectsAliasEmailAlreadyUsedByOtherUser(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "root-tenant"
|
||||
tenantID := "saman-tenant"
|
||||
@@ -36,7 +37,7 @@ func TestWorksmobileSyncServiceRejectsAliasLocalPartAlreadyUsedByOtherUser(t *te
|
||||
}
|
||||
existing := domain.User{
|
||||
ID: "existing-user",
|
||||
Email: "used@samaneng.com",
|
||||
Email: "used@hanmaceng.co.kr",
|
||||
Name: "Existing",
|
||||
TenantID: &tenantID,
|
||||
}
|
||||
@@ -48,7 +49,7 @@ func TestWorksmobileSyncServiceRejectsAliasLocalPartAlreadyUsedByOtherUser(t *te
|
||||
nil,
|
||||
)
|
||||
|
||||
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID)
|
||||
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "")
|
||||
|
||||
require.Nil(t, item)
|
||||
require.Error(t, err)
|
||||
@@ -88,7 +89,7 @@ func TestWorksmobileSyncServiceEnqueuesSuspendedUserStatusWithOrganizations(t *t
|
||||
nil,
|
||||
)
|
||||
|
||||
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID)
|
||||
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, item)
|
||||
@@ -101,6 +102,253 @@ func TestWorksmobileSyncServiceEnqueuesSuspendedUserStatusWithOrganizations(t *t
|
||||
require.Equal(t, "target@samaneng.com", outboxRepo.created[0].Payload["loginEmail"])
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceEnqueuesUserCredentialBatchID(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,
|
||||
)
|
||||
|
||||
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "batch-1")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, item)
|
||||
require.Len(t, outboxRepo.created, 1)
|
||||
require.Equal(t, "batch-1", outboxRepo.created[0].Payload["credentialBatchId"])
|
||||
require.NotEmpty(t, outboxRepo.created[0].Payload["credentialBatchCreatedAt"])
|
||||
require.Equal(t, "Target", outboxRepo.created[0].Payload["displayName"])
|
||||
require.Equal(t, "Saman", outboxRepo.created[0].Payload["primaryLeafOrgName"])
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceEnqueuesUserPasswordResetCredentialBatch(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "root-tenant"
|
||||
tenantID := "saman-leaf"
|
||||
root := domain.Tenant{
|
||||
ID: rootID,
|
||||
Slug: HanmacFamilyTenantSlug,
|
||||
Name: "Hanmac Family",
|
||||
}
|
||||
tenant := domain.Tenant{
|
||||
ID: tenantID,
|
||||
Slug: "people-growth",
|
||||
Name: "인재성장",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
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,
|
||||
)
|
||||
|
||||
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, "reset-batch-1", outboxRepo.created[0].Payload["credentialBatchId"])
|
||||
require.Equal(t, "worksmobile_password_reset", outboxRepo.created[0].Payload["credentialOperation"])
|
||||
require.NotEmpty(t, outboxRepo.created[0].Payload["credentialBatchCreatedAt"])
|
||||
require.Equal(t, "target@samaneng.com", outboxRepo.created[0].Payload["loginEmail"])
|
||||
require.Equal(t, "Target", outboxRepo.created[0].Payload["displayName"])
|
||||
require.Equal(t, "인재성장", outboxRepo.created[0].Payload["primaryLeafOrgName"])
|
||||
require.NotEmpty(t, outboxRepo.created[0].Payload["initialPassword"])
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceFiltersInitialPasswordsByCredentialBatchID(t *testing.T) {
|
||||
rootID := "root-tenant"
|
||||
root := domain.Tenant{
|
||||
ID: rootID,
|
||||
Slug: HanmacFamilyTenantSlug,
|
||||
Name: "Hanmac Family",
|
||||
}
|
||||
outboxRepo := &fakeWorksmobileOutboxRepo{
|
||||
credentialBatchJobs: []domain.WorksmobileOutbox{
|
||||
{
|
||||
ResourceType: domain.WorksmobileResourceUser,
|
||||
Status: domain.WorksmobileOutboxStatusProcessed,
|
||||
Payload: domain.JSONMap{
|
||||
"tenantRootId": rootID,
|
||||
"loginEmail": "batch-user@samaneng.com",
|
||||
"displayName": "Batch User",
|
||||
"primaryLeafOrgName": "인재성장",
|
||||
"initialPassword": "BatchPass1!",
|
||||
"credentialBatchId": "batch-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
ResourceType: domain.WorksmobileResourceUser,
|
||||
Status: domain.WorksmobileOutboxStatusProcessed,
|
||||
Payload: domain.JSONMap{
|
||||
"tenantRootId": rootID,
|
||||
"loginEmail": "other-user@samaneng.com",
|
||||
"initialPassword": "OtherPass1!",
|
||||
"credentialBatchId": "batch-2",
|
||||
},
|
||||
},
|
||||
{
|
||||
ResourceType: domain.WorksmobileResourceUser,
|
||||
Status: domain.WorksmobileOutboxStatusProcessed,
|
||||
Payload: domain.JSONMap{
|
||||
"tenantRootId": rootID,
|
||||
"loginEmail": "legacy-user@samaneng.com",
|
||||
"initialPassword": "LegacyPass1!",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
service := NewWorksmobileSyncService(
|
||||
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root}, list: []domain.Tenant{root}},
|
||||
&fakeWorksmobileUserRepo{},
|
||||
outboxRepo,
|
||||
nil,
|
||||
)
|
||||
|
||||
credentials, err := service.ListInitialPasswordCredentials(context.Background(), rootID, "batch-1")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []WorksmobileInitialPasswordCredential{
|
||||
{
|
||||
Email: "batch-user@samaneng.com",
|
||||
Name: "Batch User",
|
||||
PrimaryLeafOrgName: "인재성장",
|
||||
InitialPassword: "BatchPass1!",
|
||||
Status: domain.WorksmobileOutboxStatusProcessed,
|
||||
},
|
||||
}, credentials)
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceDeletesCredentialBatchPasswordsButKeepsHistory(t *testing.T) {
|
||||
rootID := "root-tenant"
|
||||
root := domain.Tenant{
|
||||
ID: rootID,
|
||||
Slug: HanmacFamilyTenantSlug,
|
||||
Name: "Hanmac Family",
|
||||
}
|
||||
outboxRepo := &fakeWorksmobileOutboxRepo{
|
||||
credentialBatchJobs: []domain.WorksmobileOutbox{
|
||||
{
|
||||
ID: "job-1",
|
||||
ResourceType: domain.WorksmobileResourceUser,
|
||||
Status: domain.WorksmobileOutboxStatusProcessed,
|
||||
Payload: domain.JSONMap{
|
||||
"tenantRootId": rootID,
|
||||
"loginEmail": "batch-user@samaneng.com",
|
||||
"initialPassword": "BatchPass1!",
|
||||
"credentialBatchId": "batch-1",
|
||||
"credentialOperation": "worksmobile_user_sync",
|
||||
"request": map[string]any{"passwordConfig": map[string]any{"password": "BatchPass1!"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "job-2",
|
||||
ResourceID: "failed-user",
|
||||
ResourceType: domain.WorksmobileResourceUser,
|
||||
Status: domain.WorksmobileOutboxStatusFailed,
|
||||
RetryCount: 2,
|
||||
LastError: "worksmobile api failed",
|
||||
Payload: domain.JSONMap{
|
||||
"tenantRootId": rootID,
|
||||
"loginEmail": "failed-user@samaneng.com",
|
||||
"initialPassword": "FailedPass1!",
|
||||
"credentialBatchId": "batch-1",
|
||||
"credentialOperation": "worksmobile_user_sync",
|
||||
"request": map[string]any{"passwordConfig": map[string]any{"password": "FailedPass1!"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
service := NewWorksmobileSyncService(
|
||||
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root}, list: []domain.Tenant{root}},
|
||||
&fakeWorksmobileUserRepo{},
|
||||
outboxRepo,
|
||||
nil,
|
||||
)
|
||||
|
||||
before, err := service.ListCredentialBatches(context.Background(), rootID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, before, 1)
|
||||
require.True(t, before[0].HasPasswords)
|
||||
require.Equal(t, 1, before[0].FailedCount)
|
||||
require.Len(t, before[0].Failures, 1)
|
||||
require.Equal(t, "failed-user", before[0].Failures[0].UserID)
|
||||
require.Equal(t, "failed-user@samaneng.com", before[0].Failures[0].Email)
|
||||
require.Equal(t, "worksmobile api failed", before[0].Failures[0].LastError)
|
||||
|
||||
after, err := service.DeleteCredentialBatchPasswords(context.Background(), rootID, "batch-1")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "batch-1", after.BatchID)
|
||||
require.False(t, after.HasPasswords)
|
||||
require.Equal(t, 2, after.UserCount)
|
||||
require.NotEmpty(t, after.DeletedAt)
|
||||
require.Len(t, outboxRepo.payloadUpdates, 2)
|
||||
require.Empty(t, stringValue(outboxRepo.payloadUpdates[0]["initialPassword"]))
|
||||
require.Empty(t, stringValue(outboxRepo.payloadUpdates[1]["initialPassword"]))
|
||||
request := outboxRepo.payloadUpdates[0]["request"].(map[string]any)
|
||||
passwordConfig := request["passwordConfig"].(map[string]any)
|
||||
require.Empty(t, stringValue(passwordConfig["password"]))
|
||||
}
|
||||
|
||||
func TestAggregateWorksmobileCredentialBatchesUsesCredentialBatchCreatedAt(t *testing.T) {
|
||||
oldCreatedAt := time.Date(2026, 5, 29, 1, 4, 15, 0, time.UTC)
|
||||
batchCreatedAt := time.Date(2026, 6, 1, 7, 20, 0, 0, time.UTC)
|
||||
|
||||
batches := aggregateWorksmobileCredentialBatches([]domain.WorksmobileOutbox{
|
||||
{
|
||||
ID: "job-1",
|
||||
CreatedAt: oldCreatedAt,
|
||||
UpdatedAt: batchCreatedAt.Add(time.Minute),
|
||||
Status: domain.WorksmobileOutboxStatusPending,
|
||||
Payload: domain.JSONMap{
|
||||
"credentialBatchId": "batch-1",
|
||||
"credentialOperation": "worksmobile_user_sync",
|
||||
"credentialBatchCreatedAt": batchCreatedAt.Format(time.RFC3339),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
require.Len(t, batches, 1)
|
||||
require.Equal(t, batchCreatedAt, batches[0].CreatedAt)
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceDeprovisionsArchivedUser(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "root-tenant"
|
||||
@@ -133,7 +381,7 @@ func TestWorksmobileSyncServiceDeprovisionsArchivedUser(t *testing.T) {
|
||||
nil,
|
||||
)
|
||||
|
||||
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID)
|
||||
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, item)
|
||||
@@ -1139,6 +1387,95 @@ func TestWorksmobileSyncServiceBackfillDryRunSkipsArchivedUsers(t *testing.T) {
|
||||
require.Equal(t, 1, outboxRepo.created[0].Payload["userCount"])
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersMarksManagerChangeNeedsUpdate(t *testing.T) {
|
||||
tenantID := "tenant-leaf"
|
||||
user := domain.User{
|
||||
ID: "user-manager",
|
||||
Email: "manager@samaneng.com",
|
||||
Name: "Manager User",
|
||||
TenantID: &tenantID,
|
||||
Status: domain.UserStatusActive,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": tenantID,
|
||||
"isPrimary": true,
|
||||
"isManager": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
remoteManager := false
|
||||
items := compareWorksmobileUsers(
|
||||
[]domain.User{user},
|
||||
[]WorksmobileRemoteUser{{
|
||||
ID: "works-user-manager",
|
||||
ExternalID: user.ID,
|
||||
Email: user.Email,
|
||||
DisplayName: user.Name,
|
||||
PrimaryOrgUnitID: "externalKey:" + tenantID,
|
||||
PrimaryOrgUnitIsManager: &remoteManager,
|
||||
}},
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
tenantID: {ID: tenantID, Name: "Leaf", Type: domain.TenantTypeOrganization},
|
||||
},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "needs_update", items[0].Status)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersMarksSecondaryManagerChangeNeedsUpdate(t *testing.T) {
|
||||
primaryTenantID := "tenant-company"
|
||||
secondaryTenantID := "tenant-gpdtdc-leaf"
|
||||
user := domain.User{
|
||||
ID: "user-secondary-manager",
|
||||
Email: "secondary-manager@samaneng.com",
|
||||
Name: "Secondary Manager User",
|
||||
TenantID: &secondaryTenantID,
|
||||
Status: domain.UserStatusActive,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": primaryTenantID,
|
||||
"isPrimary": true,
|
||||
},
|
||||
map[string]any{
|
||||
"tenantId": secondaryTenantID,
|
||||
"isPrimary": false,
|
||||
"isManager": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
remotePrimaryManager := false
|
||||
remoteSecondaryManager := false
|
||||
items := compareWorksmobileUsers(
|
||||
[]domain.User{user},
|
||||
[]WorksmobileRemoteUser{{
|
||||
ID: "works-user-secondary-manager",
|
||||
ExternalID: user.ID,
|
||||
Email: user.Email,
|
||||
DisplayName: user.Name,
|
||||
PrimaryOrgUnitID: "externalKey:" + primaryTenantID,
|
||||
PrimaryOrgUnitIsManager: &remotePrimaryManager,
|
||||
OrgUnitManagers: map[string]*bool{
|
||||
"externalKey:" + primaryTenantID: &remotePrimaryManager,
|
||||
"externalKey:" + secondaryTenantID: &remoteSecondaryManager,
|
||||
},
|
||||
}},
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
primaryTenantID: {ID: primaryTenantID, Name: "Company", Type: domain.TenantTypeCompany},
|
||||
secondaryTenantID: {ID: secondaryTenantID, Name: "GPDTDC Leaf", Type: domain.TenantTypeOrganization},
|
||||
},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "needs_update", items[0].Status)
|
||||
}
|
||||
|
||||
type fakeWorksmobileTenantService struct {
|
||||
tenants map[string]domain.Tenant
|
||||
list []domain.Tenant
|
||||
|
||||
78
common/pnpm-lock.yaml
generated
78
common/pnpm-lock.yaml
generated
@@ -63,8 +63,8 @@ importers:
|
||||
specifier: ^3.3.0
|
||||
version: 3.3.1(oidc-client-ts@3.5.0)(react@19.2.6)
|
||||
react-router-dom:
|
||||
specifier: ^6.28.2
|
||||
version: 6.30.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
specifier: ^7.15.1
|
||||
version: 7.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
tailwind-merge:
|
||||
specifier: ^3.4.0
|
||||
version: 3.6.0
|
||||
@@ -469,7 +469,7 @@ importers:
|
||||
specifier: ^8.0.14
|
||||
version: 8.0.14(@types/node@25.7.0)(jiti@1.21.7)
|
||||
vitest:
|
||||
specifier: ^4.1.6
|
||||
specifier: 4.1.6
|
||||
version: 4.1.6(@types/node@25.7.0)(@vitest/coverage-v8@4.1.6)(jsdom@28.1.0)(vite@8.0.14(@types/node@25.7.0)(jiti@1.21.7))
|
||||
|
||||
packages:
|
||||
@@ -549,28 +549,24 @@ packages:
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@biomejs/cli-linux-arm64@2.4.16':
|
||||
resolution: {integrity: sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@biomejs/cli-linux-x64-musl@2.4.16':
|
||||
resolution: {integrity: sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@biomejs/cli-linux-x64@2.4.16':
|
||||
resolution: {integrity: sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@biomejs/cli-win32-arm64@2.4.16':
|
||||
resolution: {integrity: sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A==}
|
||||
@@ -1095,10 +1091,6 @@ packages:
|
||||
'@radix-ui/rect@1.1.1':
|
||||
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
||||
|
||||
'@remix-run/router@1.23.2':
|
||||
resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.0':
|
||||
resolution: {integrity: sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -1164,84 +1156,72 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-arm64-gnu@1.0.2':
|
||||
resolution: {integrity: sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.0.0':
|
||||
resolution: {integrity: sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.0.2':
|
||||
resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.0.0':
|
||||
resolution: {integrity: sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.0.2':
|
||||
resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-s390x-gnu@1.0.0':
|
||||
resolution: {integrity: sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-s390x-gnu@1.0.2':
|
||||
resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.0.0':
|
||||
resolution: {integrity: sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.0.2':
|
||||
resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.0.0':
|
||||
resolution: {integrity: sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.0.2':
|
||||
resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.0.0':
|
||||
resolution: {integrity: sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==}
|
||||
@@ -1940,28 +1920,24 @@ packages:
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.32.0:
|
||||
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.32.0:
|
||||
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
|
||||
@@ -2219,13 +2195,6 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
react-router-dom@6.30.3:
|
||||
resolution: {integrity: sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
react: '>=16.8'
|
||||
react-dom: '>=16.8'
|
||||
|
||||
react-router-dom@7.15.0:
|
||||
resolution: {integrity: sha512-VcrVg64Fo8nwBvDscajG8gRTLIuTC6N50nb22l2HOOV4PTOHgoGp8mUjy9wLiHYoYTSYI36tUnXZgasSRFZorQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -2233,11 +2202,12 @@ packages:
|
||||
react: '>=18'
|
||||
react-dom: '>=18'
|
||||
|
||||
react-router@6.30.3:
|
||||
resolution: {integrity: sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
react-router-dom@7.16.0:
|
||||
resolution: {integrity: sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
react: '>=16.8'
|
||||
react: '>=18'
|
||||
react-dom: '>=18'
|
||||
|
||||
react-router@7.15.0:
|
||||
resolution: {integrity: sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ==}
|
||||
@@ -2249,6 +2219,16 @@ packages:
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
react-router@7.16.0:
|
||||
resolution: {integrity: sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
react: '>=18'
|
||||
react-dom: '>=18'
|
||||
peerDependenciesMeta:
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
react-style-singleton@2.2.3:
|
||||
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -3210,8 +3190,6 @@ snapshots:
|
||||
|
||||
'@radix-ui/rect@1.1.1': {}
|
||||
|
||||
'@remix-run/router@1.23.2': {}
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.0':
|
||||
optional: true
|
||||
|
||||
@@ -4199,23 +4177,17 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
react-router-dom@6.30.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||
dependencies:
|
||||
'@remix-run/router': 1.23.2
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
react-router: 6.30.3(react@19.2.6)
|
||||
|
||||
react-router-dom@7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
react-router: 7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
|
||||
react-router@6.30.3(react@19.2.6):
|
||||
react-router-dom@7.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||
dependencies:
|
||||
'@remix-run/router': 1.23.2
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
react-router: 7.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
|
||||
react-router@7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||
dependencies:
|
||||
@@ -4225,6 +4197,14 @@ snapshots:
|
||||
optionalDependencies:
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
|
||||
react-router@7.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||
dependencies:
|
||||
cookie: 1.1.1
|
||||
react: 19.2.6
|
||||
set-cookie-parser: 2.7.2
|
||||
optionalDependencies:
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
|
||||
react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.6):
|
||||
dependencies:
|
||||
get-nonce: 1.0.1
|
||||
|
||||
@@ -106,7 +106,7 @@ ensure_frontend_dependencies() {
|
||||
return 0
|
||||
fi
|
||||
if [ "$WORKSPACE_DIR" = "/workspace/common" ]; then
|
||||
(cd /workspace/common && CI=true pnpm install --filter "${APP_WORKSPACE_FILTER}..." --no-frozen-lockfile --ignore-scripts)
|
||||
(cd /workspace/common && CI=true pnpm install --filter "${APP_WORKSPACE_FILTER}..." --frozen-lockfile --ignore-scripts)
|
||||
else
|
||||
npm ci
|
||||
fi
|
||||
|
||||
349
docs/backup-restore-design.md
Normal file
349
docs/backup-restore-design.md
Normal file
@@ -0,0 +1,349 @@
|
||||
# Baron SSO 전체 시스템 백업 및 복구 설계
|
||||
|
||||
## 목적
|
||||
|
||||
Baron SSO의 전체 시스템 백업/복구는 CSV export/import의 확장판이 아니라, 서비스 저장소 전체를 일관된 시점으로 보존하고 복원하는 재해 복구 기능이다.
|
||||
|
||||
핵심 목표는 다음과 같다.
|
||||
|
||||
- 사용자, 조직, 권한, RP, WORKS 연동 참조에 쓰이는 UUID를 그대로 보존한다.
|
||||
- Kratos identity subject와 Baron local user ID가 어긋나지 않게 복구한다.
|
||||
- Hydra/Keto/Oathkeeper 기반 인증/인가 상태를 서비스 가능한 수준으로 복원한다.
|
||||
- 복구 후 WORKS externalKey 기반 비교/동기화가 기존 연동과 이어지도록 한다.
|
||||
- 백업 산출물의 무결성, 보안, 복구 가능성을 검증 가능한 절차로 만든다.
|
||||
|
||||
## 배경과 결론
|
||||
|
||||
사용자 CSV는 `user_id`를 포함해 내보낼 수 있지만, 실제 사용자 계정의 주체 ID는 Kratos identity ID다. Kratos Admin API의 identity 생성 요청은 `id` 필드를 받지 않으므로, CSV import만으로 기존 사용자 UUID를 보장할 수 없다.
|
||||
|
||||
따라서 사용자 UUID 보존이 필요한 복구는 반드시 Kratos DB까지 포함한 full backup/restore로 처리해야 한다. CSV import는 운영 편의 기능으로 유지하되, 재해 복구나 WORKS 연동 보존 목적의 원장 복구 수단으로 간주하지 않는다.
|
||||
|
||||
## 백업 대상
|
||||
|
||||
### 필수 저장소
|
||||
|
||||
| 대상 | 저장소 | 포함 이유 | 복구 필수 여부 |
|
||||
| --- | --- | --- | --- |
|
||||
| Baron 애플리케이션 DB | `baron_postgres` | users, tenants, user_login_ids, user_groups, api_keys, outbox, WORKS mapping, RP metadata 등 | 필수 |
|
||||
| Kratos DB | `ory_postgres`의 `ory_kratos` | identity UUID, credentials, verifiable/recovery addresses, sessions | 필수 |
|
||||
| Hydra DB | `ory_postgres`의 `ory_hydra` | OAuth2 clients, consent, token/session 관련 상태 | 필수 |
|
||||
| Keto DB | `ory_postgres`의 `ory_keto` | ReBAC relation tuple 원장 | 필수 |
|
||||
| Baron ClickHouse | `baron_clickhouse` | 감사 로그, 운영 추적 데이터 | 운영 정책상 필수 |
|
||||
| Ory ClickHouse | `ory_clickhouse` | Ory/Oathkeeper/Vector 계열 로그 | 운영 정책상 필수 |
|
||||
| 설정/비밀값 | `.env`, generated Ory config, WORKS private key, gateway/Oathkeeper config | 동일 환경 재기동과 외부 연동에 필요 | 필수 |
|
||||
|
||||
### 선택 저장소
|
||||
|
||||
| 대상 | 처리 원칙 |
|
||||
| --- | --- |
|
||||
| Redis | 로그인 pending state, cache, short code 등 휘발성 데이터다. full restore에서는 원칙적으로 제외하고 재시작 시 비운다. 무중단 이전 시나리오에서만 snapshot을 검토한다. |
|
||||
| 프론트 빌드 산출물 | 소스/이미지 태그로 재생성한다. 별도 보관은 배포 재현성을 위한 선택 항목이다. |
|
||||
| 로컬 개발 산출물 | reports, coverage, test-results 등은 백업 대상에서 제외한다. |
|
||||
|
||||
## 백업 산출물 형식
|
||||
|
||||
백업 단위는 압축된 디렉터리 또는 object storage prefix로 관리한다.
|
||||
|
||||
```text
|
||||
baron-sso-backup-YYYYMMDD-HHMMSSZ/
|
||||
manifest.json
|
||||
checksums.sha256
|
||||
postgres/
|
||||
baron.dump
|
||||
ory_kratos.dump
|
||||
ory_hydra.dump
|
||||
ory_keto.dump
|
||||
globals.sql
|
||||
clickhouse/
|
||||
baron_clickhouse/
|
||||
ory_clickhouse/
|
||||
config/
|
||||
env.redacted
|
||||
env.encrypted
|
||||
ory/
|
||||
gateway/
|
||||
reports/
|
||||
row-counts.json
|
||||
restore-readiness.json
|
||||
```
|
||||
|
||||
`manifest.json`에는 최소한 다음 정보를 기록한다.
|
||||
|
||||
- backup format version
|
||||
- 생성 시각, git commit, 이미지 태그
|
||||
- 서비스별 DB schema/migration version
|
||||
- 각 dump 파일의 checksum, 크기, 생성 명령 버전
|
||||
- 백업 모드: `offline`, `maintenance`, `online-best-effort`
|
||||
- 암호화 방식과 key id
|
||||
- 복구 대상 환경 제한: `same-env-only`, `staging-rehearsal`, `cross-env`
|
||||
|
||||
## 백업 모드
|
||||
|
||||
### 1. Offline backup
|
||||
|
||||
가장 안전한 모드다. 모든 writer를 중지한 뒤 dump한다.
|
||||
|
||||
순서:
|
||||
|
||||
1. gateway 또는 Oathkeeper에서 maintenance mode 활성화
|
||||
2. backend, relay worker, vector 등 write producer 중지
|
||||
3. Kratos/Hydra/Keto public/admin 요청 차단 또는 컨테이너 중지
|
||||
4. Baron Postgres dump
|
||||
5. Ory Postgres의 Kratos/Hydra/Keto DB dump
|
||||
6. ClickHouse backup
|
||||
7. 설정/비밀값 백업
|
||||
8. checksum과 row count 생성
|
||||
9. 서비스 재개
|
||||
|
||||
이 모드는 사용자 UUID, WORKS mapping, Keto relation, OAuth consent 상태의 일관성이 가장 좋다.
|
||||
|
||||
### 2. Maintenance backup
|
||||
|
||||
짧은 점검 모드에서 writer만 막고 read는 제한적으로 허용한다. 운영 기본 모드로 권장한다.
|
||||
|
||||
필수 조건:
|
||||
|
||||
- 사용자 생성/삭제/수정 차단
|
||||
- 테넌트/조직 변경 차단
|
||||
- WORKS outbox relay 중지
|
||||
- Keto outbox relay 중지
|
||||
- OAuth client 변경 차단
|
||||
|
||||
### 3. Online best-effort backup
|
||||
|
||||
무중단 스냅샷이다. 저장소가 여러 개라 cross-store 일관성을 보장할 수 없다. 감사 로그나 분석용 백업에는 가능하지만, 재해 복구용 원본으로는 사용하지 않는다.
|
||||
|
||||
## Postgres 백업 전략
|
||||
|
||||
Postgres는 논리 dump를 기본으로 한다.
|
||||
|
||||
- `pg_dump -Fc` 형식 사용
|
||||
- DB별 dump 파일 분리
|
||||
- `pg_dumpall --globals-only`로 role/extension/권한 정보 별도 보관
|
||||
- 백업 전후 row count와 핵심 UUID sample 기록
|
||||
|
||||
대상 DB:
|
||||
|
||||
- Baron DB: `DB_NAME`
|
||||
- Kratos DB: `ory_kratos`
|
||||
- Hydra DB: `ory_hydra`
|
||||
- Keto DB: `ory_keto`
|
||||
|
||||
복구는 빈 DB에 `pg_restore --clean --if-exists`로 수행한다. 기존 운영 DB에 덮어쓰는 in-place restore는 금지한다.
|
||||
|
||||
## ClickHouse 백업 전략
|
||||
|
||||
ClickHouse는 감사 로그 성격이 강하므로 정책을 분리한다.
|
||||
|
||||
- 재해 복구: ClickHouse native backup 또는 volume snapshot 사용
|
||||
- 장기 보관: 파티션 단위 export 또는 object storage backup
|
||||
- 복구 검증: 날짜 파티션별 row count와 min/max timestamp 비교
|
||||
|
||||
ClickHouse 백업 실패가 인증 기능 복구를 막지는 않지만, 감사 로그 보존 정책상 별도 실패로 취급한다.
|
||||
|
||||
## Redis 처리 원칙
|
||||
|
||||
Redis는 기본적으로 백업하지 않는다.
|
||||
|
||||
복구 후 영향:
|
||||
|
||||
- 로그인 pending flow 만료
|
||||
- short code/link login flow 재시작 필요
|
||||
- headless JWKS cache 재생성
|
||||
- 세션 cache miss 발생 가능
|
||||
|
||||
Kratos/Hydra 자체 session/token 원장은 Postgres 쪽에 있으므로 Redis가 비어 있어도 서비스는 재수렴해야 한다.
|
||||
|
||||
## 설정과 비밀값
|
||||
|
||||
DB dump만으로는 복구가 불완전하다. 다음 항목은 암호화해서 함께 보관한다.
|
||||
|
||||
- `.env` 또는 배포 환경 변수
|
||||
- Ory generated config
|
||||
- Hydra system secret, cookie secret
|
||||
- Kratos courier/config secret
|
||||
- Keto config
|
||||
- Oathkeeper rules/config
|
||||
- WORKS Admin OAuth client private key
|
||||
- API gateway 설정
|
||||
- object storage backup key id
|
||||
|
||||
`env.redacted`는 검토용이고, 실제 복구에는 `env.encrypted`만 사용한다.
|
||||
|
||||
## 복구 절차
|
||||
|
||||
### Full restore 기본 절차
|
||||
|
||||
1. 대상 환경을 새로 준비한다.
|
||||
2. 모든 애플리케이션 서비스를 중지한다.
|
||||
3. 기존 DB가 있으면 별도 보관 후 빈 DB를 만든다.
|
||||
4. Postgres globals를 복구한다.
|
||||
5. Baron DB를 복구한다.
|
||||
6. Kratos/Hydra/Keto DB를 복구한다.
|
||||
7. ClickHouse를 복구한다.
|
||||
8. 설정/비밀값을 복호화해 배치한다.
|
||||
9. migration은 자동 실행하지 않고 현재 dump의 schema version을 확인한다.
|
||||
10. Ory Stack을 기동한다.
|
||||
11. backend를 기동한다.
|
||||
12. relay worker는 아직 켜지 않는다.
|
||||
13. post-restore verification을 수행한다.
|
||||
14. WORKS comparison dry-run을 수행한다.
|
||||
15. 문제가 없을 때 relay worker와 외부 동기화를 재개한다.
|
||||
|
||||
### Post-restore verification
|
||||
|
||||
필수 검증:
|
||||
|
||||
- Kratos identity 수와 Baron users 수 비교
|
||||
- Baron `users.id`가 Kratos `identities.id`에 존재하는지 확인
|
||||
- tenant parent tree 참조 무결성 확인
|
||||
- `user_login_ids.user_id`, `user_login_ids.tenant_id` 참조 무결성 확인
|
||||
- Keto relation subject/object가 복구된 사용자/테넌트를 참조하는지 확인
|
||||
- Hydra client와 Baron RP metadata 참조 확인
|
||||
- WORKS mapping의 BaronResourceID가 복구된 사용자/테넌트를 참조하는지 확인
|
||||
- super admin 로그인 확인
|
||||
- 일반 사용자 로그인 확인
|
||||
- 대표 RP OIDC login/consent 확인
|
||||
- WORKS comparison dry-run에서 externalKey 기준 대량 삭제/생성 후보가 없는지 확인
|
||||
|
||||
## WORKS 연동 복구 정책
|
||||
|
||||
WORKS 자체 데이터는 Baron 백업으로 복구하지 않는다. Baron이 보존해야 하는 것은 WORKS와 연결되는 참조 키다.
|
||||
|
||||
필수 보존:
|
||||
|
||||
- 사용자 UUID
|
||||
- 테넌트 UUID
|
||||
- WORKS resource mapping
|
||||
- WORKS outbox 처리 상태
|
||||
- WORKS domain mapping/config
|
||||
|
||||
복구 직후 정책:
|
||||
|
||||
- relay worker 자동 실행 금지
|
||||
- comparison dry-run 먼저 실행
|
||||
- externalKey 기준으로 Baron/WORKS가 매칭되는지 확인
|
||||
- 대량 delete/upsert가 감지되면 동기화 중단
|
||||
- 확인 후 필요한 사용자/조직만 재동기화
|
||||
|
||||
outbox는 복구 모드에 따라 처리한다.
|
||||
|
||||
| 복구 모드 | outbox 정책 |
|
||||
| --- | --- |
|
||||
| 같은 운영 환경 재해 복구 | outbox 상태 보존, 단 relay 재개 전 dry-run 필수 |
|
||||
| staging rehearsal | outbox relay 비활성화, 외부 WORKS 호출 금지 |
|
||||
| cross-env migration | outbox는 보존하되 실행하지 않고 별도 remap 정책 필요 |
|
||||
|
||||
## CSV export/import의 위치
|
||||
|
||||
CSV는 다음 목적으로만 사용한다.
|
||||
|
||||
- 운영자가 사용자/조직을 일괄 등록하거나 보정
|
||||
- 일부 필드를 검토하기 위한 추출
|
||||
- dry-run 입력 보조 자료
|
||||
|
||||
CSV는 다음 목적에 사용하지 않는다.
|
||||
|
||||
- Kratos identity UUID 보존 복구
|
||||
- 비밀번호/credential 복구
|
||||
- OAuth consent/token/session 복구
|
||||
- Keto relation 원장 복구
|
||||
- WORKS mapping 원장 복구
|
||||
|
||||
## 구현 계획
|
||||
|
||||
### Phase 1: 백업/복구 스크립트와 문서화
|
||||
|
||||
Estimate Time: 3~5d
|
||||
|
||||
- `scripts/backup/full-backup.sh`
|
||||
- `scripts/backup/full-restore.sh`
|
||||
- `scripts/backup/verify-backup.sh`
|
||||
- `scripts/backup/verify-restore.sh`
|
||||
- `manifest.json` 생성기
|
||||
- checksum 생성
|
||||
- row count report 생성
|
||||
|
||||
### Phase 2: staging restore rehearsal
|
||||
|
||||
Estimate Time: 3~5d
|
||||
|
||||
- 백업 파일로 격리된 staging stack 복구
|
||||
- Ory/Baron/Postgres/ClickHouse 복구 검증
|
||||
- 로그인/OIDC/관리자 화면 smoke test
|
||||
- WORKS 외부 호출 차단 상태에서 comparison dry-run
|
||||
|
||||
### Phase 3: 운영 자동화
|
||||
|
||||
Estimate Time: 5~8d
|
||||
|
||||
- 정기 백업 스케줄링
|
||||
- 암호화 및 object storage 업로드
|
||||
- retention 정책
|
||||
- 실패 알림
|
||||
- restore rehearsal 주기화
|
||||
|
||||
### Phase 4: 관리 UI
|
||||
|
||||
Estimate Time: 5~8d
|
||||
|
||||
- backup 목록 조회
|
||||
- backup 생성 요청
|
||||
- restore readiness report 조회
|
||||
- staging rehearsal 결과 조회
|
||||
- 운영 restore는 UI에서 직접 실행하지 않고 승인 절차와 runbook으로 제한
|
||||
|
||||
## 테스트 전략
|
||||
|
||||
### 단위 테스트
|
||||
|
||||
- manifest 생성 검증
|
||||
- checksum 검증
|
||||
- row count report 비교
|
||||
- restore readiness parser 검증
|
||||
|
||||
### 통합 테스트
|
||||
|
||||
- fixture DB 생성
|
||||
- full backup 실행
|
||||
- 빈 DB로 restore
|
||||
- 핵심 테이블 row count 비교
|
||||
- 사용자/테넌트 UUID 동일성 비교
|
||||
- Kratos identity와 Baron user ID 일치 검증
|
||||
|
||||
### E2E 테스트
|
||||
|
||||
- super admin 로그인
|
||||
- 일반 사용자 로그인
|
||||
- AdminFront 사용자 목록 조회
|
||||
- UserFront 로그인
|
||||
- 대표 RP OIDC 로그인
|
||||
- WORKS comparison dry-run
|
||||
|
||||
### 실패 테스트
|
||||
|
||||
- 누락된 dump 파일
|
||||
- checksum 불일치
|
||||
- schema version 불일치
|
||||
- 일부 DB만 복구된 상태
|
||||
- Kratos identity는 있는데 Baron user가 없는 상태
|
||||
- Baron user는 있는데 Kratos identity가 없는 상태
|
||||
- WORKS mapping이 복구되지 않은 상태
|
||||
|
||||
## 운영 정책
|
||||
|
||||
- 백업은 암호화하지 않은 상태로 저장하지 않는다.
|
||||
- 운영 restore는 빈 환경 또는 새 볼륨에만 수행한다.
|
||||
- restore 전 현재 운영 DB는 별도 snapshot으로 보존한다.
|
||||
- restore 후 WORKS relay는 수동 승인 전까지 비활성화한다.
|
||||
- 월 1회 이상 staging restore rehearsal을 수행한다.
|
||||
- schema migration 직전 수동 backup을 강제한다.
|
||||
|
||||
## 남은 결정 사항
|
||||
|
||||
- RPO/RTO 목표값
|
||||
- 백업 저장 위치와 암호화 key 관리 방식
|
||||
- ClickHouse 장기 보관 기간
|
||||
- WORKS outbox replay 정책의 운영 기본값
|
||||
- 운영 restore 승인자와 절차
|
||||
- restore rehearsal 자동 실행 주기
|
||||
@@ -106,7 +106,7 @@ ensure_frontend_dependencies() {
|
||||
return 0
|
||||
fi
|
||||
if [ "$WORKSPACE_DIR" = "/workspace/common" ]; then
|
||||
(cd /workspace/common && CI=true pnpm install --filter "${APP_WORKSPACE_FILTER}..." --no-frozen-lockfile --ignore-scripts)
|
||||
(cd /workspace/common && CI=true pnpm install --filter "${APP_WORKSPACE_FILTER}..." --frozen-lockfile --ignore-scripts)
|
||||
else
|
||||
npm ci
|
||||
fi
|
||||
|
||||
@@ -69,7 +69,7 @@ tskim@samaneng.com,김태식A,010-9965-9940,user,rnd-saman,,,,,222182,design-pla
|
||||
jhkang@samaneng.com,강정훈,010-9891-8798,user,rnd-saman,,,,,222212,strana,,연구원,,,B22048,b22048@hanmaceng.co.kr
|
||||
jhkim14@samaneng.com,김재현,010-2534-7837,user,rnd-saman,,,,,222231,watch-bim,,수석연구원,,,B22051,b22051@hanmaceng.co.kr
|
||||
yjchoi1@samaneng.com,최윤진,010-2349-6687,user,rnd-saman,,,,,222240,way-draw,,연구원,,,B22052,b22052@hanmaceng.co.kr
|
||||
wkkim@samaneng.com,김원기,010-4727-8530,user,rnd-saman,,,,,222242,infra-bim1,,책임연구원,,,B22057,kwongi79@hanmaceng.co.kr
|
||||
wkkim@samaneng.com,김원기,010-4727-8530,user,rnd-saman,,,,,222242,infra-bim1,,책임연구원,,,B22057,
|
||||
jhlee@samaneng.com,이준호,010-2514-6898,user,rnd-saman,,,,,223046,structural-software,,연구원,,,B23003,b23003@hanmaceng.co.kr
|
||||
jhchoi3@samaneng.com,최진헌,010-8638-8079,user,rnd-saman,,,,,222272,strana,,선임연구원,,,B22063,b22063@hanmaceng.co.kr
|
||||
hulee1@samaneng.com,이한울,010-9271-8997,user,rnd-saman,,,,,222294,web-design,,연구원,,,B22069,b22069@hanmaceng.co.kr
|
||||
@@ -94,7 +94,7 @@ hmin@samaneng.com,민홍,010-8654-5461,user,rnd-saman,,,,,223313,gsim,,선임연
|
||||
hwan@samaneng.com,안효원,010-3358-4260,user,rnd-saman,,,,,223228,infra-bim1,,선임연구원,,,B23040,b23040@hanmaceng.co.kr
|
||||
sihan@samaneng.com,한성일,010-4322-1100,user,rnd-saman,,,,,223226,abut-control,,책임연구원,,,B23042,b23042@hanmaceng.co.kr
|
||||
jhkim25@samaneng.com,김재환,010-8962-3743,user,rnd-saman,,,,,223229,structural-design,,책임연구원,,,B23041,b23041@hanmaceng.co.kr
|
||||
gy9411@naver.com,이가연,010-2430-5102,user,rnd-saman,,,,,223269,slope-structures,,연구원,,,B23047,b23047@hanmaceng.co.kr
|
||||
gylee1@samaneng.com,이가연,010-2430-5102,user,rnd-saman,,,,,223269,slope-structures,,연구원,,,B23047,b23047@hanmaceng.co.kr
|
||||
yskim3@samaneng.com,김예서,010-9167-6132,user,rnd-saman,,,,,223280,land-map-cell,,연구원,,,B23051,b23051@hanmaceng.co.kr
|
||||
jhpyo@samaneng.com,표재학,010-2522-4984,user,rnd-saman,,,,,223281,primal-plan,,연구원,,,B23052,b23052@hanmaceng.co.kr
|
||||
sjkim6@samaneng.com,김신지,010-7667-8256,user,rnd-saman,,,,,223361,tech-planning,,연구원,,,B23064,b23064@hanmaceng.co.kr
|
||||
@@ -140,7 +140,7 @@ hrlee1@samaneng.com,이해랑,010-8628-0094,user,rnd-saman,,,,,225175,modeler,,
|
||||
jhsim@samaneng.com,심재훈,010-6633-3366,user,rnd-saman,,,,,225183,tunnel,,수석연구원,,,B25025,b25025@hanmaceng.co.kr
|
||||
shkim4@samaneng.com,김수현,010-5645-5153,user,rnd-saman,,,,,225215,design-planning,,선임연구원,,,B25027,b25027@hanmaceng.co.kr
|
||||
smbaek@samaneng.com,백승민,010-7156-8542,user,rnd-saman,,,,,225319,hmeg,,책임연구원,,,B25035,b25035@hanmaceng.co.kr
|
||||
swpark3@saman.com,박상원,010-4794-0148,user,rnd-saman,,,,,225336,cm-planning,,연구원,,,B25036,b25036@hanmaceng.co.kr
|
||||
swpark3@samaneng.com,박상원,010-4794-0148,user,rnd-saman,,,,,225336,cm-planning,,연구원,,,B25036,b25036@hanmaceng.co.kr
|
||||
smyoun@samaneng.com,윤석무,010-9780-8901,user,rnd-saman,,,,,226049,solution-dev,,연구원,,,B26002,b26002@hanmaceng.co.kr
|
||||
jhpark4@samaneng.com,박종혁,010-4211-2090,user,rnd-saman,,,,,226072,infra-bim2,,연구원,,,B26003,b26003@hanmaceng.co.kr
|
||||
dhhong@samaneng.com,홍덕현,010-5360-7314,user,rnd-saman,,,,,226073,structural-design,,연구원,,,B26004,b26004@hanmaceng.co.kr
|
||||
|
||||
|
@@ -27,11 +27,71 @@ warm_get() {
|
||||
"http://127.0.0.1:${USERFRONT_INTERNAL_PORT}${path}" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
wait_for_userfront_build() {
|
||||
flutter_pid="$1"
|
||||
attempt=1
|
||||
|
||||
while [ "$attempt" -le "$USERFRONT_BOOT_WARMUP_ATTEMPTS" ]; do
|
||||
if [ -f "build/web/index.html" ]; then
|
||||
return 0
|
||||
fi
|
||||
if ! kill -0 "$flutter_pid" 2>/dev/null; then
|
||||
echo "[userfront-boot] warmup skipped because flutter exited before build/web/index.html was ready" >&2
|
||||
return 1
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
sleep "$USERFRONT_BOOT_WARMUP_INTERVAL_SECONDS"
|
||||
done
|
||||
|
||||
echo "[userfront-boot] warmup skipped after ${USERFRONT_BOOT_WARMUP_ATTEMPTS} build readiness attempts" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
reset_userfront_service_worker() {
|
||||
cat > build/web/flutter_service_worker.js <<'EOF'
|
||||
self.addEventListener("install", (event) => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
if (self.caches) {
|
||||
const keys = await self.caches.keys();
|
||||
await Promise.all(
|
||||
keys
|
||||
.filter(
|
||||
(key) =>
|
||||
key.indexOf("baron-userfront-") === 0 ||
|
||||
key.indexOf("flutter-app-cache") === 0,
|
||||
)
|
||||
.map((key) => self.caches.delete(key)),
|
||||
);
|
||||
}
|
||||
|
||||
await self.registration.unregister();
|
||||
|
||||
const clients = await self.clients.matchAll({
|
||||
type: "window",
|
||||
includeUncontrolled: true,
|
||||
});
|
||||
await Promise.all(clients.map((client) => client.navigate(client.url)));
|
||||
})(),
|
||||
);
|
||||
});
|
||||
EOF
|
||||
}
|
||||
|
||||
warm_userfront_once() {
|
||||
flutter_pid="$1"
|
||||
attempt=1
|
||||
started_at="$(date +%s)"
|
||||
|
||||
if ! wait_for_userfront_build "$flutter_pid"; then
|
||||
return 0
|
||||
fi
|
||||
reset_userfront_service_worker
|
||||
|
||||
while [ "$attempt" -le "$USERFRONT_BOOT_WARMUP_ATTEMPTS" ]; do
|
||||
if wget -qO- "http://127.0.0.1:${USERFRONT_INTERNAL_PORT}/flutter_bootstrap.js" >/dev/null 2>&1; then
|
||||
break
|
||||
|
||||
Reference in New Issue
Block a user