1
0
forked from baron/baron-sso

feat: update worksmobile sync and restore planning

This commit is contained in:
2026-06-01 17:01:53 +09:00
parent 6574fb54b9
commit 5c8a338085
36 changed files with 3922 additions and 243 deletions

View File

@@ -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

View File

@@ -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();
});
});

View File

@@ -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({

View File

@@ -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>
)}

View File

@@ -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"];
}

View File

@@ -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");
});
});

View File

@@ -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">

View File

@@ -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"

View File

@@ -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");
});
});

View File

@@ -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[],

View File

@@ -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",
);

View File

@@ -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;
}

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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 ""

View File

@@ -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"

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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"

View File

@@ -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")

View File

@@ -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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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(), "이미 사용 중")

View File

@@ -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
}

View File

@@ -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 ""

View File

@@ -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
View File

@@ -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

View File

@@ -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

View 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 자동 실행 주기

View File

@@ -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

View File

@@ -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
1 email name phone role tenant_slug department grade position jobTitle employee_id tenant_slug1 department1 grade1 position1 jobTitle1 employee_id1 sub_email
69 jhkang@samaneng.com 강정훈 010-9891-8798 user rnd-saman 222212 strana 연구원 B22048 b22048@hanmaceng.co.kr
70 jhkim14@samaneng.com 김재현 010-2534-7837 user rnd-saman 222231 watch-bim 수석연구원 B22051 b22051@hanmaceng.co.kr
71 yjchoi1@samaneng.com 최윤진 010-2349-6687 user rnd-saman 222240 way-draw 연구원 B22052 b22052@hanmaceng.co.kr
72 wkkim@samaneng.com 김원기 010-4727-8530 user rnd-saman 222242 infra-bim1 책임연구원 B22057 kwongi79@hanmaceng.co.kr
73 jhlee@samaneng.com 이준호 010-2514-6898 user rnd-saman 223046 structural-software 연구원 B23003 b23003@hanmaceng.co.kr
74 jhchoi3@samaneng.com 최진헌 010-8638-8079 user rnd-saman 222272 strana 선임연구원 B22063 b22063@hanmaceng.co.kr
75 hulee1@samaneng.com 이한울 010-9271-8997 user rnd-saman 222294 web-design 연구원 B22069 b22069@hanmaceng.co.kr
94 hwan@samaneng.com 안효원 010-3358-4260 user rnd-saman 223228 infra-bim1 선임연구원 B23040 b23040@hanmaceng.co.kr
95 sihan@samaneng.com 한성일 010-4322-1100 user rnd-saman 223226 abut-control 책임연구원 B23042 b23042@hanmaceng.co.kr
96 jhkim25@samaneng.com 김재환 010-8962-3743 user rnd-saman 223229 structural-design 책임연구원 B23041 b23041@hanmaceng.co.kr
97 gy9411@naver.com gylee1@samaneng.com 이가연 010-2430-5102 user rnd-saman 223269 slope-structures 연구원 B23047 b23047@hanmaceng.co.kr
98 yskim3@samaneng.com 김예서 010-9167-6132 user rnd-saman 223280 land-map-cell 연구원 B23051 b23051@hanmaceng.co.kr
99 jhpyo@samaneng.com 표재학 010-2522-4984 user rnd-saman 223281 primal-plan 연구원 B23052 b23052@hanmaceng.co.kr
100 sjkim6@samaneng.com 김신지 010-7667-8256 user rnd-saman 223361 tech-planning 연구원 B23064 b23064@hanmaceng.co.kr
140 jhsim@samaneng.com 심재훈 010-6633-3366 user rnd-saman 225183 tunnel 수석연구원 B25025 b25025@hanmaceng.co.kr
141 shkim4@samaneng.com 김수현 010-5645-5153 user rnd-saman 225215 design-planning 선임연구원 B25027 b25027@hanmaceng.co.kr
142 smbaek@samaneng.com 백승민 010-7156-8542 user rnd-saman 225319 hmeg 책임연구원 B25035 b25035@hanmaceng.co.kr
143 swpark3@saman.com swpark3@samaneng.com 박상원 010-4794-0148 user rnd-saman 225336 cm-planning 연구원 B25036 b25036@hanmaceng.co.kr
144 smyoun@samaneng.com 윤석무 010-9780-8901 user rnd-saman 226049 solution-dev 연구원 B26002 b26002@hanmaceng.co.kr
145 jhpark4@samaneng.com 박종혁 010-4211-2090 user rnd-saman 226072 infra-bim2 연구원 B26003 b26003@hanmaceng.co.kr
146 dhhong@samaneng.com 홍덕현 010-5360-7314 user rnd-saman 226073 structural-design 연구원 B26004 b26004@hanmaceng.co.kr

View File

@@ -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