forked from baron/baron-sso
패키징 개선
This commit is contained in:
@@ -91,6 +91,7 @@ jobs:
|
||||
with:
|
||||
context: ./backend
|
||||
file: ./backend/Dockerfile
|
||||
target: production
|
||||
load: true
|
||||
tags: baron_sso/backend:${{ steps.version.outputs.image_tag }}
|
||||
provenance: false
|
||||
|
||||
@@ -76,6 +76,40 @@ describe("TenantProfilePage initial profile loading", () => {
|
||||
expect(screen.getByTestId("tenant-org-unit-type-select")).toHaveValue("팀");
|
||||
expect(screen.getByLabelText("공개 범위")).toHaveValue("internal");
|
||||
expect(screen.getByLabelText("WORKS 연동")).toHaveValue("excluded");
|
||||
expect(fetchAllTenantsMock).not.toHaveBeenCalled();
|
||||
expect(fetchAllTenantsMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves the persisted parent tenant label even when org config already exists", async () => {
|
||||
fetchAllTenantsMock.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: "tenant-company",
|
||||
type: "ORGANIZATION",
|
||||
name: "인프라솔루션",
|
||||
slug: "infra-solution",
|
||||
description: "",
|
||||
status: "active",
|
||||
domains: [],
|
||||
parentId: "tenant-root",
|
||||
memberCount: 0,
|
||||
config: {},
|
||||
createdAt: "2026-06-17T00:00:00Z",
|
||||
updatedAt: "2026-06-17T00:00:00Z",
|
||||
},
|
||||
],
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
total: 1,
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/tenants/:tenantId" element={<TenantProfilePage />} />
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/인프라솔루션 · infra-solution/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,7 +97,7 @@ export function TenantProfilePage() {
|
||||
const parentQuery = useQuery({
|
||||
queryKey: ["tenants", "list-all"],
|
||||
queryFn: () => fetchAllTenants(),
|
||||
enabled: !!tenantQuery.data && !hasPersistedOrgConfig,
|
||||
enabled: !!tenantQuery.data,
|
||||
});
|
||||
const allTenants = parentQuery.data?.items ?? [];
|
||||
const orgConfigCandidate = tenantQuery.data
|
||||
|
||||
@@ -284,7 +284,7 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
||||
baron: true,
|
||||
baronOrg: true,
|
||||
worksmobileId: false,
|
||||
externalKey: false,
|
||||
externalKey: true,
|
||||
worksmobileDomain: true,
|
||||
worksmobile: true,
|
||||
worksmobileOrg: true,
|
||||
|
||||
@@ -29,6 +29,13 @@ import {
|
||||
DialogTrigger,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../../../components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -47,6 +54,7 @@ import {
|
||||
fetchMe,
|
||||
fetchWorksmobileComparison,
|
||||
fetchWorksmobileOverview,
|
||||
importWorksmobileUsersFromWorks,
|
||||
retryWorksmobileJob,
|
||||
type WorksmobileComparisonItem,
|
||||
type WorksmobileOutboxItem,
|
||||
@@ -76,7 +84,7 @@ import {
|
||||
getWorksmobileRowSelectionKey,
|
||||
getWorksmobileSelectedActionIds,
|
||||
getWorksmobileSelectedCreateUserIds,
|
||||
getWorksmobileSelectedUpdateUserIds,
|
||||
getWorksmobileSelectedImportUserIds,
|
||||
getWorksmobileSelectedWorksOnlyOrgUnitIds,
|
||||
summarizeWorksmobileComparison,
|
||||
type WorksmobileAccountStatusFilter,
|
||||
@@ -272,6 +280,7 @@ export function TenantWorksmobilePage() {
|
||||
overviewQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
overviewQuery.refetch();
|
||||
toast.error("조직 Sync 작업 등록 실패", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
@@ -285,6 +294,7 @@ export function TenantWorksmobilePage() {
|
||||
overviewQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
overviewQuery.refetch();
|
||||
toast.error("구성원 Sync 작업 등록 실패", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
@@ -296,12 +306,15 @@ export function TenantWorksmobilePage() {
|
||||
resourceKind,
|
||||
ids,
|
||||
initialPassword,
|
||||
initialPasswordUserIds,
|
||||
}: {
|
||||
resourceKind: "users" | "groups";
|
||||
ids: string[];
|
||||
initialPassword?: string;
|
||||
initialPasswordUserIds?: string[];
|
||||
}) => {
|
||||
const trimmedInitialPassword = initialPassword?.trim();
|
||||
const passwordUserIdSet = new Set(initialPasswordUserIds ?? []);
|
||||
const failures: string[] = [];
|
||||
let successCount = 0;
|
||||
for (const id of ids) {
|
||||
@@ -311,7 +324,7 @@ export function TenantWorksmobilePage() {
|
||||
tenantId,
|
||||
id,
|
||||
undefined,
|
||||
trimmedInitialPassword,
|
||||
passwordUserIdSet.has(id) ? trimmedInitialPassword : undefined,
|
||||
);
|
||||
} else {
|
||||
await enqueueWorksmobileOrgUnitSync(tenantId, id);
|
||||
@@ -355,12 +368,56 @@ export function TenantWorksmobilePage() {
|
||||
comparisonQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
overviewQuery.refetch();
|
||||
toast.error("WORKS 생성 작업 등록 실패", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const importSelectedUsersMutation = useMutation({
|
||||
mutationFn: async (worksmobileUserIds: string[]) =>
|
||||
importWorksmobileUsersFromWorks(tenantId, worksmobileUserIds),
|
||||
onSuccess: (result) => {
|
||||
setSelectedUserRowKeys([]);
|
||||
const failureCount = result.failures?.length ?? 0;
|
||||
const description = [
|
||||
`Baron 업데이트 ${result.updatedCount}건`,
|
||||
`Baron 생성 ${result.createdCount}건`,
|
||||
`external_key 반영 ${result.externalKeyUpdates}건`,
|
||||
failureCount > 0 ? `실패 ${failureCount}건` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
if (failureCount > 0) {
|
||||
toast.error("일부 Works정보 가져오기 실패", {
|
||||
description:
|
||||
result.failures
|
||||
?.slice(0, 3)
|
||||
.map((failure) =>
|
||||
[
|
||||
failure.email ?? failure.worksmobileId ?? "unknown",
|
||||
failure.error,
|
||||
].join(": "),
|
||||
)
|
||||
.join("\n") ?? description,
|
||||
});
|
||||
} else {
|
||||
toast.success("Works정보 가져오기를 완료했습니다.", {
|
||||
description,
|
||||
});
|
||||
}
|
||||
overviewQuery.refetch();
|
||||
comparisonQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
overviewQuery.refetch();
|
||||
toast.error("Works정보 가져오기 실패", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const syncSelectedOrgUnitsMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
baronIds,
|
||||
@@ -389,6 +446,7 @@ export function TenantWorksmobilePage() {
|
||||
comparisonQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
overviewQuery.refetch();
|
||||
toast.error("선택 조직 동기화 작업 등록 실패", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
@@ -561,6 +619,7 @@ export function TenantWorksmobilePage() {
|
||||
<TableHead>작업</TableHead>
|
||||
<TableHead>변경 요약</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead>오류</TableHead>
|
||||
<TableHead>retry</TableHead>
|
||||
<TableHead className="w-24" />
|
||||
</TableRow>
|
||||
@@ -605,7 +664,29 @@ export function TenantWorksmobilePage() {
|
||||
</details>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{job.status}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
job.status === "failed" ? "destructive" : "outline"
|
||||
}
|
||||
>
|
||||
{job.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-sm">
|
||||
{job.lastError ? (
|
||||
<span
|
||||
className="line-clamp-3 text-xs text-destructive"
|
||||
title={job.lastError}
|
||||
>
|
||||
{job.lastError}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
-
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{job.retryCount}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
@@ -668,21 +749,24 @@ export function TenantWorksmobilePage() {
|
||||
visibleColumns={userVisibleColumns}
|
||||
onVisibleColumnsChange={setUserVisibleColumns}
|
||||
passwordManageTenantId={overview?.config.adminTenantId}
|
||||
actionLabel="선택 구성원 WORKS에 생성"
|
||||
actionDisabled={isCreatingUsers || createSelectedMutation.isPending}
|
||||
updateActionLabel="선택 구성원 업데이트 적용"
|
||||
onCreateSelected={(ids, initialPassword) =>
|
||||
actionLabel="Works에 정보 넣기"
|
||||
actionDisabled={
|
||||
isCreatingUsers ||
|
||||
createSelectedMutation.isPending ||
|
||||
importSelectedUsersMutation.isPending
|
||||
}
|
||||
importActionLabel="Works정보 가져오기"
|
||||
importActionDisabled={importSelectedUsersMutation.isPending}
|
||||
onCreateSelected={(ids, initialPassword, initialPasswordUserIds) =>
|
||||
createSelectedMutation.mutateAsync({
|
||||
resourceKind: "users",
|
||||
ids,
|
||||
initialPassword,
|
||||
initialPasswordUserIds,
|
||||
})
|
||||
}
|
||||
onUpdateSelected={(ids) =>
|
||||
createSelectedMutation.mutate({
|
||||
resourceKind: "users",
|
||||
ids,
|
||||
})
|
||||
onImportSelected={(ids) =>
|
||||
importSelectedUsersMutation.mutate(ids)
|
||||
}
|
||||
requireInitialPassword
|
||||
/>
|
||||
@@ -1015,10 +1099,11 @@ function ComparisonTable({
|
||||
showBaronIdColumn = true,
|
||||
showManageColumn = true,
|
||||
actionLabel,
|
||||
updateActionLabel,
|
||||
importActionLabel,
|
||||
importActionDisabled = false,
|
||||
actionDisabled,
|
||||
onCreateSelected,
|
||||
onUpdateSelected,
|
||||
onImportSelected,
|
||||
onRunSelected,
|
||||
deleteActionLabel,
|
||||
deleteActionDisabled = false,
|
||||
@@ -1051,10 +1136,15 @@ function ComparisonTable({
|
||||
showBaronIdColumn?: boolean;
|
||||
showManageColumn?: boolean;
|
||||
actionLabel: string;
|
||||
updateActionLabel?: string;
|
||||
importActionLabel?: string;
|
||||
importActionDisabled?: boolean;
|
||||
actionDisabled: boolean;
|
||||
onCreateSelected: (ids: string[], initialPassword?: string) => unknown;
|
||||
onUpdateSelected?: (ids: string[]) => void;
|
||||
onCreateSelected: (
|
||||
ids: string[],
|
||||
initialPassword?: string,
|
||||
initialPasswordUserIds?: string[],
|
||||
) => unknown;
|
||||
onImportSelected?: (worksmobileUserIds: string[]) => void;
|
||||
onRunSelected?: (actionIds: string[], deleteIds: string[]) => void;
|
||||
deleteActionLabel?: string;
|
||||
deleteActionDisabled?: boolean;
|
||||
@@ -1066,6 +1156,7 @@ function ComparisonTable({
|
||||
const [initialPassword, setInitialPassword] = React.useState("");
|
||||
const [pendingInitialPasswordIds, setPendingInitialPasswordIds] =
|
||||
React.useState<string[]>([]);
|
||||
const [pendingActionIds, setPendingActionIds] = React.useState<string[]>([]);
|
||||
const tableViewportRef = React.useRef<HTMLDivElement>(null);
|
||||
const selectableKeys = rows
|
||||
.filter(canSelectWorksmobileRow)
|
||||
@@ -1076,7 +1167,7 @@ function ComparisonTable({
|
||||
rows,
|
||||
selectedKeys,
|
||||
);
|
||||
const selectedUpdateUserIds = getWorksmobileSelectedUpdateUserIds(
|
||||
const selectedImportUserIds = getWorksmobileSelectedImportUserIds(
|
||||
rows,
|
||||
selectedKeys,
|
||||
);
|
||||
@@ -1090,7 +1181,7 @@ function ComparisonTable({
|
||||
selectedActionIds.length === 0 &&
|
||||
selectedDeleteIds.length > 0 &&
|
||||
canRunDeleteAction;
|
||||
const canRunUserUpdateAction = Boolean(onUpdateSelected);
|
||||
const canRunUserImportAction = Boolean(onImportSelected);
|
||||
const selectedActionLabel = shouldRunDeleteAction
|
||||
? deleteActionLabel
|
||||
: actionLabel;
|
||||
@@ -1102,11 +1193,9 @@ function ComparisonTable({
|
||||
? selectedActionIds.length === 0 && selectedDeleteIds.length === 0
|
||||
: shouldRunDeleteAction
|
||||
? selectedDeleteIds.length === 0 || deleteActionDisabled
|
||||
: requireInitialPassword
|
||||
? selectedCreateUserIds.length === 0
|
||||
: selectedActionIds.length === 0) || actionDisabled;
|
||||
const updateActionDisabled =
|
||||
selectedUpdateUserIds.length === 0 || actionDisabled;
|
||||
: selectedActionIds.length === 0) || actionDisabled;
|
||||
const importActionButtonDisabled =
|
||||
selectedImportUserIds.length === 0 || importActionDisabled;
|
||||
const allSelectableSelected =
|
||||
selectableKeys.length > 0 &&
|
||||
selectableKeys.every((key) => selectedKeys.includes(key));
|
||||
@@ -1228,7 +1317,8 @@ function ComparisonTable({
|
||||
onDeleteSelected(selectedDeleteIds);
|
||||
return;
|
||||
}
|
||||
if (requireInitialPassword) {
|
||||
if (requireInitialPassword && selectedCreateUserIds.length > 0) {
|
||||
setPendingActionIds(selectedActionIds);
|
||||
setPendingInitialPasswordIds(selectedCreateUserIds);
|
||||
setInitialPassword("");
|
||||
setInitialPasswordOpen(true);
|
||||
@@ -1237,11 +1327,11 @@ function ComparisonTable({
|
||||
onCreateSelected(selectedActionIds);
|
||||
};
|
||||
|
||||
const runUpdateAction = () => {
|
||||
if (!onUpdateSelected || selectedUpdateUserIds.length === 0) {
|
||||
const runImportAction = () => {
|
||||
if (!onImportSelected || selectedImportUserIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
onUpdateSelected(selectedUpdateUserIds);
|
||||
onImportSelected(selectedImportUserIds);
|
||||
};
|
||||
|
||||
const confirmInitialPassword = async () => {
|
||||
@@ -1251,13 +1341,18 @@ function ComparisonTable({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await onCreateSelected(pendingInitialPasswordIds, password);
|
||||
await onCreateSelected(
|
||||
pendingActionIds,
|
||||
password,
|
||||
pendingInitialPasswordIds,
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setInitialPasswordOpen(false);
|
||||
setInitialPassword("");
|
||||
setPendingInitialPasswordIds([]);
|
||||
setPendingActionIds([]);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -1300,27 +1395,28 @@ function ComparisonTable({
|
||||
}
|
||||
/>
|
||||
{accountStatusFilter && onAccountStatusFilterChange ? (
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
role="tablist"
|
||||
aria-label="WORKS 계정 상태"
|
||||
<Select
|
||||
value={accountStatusFilter}
|
||||
onValueChange={(value) =>
|
||||
onAccountStatusFilterChange(
|
||||
value as WorksmobileAccountStatusFilter,
|
||||
)
|
||||
}
|
||||
>
|
||||
{worksmobileAccountStatusFilterOptions.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
size="sm"
|
||||
variant={
|
||||
accountStatusFilter === option.value ? "default" : "outline"
|
||||
}
|
||||
aria-selected={accountStatusFilter === option.value}
|
||||
onClick={() => onAccountStatusFilterChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<SelectTrigger
|
||||
className="h-9 w-[148px]"
|
||||
aria-label="WORKS 계정 상태"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{worksmobileAccountStatusFilterOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-start gap-2 xl:justify-end">
|
||||
@@ -1380,15 +1476,15 @@ function ComparisonTable({
|
||||
>
|
||||
{selectedActionLabel}
|
||||
</Button>
|
||||
{canRunUserUpdateAction && (
|
||||
{canRunUserImportAction && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={runUpdateAction}
|
||||
disabled={updateActionDisabled}
|
||||
onClick={runImportAction}
|
||||
disabled={importActionButtonDisabled}
|
||||
>
|
||||
{updateActionLabel || "선택 구성원 업데이트 적용"}
|
||||
{importActionLabel || "Works정보 가져오기"}
|
||||
</Button>
|
||||
)}
|
||||
<Dialog
|
||||
@@ -1398,6 +1494,7 @@ function ComparisonTable({
|
||||
if (!open) {
|
||||
setInitialPassword("");
|
||||
setPendingInitialPasswordIds([]);
|
||||
setPendingActionIds([]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -1437,7 +1534,7 @@ function ComparisonTable({
|
||||
onClick={confirmInitialPassword}
|
||||
disabled={actionDisabled}
|
||||
>
|
||||
생성 작업 등록
|
||||
작업 등록
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -47,7 +47,7 @@ export function getDefaultWorksmobileComparisonColumns(): WorksmobileComparisonC
|
||||
baron: true,
|
||||
baronOrg: true,
|
||||
worksmobileId: false,
|
||||
externalKey: false,
|
||||
externalKey: true,
|
||||
worksmobileDomain: true,
|
||||
worksmobile: true,
|
||||
worksmobileOrg: true,
|
||||
@@ -212,6 +212,24 @@ export function getWorksmobileSelectedUpdateUserIds(
|
||||
.filter((id): id is string => Boolean(id));
|
||||
}
|
||||
|
||||
export function getWorksmobileSelectedImportUserIds(
|
||||
rows: WorksmobileComparisonItem[],
|
||||
selectedKeys: string[],
|
||||
) {
|
||||
const selected = new Set(selectedKeys);
|
||||
return rows
|
||||
.filter(
|
||||
(row) =>
|
||||
row.resourceType === "USER" &&
|
||||
(row.status === "needs_update" ||
|
||||
row.status === "missing_external_key" ||
|
||||
row.status === "missing_in_baron") &&
|
||||
selected.has(getWorksmobileRowSelectionKey(row)),
|
||||
)
|
||||
.map((row) => row.worksmobileId)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
}
|
||||
|
||||
export function formatWorksmobileSelectionFailureDescription(
|
||||
successCount: number,
|
||||
failures: string[],
|
||||
|
||||
@@ -1005,6 +1005,23 @@ export type WorksmobileComparison = {
|
||||
groups: WorksmobileComparisonItem[];
|
||||
};
|
||||
|
||||
export type WorksmobileImportUsersResult = {
|
||||
updatedCount: number;
|
||||
createdCount: number;
|
||||
externalKeyUpdates: number;
|
||||
failures?: Array<{
|
||||
worksmobileId?: string;
|
||||
email?: string;
|
||||
error: string;
|
||||
}>;
|
||||
items?: Array<{
|
||||
worksmobileId?: string;
|
||||
baronId?: string;
|
||||
email?: string;
|
||||
action: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function fetchUsers(
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
@@ -1194,6 +1211,17 @@ export async function enqueueWorksmobileUserSync(
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function importWorksmobileUsersFromWorks(
|
||||
tenantId: string,
|
||||
worksmobileUserIds: string[],
|
||||
) {
|
||||
const { data } = await apiClient.post<WorksmobileImportUsersResult>(
|
||||
`/v1/admin/tenants/${tenantId}/worksmobile/users/import-from-works`,
|
||||
{ worksmobileUserIds },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function resetWorksmobileUserPassword(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
|
||||
@@ -356,12 +356,10 @@ test.describe("Worksmobile tenant management", () => {
|
||||
.getByRole("row", { name: /김누락/ })
|
||||
.getByRole("checkbox")
|
||||
.check();
|
||||
await page
|
||||
.getByRole("button", { name: "선택 구성원 WORKS에 생성" })
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Works에 정보 넣기" }).click();
|
||||
await expect(page.getByText("WORKS 초기 비밀번호")).toBeVisible();
|
||||
await page.getByLabel("초기 비밀번호").fill("InitPass123!");
|
||||
await page.getByRole("button", { name: "생성 작업 등록" }).click();
|
||||
await page.getByRole("button", { name: "작업 등록" }).click();
|
||||
await expect
|
||||
.poll(() => syncRequests)
|
||||
.toEqual([
|
||||
@@ -591,11 +589,11 @@ test.describe("Worksmobile tenant management", () => {
|
||||
.check();
|
||||
|
||||
await userComparisonSection
|
||||
.getByRole("button", { name: "선택 구성원 WORKS에 생성" })
|
||||
.getByRole("button", { name: "Works에 정보 넣기" })
|
||||
.click();
|
||||
await expect(page.getByText("WORKS 초기 비밀번호")).toBeVisible();
|
||||
await page.getByLabel("초기 비밀번호").fill("InitPass123!");
|
||||
await page.getByRole("button", { name: "생성 작업 등록" }).click();
|
||||
await page.getByRole("button", { name: "작업 등록" }).click();
|
||||
await expect
|
||||
.poll(() => syncRequests)
|
||||
.toEqual([
|
||||
@@ -603,6 +601,12 @@ test.describe("Worksmobile tenant management", () => {
|
||||
userId: "user-missing",
|
||||
body: expect.objectContaining({ initialPassword: "InitPass123!" }),
|
||||
},
|
||||
{
|
||||
userId: "user-update",
|
||||
body: expect.not.objectContaining({
|
||||
initialPassword: expect.anything(),
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
const updateRowCheckbox = userComparisonSection
|
||||
@@ -614,8 +618,9 @@ test.describe("Worksmobile tenant management", () => {
|
||||
.getByRole("checkbox")
|
||||
.check();
|
||||
await userComparisonSection
|
||||
.getByRole("button", { name: "선택 구성원 업데이트 적용" })
|
||||
.getByRole("button", { name: "Works에 정보 넣기" })
|
||||
.click();
|
||||
await expect(page.getByText("WORKS 초기 비밀번호")).not.toBeVisible();
|
||||
await expect
|
||||
.poll(() => syncRequests)
|
||||
.toEqual([
|
||||
@@ -629,6 +634,12 @@ test.describe("Worksmobile tenant management", () => {
|
||||
initialPassword: expect.anything(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
userId: "user-update",
|
||||
body: expect.not.objectContaining({
|
||||
initialPassword: expect.anything(),
|
||||
}),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -734,15 +745,13 @@ test.describe("Worksmobile tenant management", () => {
|
||||
.getByRole("row", { name: /실패 사용자/ })
|
||||
.getByRole("checkbox")
|
||||
.check();
|
||||
await page
|
||||
.getByRole("button", { name: "선택 구성원 WORKS에 생성" })
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Works에 정보 넣기" }).click();
|
||||
await page.getByLabel("초기 비밀번호").fill("InitPass123!");
|
||||
await page.getByRole("button", { name: "생성 작업 등록" }).click();
|
||||
await page.getByRole("button", { name: "작업 등록" }).click();
|
||||
|
||||
await expect(page.getByText("WORKS 초기 비밀번호")).toBeVisible();
|
||||
await page.getByLabel("초기 비밀번호").fill("InitPass123!");
|
||||
await page.getByRole("button", { name: "생성 작업 등록" }).click();
|
||||
await page.getByRole("button", { name: "작업 등록" }).click();
|
||||
|
||||
await expect(page.getByText("WORKS 생성 작업 등록 실패")).toBeVisible();
|
||||
await expect(
|
||||
@@ -917,6 +926,90 @@ test.describe("Worksmobile tenant management", () => {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Expose-Headers": "Content-Disposition",
|
||||
};
|
||||
const buildRecentJobs = () => [
|
||||
...(requests.includes("org-rejected-sync")
|
||||
? [
|
||||
{
|
||||
id: "job-org-rejected",
|
||||
resourceType: "ORGUNIT",
|
||||
resourceId: "org-rejected",
|
||||
action: "UPSERT",
|
||||
status: "failed",
|
||||
retryCount: 0,
|
||||
lastError: "target tenant is excluded from Worksmobile sync",
|
||||
createdAt: "2026-05-01T00:02:00Z",
|
||||
updatedAt: "2026-05-01T00:02:00Z",
|
||||
payload: {
|
||||
displayName: "제외팀",
|
||||
matchLocalPart: "excluded-team",
|
||||
requestSummary: {
|
||||
orgUnitName: "제외팀",
|
||||
orgUnitExternalKey: "org-rejected",
|
||||
tenantSlug: "excluded-team",
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "job-retry",
|
||||
resourceType: "USER",
|
||||
resourceId: "user-failed",
|
||||
action: "sync",
|
||||
status: "failed",
|
||||
retryCount: 1,
|
||||
lastError: "worksmobile api failed status=400 body=invalid org",
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
payload: {
|
||||
loginEmail: "changed-user@example.com",
|
||||
displayName: "변경 사용자",
|
||||
primaryLeafOrgName: "인재성장",
|
||||
requestSummary: {
|
||||
email: "changed-user@example.com",
|
||||
displayName: "변경 사용자",
|
||||
userExternalKey: "user-failed",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "job-org-auto",
|
||||
resourceType: "ORGUNIT",
|
||||
resourceId: "org-auto",
|
||||
action: "UPSERT",
|
||||
status: "processed",
|
||||
retryCount: 0,
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:01:00Z",
|
||||
payload: {
|
||||
matchLocalPart: "people-growth",
|
||||
requestSummary: {
|
||||
orgUnitName: "인재성장",
|
||||
email: "people-growth@example.com",
|
||||
orgUnitExternalKey: "org-auto",
|
||||
parentOrgUnitId: "externalKey:parent-org",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "job-pending",
|
||||
resourceType: "ORGUNIT",
|
||||
resourceId: "org-pending",
|
||||
action: "UPSERT",
|
||||
status: "pending",
|
||||
retryCount: 0,
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:01:00Z",
|
||||
payload: {
|
||||
matchLocalPart: "halla-site",
|
||||
requestSummary: {
|
||||
orgUnitName: "한라 현장",
|
||||
email: "halla-site@hallasanup.com",
|
||||
orgUnitExternalKey: "org-pending",
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
@@ -948,65 +1041,7 @@ test.describe("Worksmobile tenant management", () => {
|
||||
tokenConfigured: true,
|
||||
adminTenantId: "works-tenant-1",
|
||||
},
|
||||
recentJobs: [
|
||||
{
|
||||
id: "job-retry",
|
||||
resourceType: "USER",
|
||||
resourceId: "user-failed",
|
||||
action: "sync",
|
||||
status: "failed",
|
||||
retryCount: 1,
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
payload: {
|
||||
loginEmail: "changed-user@example.com",
|
||||
displayName: "변경 사용자",
|
||||
primaryLeafOrgName: "인재성장",
|
||||
requestSummary: {
|
||||
email: "changed-user@example.com",
|
||||
displayName: "변경 사용자",
|
||||
userExternalKey: "user-failed",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "job-org-auto",
|
||||
resourceType: "ORGUNIT",
|
||||
resourceId: "org-auto",
|
||||
action: "UPSERT",
|
||||
status: "processed",
|
||||
retryCount: 0,
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:01:00Z",
|
||||
payload: {
|
||||
matchLocalPart: "people-growth",
|
||||
requestSummary: {
|
||||
orgUnitName: "인재성장",
|
||||
email: "people-growth@example.com",
|
||||
orgUnitExternalKey: "org-auto",
|
||||
parentOrgUnitId: "externalKey:parent-org",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "job-pending",
|
||||
resourceType: "ORGUNIT",
|
||||
resourceId: "org-pending",
|
||||
action: "UPSERT",
|
||||
status: "pending",
|
||||
retryCount: 0,
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:01:00Z",
|
||||
payload: {
|
||||
matchLocalPart: "halla-site",
|
||||
requestSummary: {
|
||||
orgUnitName: "한라 현장",
|
||||
email: "halla-site@hallasanup.com",
|
||||
orgUnitExternalKey: "org-pending",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
recentJobs: buildRecentJobs(),
|
||||
},
|
||||
headers,
|
||||
});
|
||||
@@ -1068,6 +1103,20 @@ test.describe("Worksmobile tenant management", () => {
|
||||
return route.fulfill({ json: { id: "job-org-sync" }, headers });
|
||||
}
|
||||
|
||||
if (
|
||||
url.pathname.endsWith(
|
||||
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/orgunits/org-rejected/sync",
|
||||
) &&
|
||||
method === "POST"
|
||||
) {
|
||||
requests.push("org-rejected-sync");
|
||||
return route.fulfill({
|
||||
status: 400,
|
||||
json: { error: "target tenant is excluded from Worksmobile sync" },
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
url.pathname.endsWith(
|
||||
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/users/user-1/sync",
|
||||
@@ -1116,6 +1165,9 @@ test.describe("Worksmobile tenant management", () => {
|
||||
await page.getByPlaceholder("orgUnit tenant UUID").fill("org-1");
|
||||
await page.getByRole("button", { name: "조직 Sync" }).click();
|
||||
await expect.poll(() => requests).toContain("org-sync");
|
||||
await page.getByPlaceholder("orgUnit tenant UUID").fill("org-rejected");
|
||||
await page.getByRole("button", { name: "조직 Sync" }).click();
|
||||
await expect.poll(() => requests).toContain("org-rejected-sync");
|
||||
|
||||
await page.getByRole("tab", { name: "사용자" }).click();
|
||||
await page.getByPlaceholder("Kratos user UUID").fill("user-1");
|
||||
@@ -1123,6 +1175,10 @@ test.describe("Worksmobile tenant management", () => {
|
||||
await expect.poll(() => requests).toContain("user-sync");
|
||||
|
||||
await page.getByRole("tab", { name: "이력" }).click();
|
||||
const rejectedOrgRow = page.getByRole("row", { name: /제외팀/ });
|
||||
await expect(rejectedOrgRow).toContainText(
|
||||
"target tenant is excluded from Worksmobile sync",
|
||||
);
|
||||
await expect(page.getByRole("row", { name: /변경 사용자/ })).toContainText(
|
||||
"changed-user@example.com",
|
||||
);
|
||||
@@ -1136,6 +1192,9 @@ test.describe("Worksmobile tenant management", () => {
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
const failedJobRow = page.getByRole("row", { name: /변경 사용자/ });
|
||||
await expect(failedJobRow).toContainText(
|
||||
"worksmobile api failed status=400 body=invalid org",
|
||||
);
|
||||
await failedJobRow.getByText("payload").click();
|
||||
await expect(
|
||||
failedJobRow.getByText('"loginEmail": "changed-user@example.com"'),
|
||||
|
||||
9
backend/.dockerignore
Normal file
9
backend/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
.env
|
||||
.env.*
|
||||
/.codex
|
||||
/reports
|
||||
/tmp
|
||||
/logs
|
||||
/server
|
||||
/main
|
||||
*.log
|
||||
@@ -1,21 +1,49 @@
|
||||
FROM golang:1.26.2-alpine
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM golang:1.26.2-alpine AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install git for go mod download if needed
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# Pre-copy go.mod/sum to cache dependencies
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source
|
||||
FROM base AS dev
|
||||
|
||||
COPY . .
|
||||
|
||||
# Build for production (optional, can just run go run for dev)
|
||||
RUN go build -o main ./cmd/server
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||
go build -trimpath -ldflags="-s -w" -o /usr/local/bin/baron-backend-dev ./cmd/server
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Default command (can be overridden by compose)
|
||||
CMD ["./main"]
|
||||
CMD ["/usr/local/bin/baron-backend-dev"]
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
ARG TARGETOS=linux
|
||||
ARG TARGETARCH=amd64
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
|
||||
go build -trimpath -ldflags="-s -w" -o /out/main ./cmd/server && \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
|
||||
go build -trimpath -ldflags="-s -w" -o /out/healthcheck ./cmd/healthcheck
|
||||
|
||||
FROM gcr.io/distroless/static-debian13:nonroot AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder --chown=65532:65532 /out/main ./main
|
||||
COPY --from=builder --chown=65532:65532 /out/healthcheck ./healthcheck
|
||||
COPY --from=builder --chown=65532:65532 /app/docs ./docs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
USER 65532:65532
|
||||
|
||||
ENTRYPOINT ["/app/main"]
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"baron-sso-backend/internal/service"
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
@@ -59,6 +60,9 @@ type worksmobileSyncConfig struct {
|
||||
ComparisonOutput string
|
||||
AlignBaronFromWorksOutput string
|
||||
AlignBaronFromWorksExclude string
|
||||
ImportFromWorksEmails string
|
||||
PatchWorksUserNameEmail string
|
||||
PatchWorksUserName string
|
||||
InspectOutput string
|
||||
CredentialBatchID string
|
||||
Process bool
|
||||
@@ -202,6 +206,28 @@ func runWorksmobileSync(args []string) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if config.ImportFromWorksEmails != "" {
|
||||
kratosAdmin := service.NewKratosAdminService()
|
||||
syncService.SetIdentityServices(service.NewIdentityWriteService(kratosAdmin, nil), kratosAdmin)
|
||||
worksmobileUserIDs, err := resolveWorksmobileUserIDsByEmail(ctx, newWorksmobileAdminClient(), config.ImportFromWorksEmails)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := syncService.ImportUsersFromWorks(ctx, root.ID, worksmobileUserIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
encoded, err := json.MarshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(string(encoded))
|
||||
}
|
||||
if config.PatchWorksUserNameEmail != "" {
|
||||
if err := patchWorksmobileUserName(ctx, newWorksmobileAdminClient(), config.PatchWorksUserNameEmail, config.PatchWorksUserName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if config.Process {
|
||||
return processWorksmobileOutbox(ctx, db, outboxRepo, config)
|
||||
}
|
||||
@@ -256,6 +282,9 @@ func resolveWorksmobileSyncConfig(args []string) (worksmobileSyncConfig, error)
|
||||
fs.StringVar(&config.ComparisonOutput, "comparison-output", "", "output CSV path for current Worksmobile user comparison rows whose status is needs_update")
|
||||
fs.StringVar(&config.AlignBaronFromWorksOutput, "align-baron-from-works-output", "", "output CSV path for one-time Baron user updates from current Worksmobile needs_update rows")
|
||||
fs.StringVar(&config.AlignBaronFromWorksExclude, "align-baron-from-works-exclude", "", "comma-separated emails or local-parts to exclude from --align-baron-from-works-output")
|
||||
fs.StringVar(&config.ImportFromWorksEmails, "import-from-works-emails", "", "comma-separated Worksmobile emails to import into Baron and patch Worksmobile externalKey")
|
||||
fs.StringVar(&config.PatchWorksUserNameEmail, "patch-works-user-name-email", "", "Worksmobile email to patch userName by PATCH-only")
|
||||
fs.StringVar(&config.PatchWorksUserName, "patch-works-user-name", "", "display name for --patch-works-user-name-email")
|
||||
fs.StringVar(&config.InspectOutput, "inspect-output", "", "output CSV path for inspect/undelete commands")
|
||||
fs.StringVar(&config.CredentialBatchID, "credential-batch-id", "", "credential batch id for regenerated user password rows")
|
||||
fs.BoolVar(&config.Process, "process", false, "process ready Worksmobile outbox jobs")
|
||||
@@ -267,8 +296,11 @@ func resolveWorksmobileSyncConfig(args []string) (worksmobileSyncConfig, error)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return config, err
|
||||
}
|
||||
if !config.SyncOrgUnits && config.UsersCSV == "" && config.InspectUsersCSV == "" && config.InspectOrgUnitsCSV == "" && config.UpsertOrgUnitID == "" && config.UndeleteUsersCSV == "" && config.RemoveAliasesCSV == "" && config.FindNumberStrippedAliasesOutput == "" && config.DuplicatePhoneCountryCodeOutput == "" && !config.FixDuplicatePhoneCountryCode && config.PendingUsersOutput == "" && config.ResetPendingUsersPassword == "" && config.DeletePendingUsersResultOutput == "" && config.ForceDeleteUsersCSV == "" && config.CreateUsersCSV == "" && config.UpdateUserLevelsCSV == "" && config.ImportHanmacUsersCSV == "" && config.RecreatePendingUsersPassword == "" && config.ActivateAllUsersOutput == "" && config.ComparisonOutput == "" && config.AlignBaronFromWorksOutput == "" && !config.Process {
|
||||
return config, fmt.Errorf("nothing to do; pass --orgunits, --users-csv, --inspect-users-csv, --inspect-orgunits-csv, --upsert-orgunit-id, --undelete-users-csv, --remove-aliases-csv, --find-number-stripped-aliases-output, --duplicate-phone-country-code-output, --fix-duplicate-phone-country-code, --pending-users-output, --reset-pending-users-password, --delete-pending-users-result-output, --force-delete-users-csv, --create-users-csv, --update-user-levels-csv, --import-hanmac-users-csv, --recreate-pending-users-password, --activate-all-users-output, --comparison-output, --align-baron-from-works-output, or --process")
|
||||
if !config.SyncOrgUnits && config.UsersCSV == "" && config.InspectUsersCSV == "" && config.InspectOrgUnitsCSV == "" && config.UpsertOrgUnitID == "" && config.UndeleteUsersCSV == "" && config.RemoveAliasesCSV == "" && config.FindNumberStrippedAliasesOutput == "" && config.DuplicatePhoneCountryCodeOutput == "" && !config.FixDuplicatePhoneCountryCode && config.PendingUsersOutput == "" && config.ResetPendingUsersPassword == "" && config.DeletePendingUsersResultOutput == "" && config.ForceDeleteUsersCSV == "" && config.CreateUsersCSV == "" && config.UpdateUserLevelsCSV == "" && config.ImportHanmacUsersCSV == "" && config.RecreatePendingUsersPassword == "" && config.ActivateAllUsersOutput == "" && config.ComparisonOutput == "" && config.AlignBaronFromWorksOutput == "" && config.ImportFromWorksEmails == "" && config.PatchWorksUserNameEmail == "" && !config.Process {
|
||||
return config, fmt.Errorf("nothing to do; pass --orgunits, --users-csv, --inspect-users-csv, --inspect-orgunits-csv, --upsert-orgunit-id, --undelete-users-csv, --remove-aliases-csv, --find-number-stripped-aliases-output, --duplicate-phone-country-code-output, --fix-duplicate-phone-country-code, --pending-users-output, --reset-pending-users-password, --delete-pending-users-result-output, --force-delete-users-csv, --create-users-csv, --update-user-levels-csv, --import-hanmac-users-csv, --recreate-pending-users-password, --activate-all-users-output, --comparison-output, --align-baron-from-works-output, --import-from-works-emails, --patch-works-user-name-email, or --process")
|
||||
}
|
||||
if config.PatchWorksUserNameEmail != "" && strings.TrimSpace(config.PatchWorksUserName) == "" {
|
||||
return config, fmt.Errorf("--patch-works-user-name is required with --patch-works-user-name-email")
|
||||
}
|
||||
if config.ResetPendingUsersPassword != "" && config.ResetPendingUsersResultOutput == "" {
|
||||
return config, fmt.Errorf("--reset-pending-users-result-output is required with --reset-pending-users-password")
|
||||
@@ -306,6 +338,119 @@ func resolveWorksmobileSyncConfig(args []string) (worksmobileSyncConfig, error)
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func resolveWorksmobileUserIDsByEmail(ctx context.Context, client service.WorksmobileDirectoryClient, rawEmails string) ([]string, error) {
|
||||
if client == nil {
|
||||
return nil, errors.New("worksmobile client is not configured")
|
||||
}
|
||||
targetEmails := splitCommaSeparatedValues(rawEmails)
|
||||
if len(targetEmails) == 0 {
|
||||
return nil, errors.New("--import-from-works-emails requires at least one email")
|
||||
}
|
||||
remoteUsers, err := client.ListUsers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
remoteByEmail := make(map[string]service.WorksmobileRemoteUser, len(remoteUsers))
|
||||
for _, remote := range remoteUsers {
|
||||
email := strings.ToLower(strings.TrimSpace(remote.Email))
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
remoteByEmail[email] = remote
|
||||
}
|
||||
userIDs := make([]string, 0, len(targetEmails))
|
||||
for _, targetEmail := range targetEmails {
|
||||
remote, ok := remoteByEmail[strings.ToLower(targetEmail)]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("worksmobile user not found by email: %s", targetEmail)
|
||||
}
|
||||
if id := strings.TrimSpace(remote.ID); id != "" {
|
||||
userIDs = append(userIDs, id)
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("worksmobile user id is empty for email: %s", targetEmail)
|
||||
}
|
||||
return userIDs, nil
|
||||
}
|
||||
|
||||
func splitCommaSeparatedValues(raw string) []string {
|
||||
parts := strings.Split(raw, ",")
|
||||
values := make([]string, 0, len(parts))
|
||||
seen := map[string]bool{}
|
||||
for _, part := range parts {
|
||||
value := strings.TrimSpace(part)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(value)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
values = append(values, value)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func patchWorksmobileUserName(ctx context.Context, client service.WorksmobileDirectoryClient, email string, displayName string) error {
|
||||
if client == nil {
|
||||
return errors.New("worksmobile client is not configured")
|
||||
}
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
displayName = strings.TrimSpace(displayName)
|
||||
if email == "" || displayName == "" {
|
||||
return errors.New("email and display name are required")
|
||||
}
|
||||
remoteUsers, err := client.ListUsers(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var target *service.WorksmobileRemoteUser
|
||||
for i := range remoteUsers {
|
||||
if strings.EqualFold(strings.TrimSpace(remoteUsers[i].Email), email) {
|
||||
target = &remoteUsers[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
return fmt.Errorf("worksmobile user not found by email: %s", email)
|
||||
}
|
||||
if err := client.UpdateUserOnly(ctx, service.WorksmobileUserPayload{
|
||||
DomainID: target.DomainID,
|
||||
Email: strings.TrimSpace(target.Email),
|
||||
UserExternalKey: strings.TrimSpace(target.ExternalID),
|
||||
UserName: adminctlWorksmobileUserNameFromDisplayName(displayName),
|
||||
CellPhone: strings.TrimSpace(target.CellPhone),
|
||||
EmployeeNumber: strings.TrimSpace(target.EmployeeNumber),
|
||||
Locale: "ko_KR",
|
||||
Task: strings.TrimSpace(target.Task),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("worksmobile user name patched: email=%s display_name=%s\n", email, displayName)
|
||||
return nil
|
||||
}
|
||||
|
||||
func adminctlWorksmobileUserNameFromDisplayName(name string) service.WorksmobileUserName {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || strings.ContainsAny(name, " \t\r\n") {
|
||||
return service.WorksmobileUserName{LastName: name}
|
||||
}
|
||||
runes := []rune(name)
|
||||
if len(runes) < 2 || len(runes) > 4 {
|
||||
return service.WorksmobileUserName{LastName: name}
|
||||
}
|
||||
for _, r := range runes {
|
||||
if r < '가' || r > '힣' {
|
||||
return service.WorksmobileUserName{LastName: name}
|
||||
}
|
||||
}
|
||||
return service.WorksmobileUserName{
|
||||
LastName: string(runes[:1]),
|
||||
FirstName: string(runes[1:]),
|
||||
}
|
||||
}
|
||||
|
||||
func enqueueWorksmobileOrgUnits(ctx context.Context, db *gorm.DB, syncService service.WorksmobileAdminService, rootID string) (int, int, int, error) {
|
||||
tenantIDs, err := activeTenantSubtreeIDs(ctx, db, rootID)
|
||||
if err != nil {
|
||||
|
||||
72
backend/cmd/healthcheck/main.go
Normal file
72
backend/cmd/healthcheck/main.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
url := strings.TrimSpace(os.Getenv("BACKEND_HEALTHCHECK_URL"))
|
||||
if url == "" {
|
||||
port := strings.TrimSpace(os.Getenv("BACKEND_PORT"))
|
||||
if port == "" {
|
||||
port = strings.TrimSpace(os.Getenv("PORT"))
|
||||
}
|
||||
if port == "" {
|
||||
port = "3000"
|
||||
}
|
||||
url = "http://127.0.0.1:" + port + "/health"
|
||||
}
|
||||
|
||||
statusCode, err := checkHTTP(url, 3*time.Second)
|
||||
if err != nil {
|
||||
_, _ = os.Stderr.WriteString("healthcheck request failed: " + err.Error() + "\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
if statusCode < 200 || statusCode >= 400 {
|
||||
_, _ = os.Stderr.WriteString("healthcheck returned HTTP " + strconv.Itoa(statusCode) + "\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func checkHTTP(rawURL string, timeout time.Duration) (int, error) {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
host := parsed.Host
|
||||
if !strings.Contains(host, ":") {
|
||||
host += ":80"
|
||||
}
|
||||
path := parsed.RequestURI()
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
|
||||
conn, err := net.DialTimeout("tcp", host, timeout)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(timeout))
|
||||
|
||||
request := "GET " + path + " HTTP/1.1\r\nHost: " + parsed.Host + "\r\nConnection: close\r\n\r\n"
|
||||
if _, err := conn.Write([]byte(request)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
line, err := bufio.NewReader(conn).ReadString('\n')
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 2 {
|
||||
return 0, nil
|
||||
}
|
||||
return strconv.Atoi(parts[1])
|
||||
}
|
||||
@@ -329,6 +329,7 @@ func main() {
|
||||
configureWorksmobileClientFromEnv(worksmobileClient)
|
||||
worksmobileService := service.NewWorksmobileSyncService(tenantService, userRepo, worksmobileOutboxRepo, worksmobileClient)
|
||||
worksmobileService.SetIdentityMirror(redisService)
|
||||
worksmobileService.SetIdentityServices(service.NewIdentityWriteService(kratosAdminService, redisService), kratosAdminService)
|
||||
worksmobileRelayClient := *worksmobileClient
|
||||
worksmobileRelayClient.RateLimiter = service.NewWorksmobileAPIRateLimiter(240, time.Minute)
|
||||
worksmobileRelayWorker := service.NewWorksmobileRelayWorker(worksmobileOutboxRepo, &worksmobileRelayClient)
|
||||
@@ -781,6 +782,7 @@ func main() {
|
||||
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/import-from-works", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.ImportUsersFromWorks)
|
||||
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)
|
||||
admin.Delete("/tenants/:tenantId/worksmobile/jobs/pending", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DeletePendingJobs)
|
||||
|
||||
@@ -89,6 +89,26 @@ func (h *WorksmobileHandler) SyncUser(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusAccepted).JSON(job)
|
||||
}
|
||||
|
||||
func (h *WorksmobileHandler) ImportUsersFromWorks(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
WorksmobileUserIDs []string `json:"worksmobileUserIds"`
|
||||
}
|
||||
if len(c.Body()) > 0 {
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
}
|
||||
result, err := h.Service.ImportUsersFromWorks(
|
||||
c.Context(),
|
||||
strings.TrimSpace(c.Params("tenantId")),
|
||||
req.WorksmobileUserIDs,
|
||||
)
|
||||
if err != nil {
|
||||
return worksmobileGuardError(c, err, "import_users_from_works")
|
||||
}
|
||||
return c.JSON(result)
|
||||
}
|
||||
|
||||
func (h *WorksmobileHandler) ResetUserPassword(c *fiber.Ctx) error {
|
||||
userID := strings.TrimSpace(c.Params("userId"))
|
||||
credentialBatchID, err := parseWorksmobileCredentialBatchID(c)
|
||||
|
||||
@@ -230,6 +230,10 @@ func (f *fakeWorksmobileAdminService) GetComparison(ctx context.Context, tenantI
|
||||
return service.WorksmobileComparison{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileAdminService) ImportUsersFromWorks(ctx context.Context, tenantID string, worksmobileUserIDs []string) (service.WorksmobileImportUsersResult, error) {
|
||||
return service.WorksmobileImportUsersResult{UpdatedCount: len(worksmobileUserIDs)}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileAdminService) EnqueueBackfillDryRun(ctx context.Context, tenantID string) (service.WorksmobileBackfillDryRun, error) {
|
||||
return service.WorksmobileBackfillDryRun{}, nil
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
type WorksmobileOutboxRepository interface {
|
||||
Create(ctx context.Context, item *domain.WorksmobileOutbox) error
|
||||
ListRecent(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error)
|
||||
ListRecentByTenantRoot(ctx context.Context, tenantRootID string, resourceIDs []string, 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
|
||||
DeletePendingByTenantRoot(ctx context.Context, tenantRootID string) (int64, error)
|
||||
@@ -59,6 +60,20 @@ func (r *worksmobileOutboxRepository) ListRecent(ctx context.Context, limit int)
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (r *worksmobileOutboxRepository) ListRecentByTenantRoot(ctx context.Context, tenantRootID string, resourceIDs []string, limit int) ([]domain.WorksmobileOutbox, error) {
|
||||
if limit <= 0 || limit > 1000 {
|
||||
limit = 50
|
||||
}
|
||||
query := r.db.WithContext(ctx).Where("payload ->> 'tenantRootId' = ?", tenantRootID)
|
||||
if len(resourceIDs) > 0 {
|
||||
query = query.Or("resource_id IN ?", resourceIDs)
|
||||
}
|
||||
|
||||
var rows []domain.WorksmobileOutbox
|
||||
err := query.Order("created_at desc").Limit(limit).Find(&rows).Error
|
||||
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, "")
|
||||
|
||||
@@ -69,6 +69,56 @@ func TestWorksmobileOutboxRepositoryDeletePendingByTenantRoot(t *testing.T) {
|
||||
require.Equal(t, "00000000-0000-0000-0000-000000000104", remaining[2].ID)
|
||||
}
|
||||
|
||||
func TestWorksmobileOutboxRepositoryListRecentByTenantRoot(t *testing.T) {
|
||||
repo := NewWorksmobileOutboxRepository(testDB)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, testDB.Exec("DELETE FROM worksmobile_outboxes").Error)
|
||||
|
||||
rows := []domain.WorksmobileOutbox{
|
||||
{
|
||||
ID: "00000000-0000-0000-0000-000000000151",
|
||||
ResourceType: domain.WorksmobileResourceUser,
|
||||
ResourceID: "user-root",
|
||||
Action: domain.WorksmobileActionUpsert,
|
||||
Status: domain.WorksmobileOutboxStatusFailed,
|
||||
DedupeKey: "recent-root-user",
|
||||
Payload: domain.JSONMap{"tenantRootId": "root-1"},
|
||||
CreatedAt: time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
ID: "00000000-0000-0000-0000-000000000152",
|
||||
ResourceType: domain.WorksmobileResourceOrgUnit,
|
||||
ResourceID: "child-tenant",
|
||||
Action: domain.WorksmobileActionUpsert,
|
||||
Status: domain.WorksmobileOutboxStatusFailed,
|
||||
DedupeKey: "recent-root-org-legacy",
|
||||
Payload: domain.JSONMap{},
|
||||
CreatedAt: time.Date(2026, 6, 1, 11, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
ID: "00000000-0000-0000-0000-000000000153",
|
||||
ResourceType: domain.WorksmobileResourceUser,
|
||||
ResourceID: "user-other",
|
||||
Action: domain.WorksmobileActionUpsert,
|
||||
Status: domain.WorksmobileOutboxStatusFailed,
|
||||
DedupeKey: "recent-other-root",
|
||||
Payload: domain.JSONMap{"tenantRootId": "root-2"},
|
||||
CreatedAt: time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC),
|
||||
},
|
||||
}
|
||||
for i := range rows {
|
||||
require.NoError(t, testDB.Create(&rows[i]).Error)
|
||||
}
|
||||
|
||||
recent, err := repo.ListRecentByTenantRoot(ctx, "root-1", []string{"child-tenant"}, 50)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, recent, 2)
|
||||
require.Equal(t, "00000000-0000-0000-0000-000000000152", recent[0].ID)
|
||||
require.Equal(t, "00000000-0000-0000-0000-000000000151", recent[1].ID)
|
||||
}
|
||||
|
||||
func TestWorksmobileOutboxRepositoryListReadyWaitsForPendingOrgUnitParent(t *testing.T) {
|
||||
repo := NewWorksmobileOutboxRepository(testDB)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -1924,6 +1924,20 @@ func (f *fakeWorksmobileOutboxRepo) ListRecent(ctx context.Context, limit int) (
|
||||
return f.recent, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileOutboxRepo) ListRecentByTenantRoot(ctx context.Context, tenantRootID string, resourceIDs []string, limit int) ([]domain.WorksmobileOutbox, error) {
|
||||
resourceIDSet := map[string]bool{}
|
||||
for _, id := range resourceIDs {
|
||||
resourceIDSet[id] = true
|
||||
}
|
||||
rows := make([]domain.WorksmobileOutbox, 0)
|
||||
for _, row := range f.recent {
|
||||
if stringValue(row.Payload["tenantRootId"]) == tenantRootID || resourceIDSet[row.ResourceID] {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileOutboxRepo) ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error) {
|
||||
rows := make([]domain.WorksmobileOutbox, 0)
|
||||
for _, row := range f.credentialBatchJobs {
|
||||
|
||||
@@ -46,7 +46,8 @@ type WorksmobileUserPayload struct {
|
||||
}
|
||||
|
||||
type WorksmobileUserName struct {
|
||||
LastName string `json:"lastName,omitempty"`
|
||||
LastName string `json:"lastName,omitempty"`
|
||||
FirstName string `json:"firstName,omitempty"`
|
||||
}
|
||||
|
||||
type WorksmobilePasswordConfig struct {
|
||||
@@ -61,6 +62,26 @@ func (c WorksmobilePasswordConfig) IsZero() bool {
|
||||
c.ChangePasswordAtNextLogin == nil
|
||||
}
|
||||
|
||||
func worksmobileUserNameFromDisplayName(name string) WorksmobileUserName {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || strings.ContainsAny(name, " \t\r\n") {
|
||||
return WorksmobileUserName{LastName: name}
|
||||
}
|
||||
runes := []rune(name)
|
||||
if len(runes) < 2 || len(runes) > 4 {
|
||||
return WorksmobileUserName{LastName: name}
|
||||
}
|
||||
for _, r := range runes {
|
||||
if r < '가' || r > '힣' {
|
||||
return WorksmobileUserName{LastName: name}
|
||||
}
|
||||
}
|
||||
return WorksmobileUserName{
|
||||
LastName: string(runes[:1]),
|
||||
FirstName: string(runes[1:]),
|
||||
}
|
||||
}
|
||||
|
||||
func (p WorksmobileUserPayload) MarshalJSON() ([]byte, error) {
|
||||
type payloadJSON struct {
|
||||
DomainID int64 `json:"domainId"`
|
||||
@@ -299,7 +320,7 @@ func buildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain
|
||||
DomainID: domainID,
|
||||
Email: strings.TrimSpace(user.Email),
|
||||
UserExternalKey: user.ID,
|
||||
UserName: WorksmobileUserName{LastName: strings.TrimSpace(user.Name)},
|
||||
UserName: worksmobileUserNameFromDisplayName(user.Name),
|
||||
CellPhone: domain.NormalizePhoneNumber(user.Phone),
|
||||
EmployeeNumber: employeeNumber,
|
||||
Locale: "ko_KR",
|
||||
|
||||
@@ -34,6 +34,7 @@ type WorksmobileSyncer interface {
|
||||
type WorksmobileAdminService interface {
|
||||
GetTenantOverview(ctx context.Context, tenantID string) (WorksmobileTenantOverview, error)
|
||||
GetComparison(ctx context.Context, tenantID string, includeMatched bool) (WorksmobileComparison, error)
|
||||
ImportUsersFromWorks(ctx context.Context, tenantID string, worksmobileUserIDs []string) (WorksmobileImportUsersResult, error)
|
||||
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)
|
||||
@@ -68,6 +69,27 @@ type WorksmobilePendingJobDeleteResult struct {
|
||||
DeletedCount int `json:"deletedCount"`
|
||||
}
|
||||
|
||||
type WorksmobileImportUsersResult struct {
|
||||
UpdatedCount int `json:"updatedCount"`
|
||||
CreatedCount int `json:"createdCount"`
|
||||
ExternalKeyUpdates int `json:"externalKeyUpdates"`
|
||||
Failures []WorksmobileImportUsersFailure `json:"failures,omitempty"`
|
||||
Items []WorksmobileImportUsersResultItem `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
type WorksmobileImportUsersFailure struct {
|
||||
WorksmobileID string `json:"worksmobileId,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type WorksmobileImportUsersResultItem struct {
|
||||
WorksmobileID string `json:"worksmobileId,omitempty"`
|
||||
BaronID string `json:"baronId,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
type WorksmobileInitialPasswordCredential struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name,omitempty"`
|
||||
@@ -178,6 +200,8 @@ type worksmobileSyncService struct {
|
||||
outboxRepo repository.WorksmobileOutboxRepository
|
||||
client WorksmobileDirectoryClient
|
||||
identityMirror WorksmobileIdentityMirror
|
||||
identityWriter IdentityWriteService
|
||||
kratos KratosAdminService
|
||||
}
|
||||
|
||||
type WorksmobileIdentityMirror interface {
|
||||
@@ -201,18 +225,30 @@ func (s *worksmobileSyncService) SetIdentityMirror(source WorksmobileIdentityMir
|
||||
s.identityMirror = source
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) SetIdentityServices(writer IdentityWriteService, kratos KratosAdminService) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.identityWriter = writer
|
||||
s.kratos = kratos
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) GetTenantOverview(ctx context.Context, tenantID string) (WorksmobileTenantOverview, error) {
|
||||
tenant, err := s.tenantService.GetTenant(ctx, tenantID)
|
||||
root, err := s.hanmacRoot(ctx, tenantID)
|
||||
if err != nil {
|
||||
return WorksmobileTenantOverview{}, err
|
||||
}
|
||||
jobs, _ := s.outboxRepo.ListRecent(ctx, 50)
|
||||
scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
|
||||
if err != nil {
|
||||
return WorksmobileTenantOverview{}, err
|
||||
}
|
||||
jobs, _ := s.outboxRepo.ListRecentByTenantRoot(ctx, root.ID, worksmobileRecentResourceIDs(root.ID, scopeTenants), 50)
|
||||
jobs = redactWorksmobileOutboxPayloads(jobs)
|
||||
return WorksmobileTenantOverview{
|
||||
Tenant: *tenant,
|
||||
Tenant: *root,
|
||||
Config: WorksmobileConfigSummary{
|
||||
Enabled: WorksmobileEnabled(tenant.Config),
|
||||
DomainMappings: WorksmobileDomainMappings(tenant.Config),
|
||||
Enabled: WorksmobileEnabled(root.Config),
|
||||
DomainMappings: WorksmobileDomainMappings(root.Config),
|
||||
TokenConfigured: worksmobileDirectoryAuthConfigured(),
|
||||
AdminTenantID: strings.TrimSpace(os.Getenv("WORKS_ADMIN_TENANT_ID")),
|
||||
},
|
||||
@@ -231,6 +267,15 @@ func worksmobileDirectoryAuthConfigured() bool {
|
||||
strings.TrimSpace(os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE")) != "")
|
||||
}
|
||||
|
||||
func worksmobileRecentResourceIDs(rootID string, tenants []domain.Tenant) []string {
|
||||
ids := make([]string, 0, len(tenants)+1)
|
||||
ids = append(ids, rootID)
|
||||
for _, tenant := range tenants {
|
||||
ids = append(ids, tenant.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func WorksmobileExcluded(config domain.JSONMap) bool {
|
||||
rawValue, ok := config[worksmobileExcludedConfigKey]
|
||||
if !ok {
|
||||
@@ -403,6 +448,273 @@ func (s *worksmobileSyncService) GetComparison(ctx context.Context, tenantID str
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) ImportUsersFromWorks(ctx context.Context, tenantID string, worksmobileUserIDs []string) (WorksmobileImportUsersResult, error) {
|
||||
root, err := s.hanmacRoot(ctx, tenantID)
|
||||
if err != nil {
|
||||
return WorksmobileImportUsersResult{}, err
|
||||
}
|
||||
if s.client == nil {
|
||||
return WorksmobileImportUsersResult{}, errors.New("worksmobile client is not configured")
|
||||
}
|
||||
if len(worksmobileUserIDs) == 0 {
|
||||
return WorksmobileImportUsersResult{}, errors.New("worksmobile user ids are required")
|
||||
}
|
||||
scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
|
||||
if err != nil {
|
||||
return WorksmobileImportUsersResult{}, err
|
||||
}
|
||||
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
||||
remoteUsers, err := s.client.ListUsers(ctx)
|
||||
if err != nil {
|
||||
return WorksmobileImportUsersResult{}, err
|
||||
}
|
||||
remoteGroups, err := s.client.ListGroups(ctx)
|
||||
if err != nil {
|
||||
return WorksmobileImportUsersResult{}, err
|
||||
}
|
||||
remoteByID := make(map[string]WorksmobileRemoteUser, len(remoteUsers))
|
||||
for _, remote := range remoteUsers {
|
||||
if id := strings.TrimSpace(remote.ID); id != "" {
|
||||
remoteByID[id] = remote
|
||||
}
|
||||
}
|
||||
groupByID := make(map[string]WorksmobileRemoteGroup, len(remoteGroups))
|
||||
for _, group := range remoteGroups {
|
||||
if id := strings.TrimSpace(group.ID); id != "" {
|
||||
groupByID[id] = group
|
||||
}
|
||||
}
|
||||
|
||||
result := WorksmobileImportUsersResult{}
|
||||
seen := map[string]bool{}
|
||||
for _, rawID := range worksmobileUserIDs {
|
||||
worksmobileID := strings.TrimSpace(rawID)
|
||||
if worksmobileID == "" || seen[worksmobileID] {
|
||||
continue
|
||||
}
|
||||
seen[worksmobileID] = true
|
||||
remote, ok := remoteByID[worksmobileID]
|
||||
if !ok {
|
||||
result.Failures = append(result.Failures, WorksmobileImportUsersFailure{WorksmobileID: worksmobileID, Error: "worksmobile user not found"})
|
||||
continue
|
||||
}
|
||||
user, created, externalKeyUpdated, err := s.importSingleWorksmobileUser(ctx, root.ID, remote, tenantByID, groupByID)
|
||||
if err != nil {
|
||||
result.Failures = append(result.Failures, WorksmobileImportUsersFailure{WorksmobileID: worksmobileID, Email: remote.Email, Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
action := "updated"
|
||||
if created {
|
||||
action = "created"
|
||||
result.CreatedCount++
|
||||
} else {
|
||||
result.UpdatedCount++
|
||||
}
|
||||
if externalKeyUpdated {
|
||||
result.ExternalKeyUpdates++
|
||||
}
|
||||
result.Items = append(result.Items, WorksmobileImportUsersResultItem{
|
||||
WorksmobileID: worksmobileID,
|
||||
BaronID: user.ID,
|
||||
Email: user.Email,
|
||||
Action: action,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) importSingleWorksmobileUser(ctx context.Context, rootID string, remote WorksmobileRemoteUser, tenantByID map[string]domain.Tenant, groupByID map[string]WorksmobileRemoteGroup) (domain.User, bool, bool, error) {
|
||||
email := strings.ToLower(strings.TrimSpace(remote.Email))
|
||||
if email == "" {
|
||||
return domain.User{}, false, false, errors.New("worksmobile user email is required")
|
||||
}
|
||||
tenantID := worksmobileTenantIDForRemoteUser(remote, groupByID)
|
||||
tenant, ok := tenantByID[tenantID]
|
||||
if !ok || !isWorksmobileUserScopeTenant(tenant) {
|
||||
return domain.User{}, false, false, fmt.Errorf("worksmobile primary org is outside import scope: %s", tenantID)
|
||||
}
|
||||
|
||||
var existing *domain.User
|
||||
if externalKey := strings.TrimSpace(remote.ExternalID); externalKey != "" {
|
||||
if user, err := s.userRepo.FindByID(ctx, externalKey); err == nil {
|
||||
existing = user
|
||||
} else {
|
||||
return domain.User{}, false, false, fmt.Errorf("worksmobile external key does not match a Baron user: %s", externalKey)
|
||||
}
|
||||
} else if user, err := s.userRepo.FindByEmail(ctx, email); err == nil {
|
||||
existing = user
|
||||
}
|
||||
|
||||
if existing != nil {
|
||||
user := *existing
|
||||
applyWorksmobileRemoteToUser(&user, remote, tenant.ID)
|
||||
if err := s.updateImportedWorksmobileUserIdentity(ctx, user); err != nil {
|
||||
return domain.User{}, false, false, err
|
||||
}
|
||||
if err := s.userRepo.Update(ctx, &user); err != nil {
|
||||
return domain.User{}, false, false, err
|
||||
}
|
||||
updatedExternalKey := false
|
||||
if strings.TrimSpace(remote.ExternalID) == "" {
|
||||
if err := s.patchWorksmobileUserExternalKey(ctx, remote, user.ID); err != nil {
|
||||
return domain.User{}, false, false, err
|
||||
}
|
||||
updatedExternalKey = true
|
||||
}
|
||||
return user, false, updatedExternalKey, nil
|
||||
}
|
||||
|
||||
if strings.TrimSpace(remote.ExternalID) != "" {
|
||||
return domain.User{}, false, false, errors.New("creating Baron user from non-empty unmatched worksmobile external key is not supported")
|
||||
}
|
||||
if s.kratos == nil {
|
||||
return domain.User{}, false, false, errors.New("kratos admin service is required")
|
||||
}
|
||||
identityID, err := s.kratos.CreateUser(ctx, &domain.BrokerUser{
|
||||
Email: email,
|
||||
Name: strings.TrimSpace(remote.DisplayName),
|
||||
PhoneNumber: strings.TrimSpace(remote.CellPhone),
|
||||
Attributes: map[string]any{
|
||||
"tenant_id": tenant.ID,
|
||||
"role": domain.RoleUser,
|
||||
"status": domain.UserStatusActive,
|
||||
"grade": strings.TrimSpace(remote.LevelName),
|
||||
"jobTitle": strings.TrimSpace(remote.Task),
|
||||
},
|
||||
}, GenerateWorksmobileInitialPassword())
|
||||
if err != nil {
|
||||
return domain.User{}, false, false, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
user := domain.User{
|
||||
ID: identityID,
|
||||
Email: email,
|
||||
Name: strings.TrimSpace(remote.DisplayName),
|
||||
Phone: strings.TrimSpace(remote.CellPhone),
|
||||
Role: domain.RoleUser,
|
||||
Status: domain.UserStatusActive,
|
||||
TenantID: &tenant.ID,
|
||||
Grade: strings.TrimSpace(remote.LevelName),
|
||||
JobTitle: strings.TrimSpace(remote.Task),
|
||||
Metadata: worksmobileImportedUserMetadata(remote, tenant),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := s.userRepo.Update(ctx, &user); err != nil {
|
||||
return domain.User{}, false, false, err
|
||||
}
|
||||
if err := s.patchWorksmobileUserExternalKey(ctx, remote, user.ID); err != nil {
|
||||
return domain.User{}, false, false, err
|
||||
}
|
||||
return user, true, true, nil
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) updateImportedWorksmobileUserIdentity(ctx context.Context, user domain.User) error {
|
||||
if s.identityWriter == nil {
|
||||
return nil
|
||||
}
|
||||
identity, err := s.identityWriter.GetIdentity(ctx, user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
traits := map[string]any{}
|
||||
for key, value := range identity.Traits {
|
||||
traits[key] = value
|
||||
}
|
||||
traits["email"] = user.Email
|
||||
traits["name"] = user.Name
|
||||
if phone := strings.TrimSpace(user.Phone); phone != "" {
|
||||
traits["phone_number"] = phone
|
||||
}
|
||||
traits["tenant_id"] = strings.TrimSpace(stringPtrValue(user.TenantID))
|
||||
traits["role"] = user.Role
|
||||
traits["status"] = user.Status
|
||||
traits["grade"] = user.Grade
|
||||
traits["jobTitle"] = user.JobTitle
|
||||
_, err = s.identityWriter.UpdateIdentity(ctx, IdentityUpdateRequest{
|
||||
IdentityID: user.ID,
|
||||
Traits: traits,
|
||||
State: strings.TrimSpace(identity.State),
|
||||
Reason: "worksmobile_import_from_works",
|
||||
Source: "admin_worksmobile",
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) patchWorksmobileUserExternalKey(ctx context.Context, remote WorksmobileRemoteUser, userID string) error {
|
||||
return s.client.UpdateUserOnly(ctx, WorksmobileUserPayload{
|
||||
DomainID: remote.DomainID,
|
||||
Email: strings.TrimSpace(remote.Email),
|
||||
UserExternalKey: strings.TrimSpace(userID),
|
||||
CellPhone: strings.TrimSpace(remote.CellPhone),
|
||||
EmployeeNumber: strings.TrimSpace(remote.EmployeeNumber),
|
||||
Locale: "ko_KR",
|
||||
Task: strings.TrimSpace(remote.Task),
|
||||
})
|
||||
}
|
||||
|
||||
func applyWorksmobileRemoteToUser(user *domain.User, remote WorksmobileRemoteUser, tenantID string) {
|
||||
now := time.Now().UTC()
|
||||
user.Email = strings.ToLower(strings.TrimSpace(remote.Email))
|
||||
user.Name = strings.TrimSpace(remote.DisplayName)
|
||||
user.Phone = strings.TrimSpace(remote.CellPhone)
|
||||
user.Role = domain.NormalizeRole(user.Role)
|
||||
user.Status = domain.UserStatusActive
|
||||
user.TenantID = &tenantID
|
||||
user.Grade = strings.TrimSpace(remote.LevelName)
|
||||
user.JobTitle = strings.TrimSpace(remote.Task)
|
||||
user.Metadata = mergeWorksmobileImportedUserMetadata(user.Metadata, remote, tenantID)
|
||||
user.UpdatedAt = now
|
||||
}
|
||||
|
||||
func worksmobileImportedUserMetadata(remote WorksmobileRemoteUser, tenant domain.Tenant) domain.JSONMap {
|
||||
return mergeWorksmobileImportedUserMetadata(domain.JSONMap{}, remote, tenant.ID)
|
||||
}
|
||||
|
||||
func mergeWorksmobileImportedUserMetadata(metadata domain.JSONMap, remote WorksmobileRemoteUser, tenantID string) domain.JSONMap {
|
||||
if metadata == nil {
|
||||
metadata = domain.JSONMap{}
|
||||
}
|
||||
if value := strings.TrimSpace(remote.EmployeeNumber); value != "" {
|
||||
metadata["employeeNumber"] = value
|
||||
metadata["employee_id"] = value
|
||||
}
|
||||
if value := strings.TrimSpace(remote.LevelName); value != "" {
|
||||
metadata["grade"] = value
|
||||
}
|
||||
if value := strings.TrimSpace(remote.PrimaryOrgUnitName); value != "" {
|
||||
metadata["department"] = value
|
||||
}
|
||||
metadata["worksmobileImportedAt"] = time.Now().UTC().Format(time.RFC3339Nano)
|
||||
metadata["worksmobileId"] = strings.TrimSpace(remote.ID)
|
||||
metadata["worksmobileDomainId"] = remote.DomainID
|
||||
metadata["worksmobilePrimaryOrgUnitId"] = strings.TrimSpace(remote.PrimaryOrgUnitID)
|
||||
metadata["additionalAppointments"] = []domain.JSONMap{{
|
||||
"tenantId": tenantID,
|
||||
"isPrimary": true,
|
||||
"grade": strings.TrimSpace(remote.LevelName),
|
||||
}}
|
||||
return metadata
|
||||
}
|
||||
|
||||
func worksmobileTenantIDForRemoteUser(remote WorksmobileRemoteUser, groupByID map[string]WorksmobileRemoteGroup) string {
|
||||
primaryOrgUnitID := strings.TrimSpace(remote.PrimaryOrgUnitID)
|
||||
if tenantID, ok := strings.CutPrefix(primaryOrgUnitID, "externalKey:"); ok {
|
||||
return strings.TrimSpace(tenantID)
|
||||
}
|
||||
if group, ok := groupByID[primaryOrgUnitID]; ok {
|
||||
return strings.TrimSpace(group.ExternalID)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func stringPtrValue(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) comparisonUsers(ctx context.Context, tenantIDs []string, tenantByID map[string]domain.Tenant) ([]domain.User, error) {
|
||||
if s.identityMirror != nil {
|
||||
status, err := s.identityMirror.GetIdentityCacheStatus(ctx)
|
||||
@@ -586,8 +898,9 @@ func (s *worksmobileSyncService) EnqueueBackfillDryRun(ctx context.Context, tena
|
||||
Action: domain.WorksmobileActionDryRun,
|
||||
DedupeKey: "backfill:dry-run:" + root.ID,
|
||||
Payload: domain.JSONMap{
|
||||
"tenantIds": orgUnitTenantIDs,
|
||||
"userCount": len(users),
|
||||
"tenantRootId": root.ID,
|
||||
"tenantIds": orgUnitTenantIDs,
|
||||
"userCount": len(users),
|
||||
},
|
||||
})
|
||||
return WorksmobileBackfillDryRun{OrgUnitCount: len(orgUnitTenantIDs), UserCount: len(users)}, nil
|
||||
@@ -604,10 +917,17 @@ func (s *worksmobileSyncService) EnqueueOrgUnitSync(ctx context.Context, tenantI
|
||||
}
|
||||
tenantRoot, ok, err := s.rootForTenant(ctx, *tenant)
|
||||
if err != nil {
|
||||
if recordErr := s.recordRejectedOrgUnitSync(ctx, root.ID, *tenant, err); recordErr != nil {
|
||||
return nil, errors.Join(err, recordErr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !ok || tenantRoot.ID != root.ID {
|
||||
return nil, errors.New("target orgunit is outside hanmac-family subtree")
|
||||
err := errors.New("target orgunit is outside hanmac-family subtree")
|
||||
if recordErr := s.recordRejectedOrgUnitSync(ctx, root.ID, *tenant, err); recordErr != nil {
|
||||
return nil, errors.Join(err, recordErr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
|
||||
if err != nil {
|
||||
@@ -615,10 +935,18 @@ func (s *worksmobileSyncService) EnqueueOrgUnitSync(ctx context.Context, tenantI
|
||||
}
|
||||
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
||||
if _, ok := tenantByID[tenant.ID]; !ok {
|
||||
return nil, errors.New("target tenant is excluded from Worksmobile sync")
|
||||
err := errors.New("target tenant is excluded from Worksmobile sync")
|
||||
if recordErr := s.recordRejectedOrgUnitSync(ctx, root.ID, *tenant, err); recordErr != nil {
|
||||
return nil, errors.Join(err, recordErr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !isWorksmobileOrgUnitTenant(*tenant, tenantByID) {
|
||||
return nil, errors.New("target tenant is not a worksmobile orgunit tenant")
|
||||
err := errors.New("target tenant is not a worksmobile orgunit tenant")
|
||||
if recordErr := s.recordRejectedOrgUnitSync(ctx, root.ID, *tenant, err); recordErr != nil {
|
||||
return nil, errors.Join(err, recordErr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return s.enqueueOrgUnitUpsert(ctx, root, *tenant, scopeTenants)
|
||||
}
|
||||
@@ -632,6 +960,9 @@ func (s *worksmobileSyncService) enqueueOrgUnitUpsert(ctx context.Context, root
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
if recordErr := s.recordRejectedOrgUnitSync(ctx, root.ID, tenant, err); recordErr != nil {
|
||||
return nil, errors.Join(err, recordErr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
payload = normalizeWorksmobileOrgUnitParent(payload, tenant, tenantByID, root.ID)
|
||||
@@ -641,6 +972,7 @@ func (s *worksmobileSyncService) enqueueOrgUnitUpsert(ctx context.Context, root
|
||||
Action: domain.WorksmobileActionUpsert,
|
||||
DedupeKey: "orgunit:upsert:" + tenant.ID,
|
||||
Payload: domain.JSONMap{
|
||||
"tenantRootId": root.ID,
|
||||
"request": payload,
|
||||
"matchLocalPart": tenant.Slug,
|
||||
},
|
||||
@@ -651,6 +983,36 @@ func (s *worksmobileSyncService) enqueueOrgUnitUpsert(ctx context.Context, root
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) recordRejectedOrgUnitSync(ctx context.Context, rootID string, tenant domain.Tenant, reason error) error {
|
||||
if s.outboxRepo == nil {
|
||||
return nil
|
||||
}
|
||||
payload := domain.JSONMap{
|
||||
"tenantRootId": rootID,
|
||||
"displayName": strings.TrimSpace(tenant.Name),
|
||||
"matchLocalPart": strings.TrimSpace(tenant.Slug),
|
||||
"tenantSlug": strings.TrimSpace(tenant.Slug),
|
||||
"requestSummary": domain.JSONMap{
|
||||
"orgUnitName": strings.TrimSpace(tenant.Name),
|
||||
"orgUnitExternalKey": tenant.ID,
|
||||
"tenantSlug": strings.TrimSpace(tenant.Slug),
|
||||
},
|
||||
}
|
||||
if tenant.ParentID != nil {
|
||||
payload["parentTenantId"] = strings.TrimSpace(*tenant.ParentID)
|
||||
}
|
||||
item := &domain.WorksmobileOutbox{
|
||||
ResourceType: domain.WorksmobileResourceOrgUnit,
|
||||
ResourceID: tenant.ID,
|
||||
Action: domain.WorksmobileActionUpsert,
|
||||
DedupeKey: "orgunit:rejected:" + tenant.ID + ":" + uuid.NewString(),
|
||||
Payload: payload,
|
||||
Status: domain.WorksmobileOutboxStatusFailed,
|
||||
LastError: reason.Error(),
|
||||
}
|
||||
return s.outboxRepo.Create(ctx, item)
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) EnqueueOrgUnitDelete(ctx context.Context, tenantID, worksmobileOrgUnitID string) (*domain.WorksmobileOutbox, error) {
|
||||
root, err := s.hanmacRoot(ctx, tenantID)
|
||||
if err != nil {
|
||||
@@ -692,6 +1054,7 @@ func (s *worksmobileSyncService) EnqueueOrgUnitDelete(ctx context.Context, tenan
|
||||
Action: domain.WorksmobileActionDelete,
|
||||
DedupeKey: "orgunit:delete:works:" + worksmobileOrgUnitID,
|
||||
Payload: domain.JSONMap{
|
||||
"tenantRootId": root.ID,
|
||||
"worksmobileId": worksmobileOrgUnitID,
|
||||
"externalKey": target.ExternalID,
|
||||
"domainId": target.DomainID,
|
||||
@@ -756,7 +1119,7 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
|
||||
return nil, err
|
||||
}
|
||||
action := WorksmobileUserStatusAction(user.Status)
|
||||
if action == domain.WorksmobileActionUpsert {
|
||||
if action == domain.WorksmobileActionUpsert && strings.TrimSpace(initialPassword) != "" {
|
||||
payload.PasswordConfig = worksmobileAdminInitialPasswordConfig(initialPassword)
|
||||
}
|
||||
item := &domain.WorksmobileOutbox{
|
||||
@@ -768,7 +1131,7 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
|
||||
}
|
||||
item.Payload["displayName"] = strings.TrimSpace(user.Name)
|
||||
item.Payload["primaryLeafOrgName"] = worksmobileUserPrimaryOrgName(*user, tenantByID)
|
||||
if batchID := strings.TrimSpace(credentialBatchID); batchID != "" {
|
||||
if batchID := strings.TrimSpace(credentialBatchID); batchID != "" && strings.TrimSpace(payload.PasswordConfig.Password) != "" {
|
||||
item.Payload["credentialBatchId"] = batchID
|
||||
item.Payload["credentialOperation"] = "worksmobile_user_sync"
|
||||
item.Payload["credentialBatchCreatedAt"] = time.Now().UTC().Format(time.RFC3339Nano)
|
||||
@@ -783,7 +1146,7 @@ func (s *worksmobileSyncService) recordRejectedUserSync(ctx context.Context, roo
|
||||
payload := WorksmobileUserPayload{
|
||||
Email: strings.TrimSpace(user.Email),
|
||||
UserExternalKey: user.ID,
|
||||
UserName: WorksmobileUserName{LastName: strings.TrimSpace(user.Name)},
|
||||
UserName: worksmobileUserNameFromDisplayName(user.Name),
|
||||
CellPhone: domain.NormalizePhoneNumber(user.Phone),
|
||||
EmployeeNumber: metadataEmployeeNumber(user.Metadata),
|
||||
Locale: "ko_KR",
|
||||
|
||||
@@ -164,7 +164,7 @@ func TestWorksmobileSyncServiceEnqueuesUserCredentialBatchID(t *testing.T) {
|
||||
require.True(t, *request.PasswordConfig.ChangePasswordAtNextLogin)
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceAutoGeneratesAdminInitialPassword(t *testing.T) {
|
||||
func TestWorksmobileSyncServiceSkipsAdminInitialPasswordWhenEmpty(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "root-tenant"
|
||||
tenantID := "saman-tenant"
|
||||
@@ -201,13 +201,13 @@ func TestWorksmobileSyncServiceAutoGeneratesAdminInitialPassword(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, item)
|
||||
initialPassword := stringValue(outboxRepo.created[0].Payload["initialPassword"])
|
||||
require.NotEmpty(t, initialPassword)
|
||||
require.Empty(t, initialPassword)
|
||||
request, ok := outboxRepo.created[0].Payload["request"].(WorksmobileUserPayload)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "ADMIN", request.PasswordConfig.PasswordCreationType)
|
||||
require.Equal(t, initialPassword, request.PasswordConfig.Password)
|
||||
require.NotNil(t, request.PasswordConfig.ChangePasswordAtNextLogin)
|
||||
require.True(t, *request.PasswordConfig.ChangePasswordAtNextLogin)
|
||||
require.Empty(t, request.PasswordConfig.PasswordCreationType)
|
||||
require.Empty(t, request.PasswordConfig.Password)
|
||||
require.Nil(t, request.PasswordConfig.ChangePasswordAtNextLogin)
|
||||
require.Empty(t, stringValue(outboxRepo.created[0].Payload["credentialBatchId"]))
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceCreatesDistinctUserSyncHistoryJobs(t *testing.T) {
|
||||
@@ -661,6 +661,7 @@ func TestWorksmobileSyncServiceOverviewKeepsSafeRecentJobChangeLogPayload(t *tes
|
||||
Action: domain.WorksmobileActionUpsert,
|
||||
Status: domain.WorksmobileOutboxStatusProcessed,
|
||||
Payload: domain.JSONMap{
|
||||
"tenantRootId": root.ID,
|
||||
"loginEmail": "changed@example.com",
|
||||
"displayName": "변경 사용자",
|
||||
"primaryLeafOrgName": "인재성장",
|
||||
@@ -680,6 +681,7 @@ func TestWorksmobileSyncServiceOverviewKeepsSafeRecentJobChangeLogPayload(t *tes
|
||||
Action: domain.WorksmobileActionUpsert,
|
||||
Status: domain.WorksmobileOutboxStatusProcessed,
|
||||
Payload: domain.JSONMap{
|
||||
"tenantRootId": root.ID,
|
||||
"matchLocalPart": "people-growth",
|
||||
"request": WorksmobileOrgUnitPayload{
|
||||
OrgUnitName: "인재성장",
|
||||
@@ -725,6 +727,67 @@ func TestWorksmobileSyncServiceOverviewKeepsSafeRecentJobChangeLogPayload(t *tes
|
||||
}, orgPayload["requestSummary"])
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceOverviewScopesRecentJobsToTenantRoot(t *testing.T) {
|
||||
rootID := "root-tenant"
|
||||
childID := "child-org"
|
||||
otherRootID := "other-root"
|
||||
root := domain.Tenant{
|
||||
ID: rootID,
|
||||
Slug: HanmacFamilyTenantSlug,
|
||||
Name: "한맥가족",
|
||||
}
|
||||
child := domain.Tenant{
|
||||
ID: childID,
|
||||
Slug: "structure-planning",
|
||||
Name: "구조물계획",
|
||||
Type: domain.TenantTypeUserGroup,
|
||||
ParentID: &rootID,
|
||||
}
|
||||
outboxRepo := &fakeWorksmobileOutboxRepo{
|
||||
recent: []domain.WorksmobileOutbox{
|
||||
{
|
||||
ID: "job-root-user-failed",
|
||||
ResourceType: domain.WorksmobileResourceUser,
|
||||
ResourceID: "user-1",
|
||||
Status: domain.WorksmobileOutboxStatusFailed,
|
||||
Payload: domain.JSONMap{"tenantRootId": rootID},
|
||||
LastError: "worksmobile api failed",
|
||||
},
|
||||
{
|
||||
ID: "job-child-org-legacy",
|
||||
ResourceType: domain.WorksmobileResourceOrgUnit,
|
||||
ResourceID: childID,
|
||||
Status: domain.WorksmobileOutboxStatusFailed,
|
||||
LastError: "legacy org job without tenantRootId",
|
||||
},
|
||||
{
|
||||
ID: "job-other-root",
|
||||
ResourceType: domain.WorksmobileResourceUser,
|
||||
ResourceID: "user-2",
|
||||
Status: domain.WorksmobileOutboxStatusFailed,
|
||||
Payload: domain.JSONMap{"tenantRootId": otherRootID},
|
||||
},
|
||||
},
|
||||
}
|
||||
service := NewWorksmobileSyncService(
|
||||
&fakeWorksmobileTenantService{
|
||||
tenants: map[string]domain.Tenant{rootID: root, childID: child},
|
||||
list: []domain.Tenant{child},
|
||||
},
|
||||
&fakeWorksmobileUserRepo{},
|
||||
outboxRepo,
|
||||
nil,
|
||||
)
|
||||
|
||||
overview, err := service.GetTenantOverview(context.Background(), rootID)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, overview.RecentJobs, 2)
|
||||
require.Equal(t, "job-root-user-failed", overview.RecentJobs[0].ID)
|
||||
require.Equal(t, "job-child-org-legacy", overview.RecentJobs[1].ID)
|
||||
require.Equal(t, "legacy org job without tenantRootId", overview.RecentJobs[1].LastError)
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceEnqueueTenantUpsertReflectsChangedParentOrgUnit(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "root-tenant"
|
||||
@@ -1041,7 +1104,12 @@ func TestWorksmobileSyncServiceRejectsDomainCompanyOrgUnitSync(t *testing.T) {
|
||||
require.Nil(t, item)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "worksmobile orgunit tenant")
|
||||
require.Empty(t, outboxRepo.created)
|
||||
require.Len(t, outboxRepo.created, 1)
|
||||
require.Equal(t, domain.WorksmobileResourceOrgUnit, outboxRepo.created[0].ResourceType)
|
||||
require.Equal(t, domain.WorksmobileOutboxStatusFailed, outboxRepo.created[0].Status)
|
||||
require.Equal(t, companyID, outboxRepo.created[0].ResourceID)
|
||||
require.Equal(t, rootID, outboxRepo.created[0].Payload["tenantRootId"])
|
||||
require.Equal(t, "target tenant is not a worksmobile orgunit tenant", outboxRepo.created[0].LastError)
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceEnqueuesBarongroupChildCompanyOrgUnitSync(t *testing.T) {
|
||||
@@ -2046,7 +2114,13 @@ func TestWorksmobileSyncServiceRejectsExcludedOrgUnitSync(t *testing.T) {
|
||||
|
||||
require.Nil(t, item)
|
||||
require.ErrorContains(t, err, "excluded from Worksmobile sync")
|
||||
require.Empty(t, outboxRepo.created)
|
||||
require.Len(t, outboxRepo.created, 1)
|
||||
require.Equal(t, domain.WorksmobileResourceOrgUnit, outboxRepo.created[0].ResourceType)
|
||||
require.Equal(t, domain.WorksmobileOutboxStatusFailed, outboxRepo.created[0].Status)
|
||||
require.Equal(t, excludedOrgID, outboxRepo.created[0].ResourceID)
|
||||
require.Equal(t, rootID, outboxRepo.created[0].Payload["tenantRootId"])
|
||||
require.Equal(t, "excluded-team", outboxRepo.created[0].Payload["matchLocalPart"])
|
||||
require.Equal(t, "target tenant is excluded from Worksmobile sync", outboxRepo.created[0].LastError)
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceSkipsExcludedTenantAndUserEventSync(t *testing.T) {
|
||||
|
||||
@@ -3,6 +3,7 @@ services:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
target: dev
|
||||
container_name: baron_backend
|
||||
env_file:
|
||||
- .env
|
||||
@@ -42,14 +43,14 @@ services:
|
||||
- ./backend:/app
|
||||
- ./config:/app/config:ro
|
||||
- ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro
|
||||
command: ["go", "run", "./cmd/server"]
|
||||
command: ["/usr/local/bin/baron-backend-dev"]
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
retries: 12
|
||||
start_period: 60s
|
||||
|
||||
adminfront:
|
||||
build:
|
||||
|
||||
@@ -3,6 +3,7 @@ services:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
target: dev
|
||||
container_name: baron_backend
|
||||
env_file:
|
||||
- .env
|
||||
@@ -42,14 +43,14 @@ services:
|
||||
- ./backend:/app
|
||||
- ./config:/app/config:ro
|
||||
- ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro
|
||||
command: ["go", "run", "./cmd/server"]
|
||||
command: ["/usr/local/bin/baron-backend-dev"]
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
retries: 12
|
||||
start_period: 60s
|
||||
|
||||
adminfront:
|
||||
build:
|
||||
|
||||
@@ -26,7 +26,7 @@ services:
|
||||
- baron_net
|
||||
- ory-net
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
|
||||
test: ["CMD", "/app/healthcheck"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
@@ -26,11 +26,11 @@ services:
|
||||
depends_on:
|
||||
- infra_check
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
|
||||
test: ["CMD", "/app/healthcheck"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
retries: 12
|
||||
start_period: 60s
|
||||
networks:
|
||||
- baron_net
|
||||
|
||||
|
||||
@@ -364,6 +364,7 @@ services:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
target: dev
|
||||
container_name: baron_backend
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
@@ -412,13 +413,13 @@ services:
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro
|
||||
command: ["go", "run", "./cmd/server"]
|
||||
command: ["/usr/local/bin/baron-backend-dev"]
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
retries: 12
|
||||
start_period: 60s
|
||||
|
||||
adminfront:
|
||||
build:
|
||||
|
||||
@@ -38,8 +38,13 @@ if ! grep -Eq "^go ${TARGET_GO_VERSION}$" "$GO_MOD"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Eq "^FROM golang:${TARGET_GO_VERSION}-alpine$" "$BACKEND_DOCKERFILE"; then
|
||||
echo "ERROR: backend Dockerfile must use golang:${TARGET_GO_VERSION}-alpine." >&2
|
||||
if ! grep -Eq "^FROM golang:${TARGET_GO_VERSION}-alpine AS base$" "$BACKEND_DOCKERFILE"; then
|
||||
echo "ERROR: backend Dockerfile base stage must use golang:${TARGET_GO_VERSION}-alpine." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Eq "^FROM gcr\\.io/distroless/static-debian13:nonroot AS production$" "$BACKEND_DOCKERFILE"; then
|
||||
echo "ERROR: backend Dockerfile production stage must use distroless/static-debian13:nonroot." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
Reference in New Issue
Block a user