1
0
forked from baron/baron-sso

패키징 개선

This commit is contained in:
2026-06-22 17:56:20 +09:00
parent 12d8d0e832
commit 9cbc9828e6
27 changed files with 1239 additions and 177 deletions

View File

@@ -91,6 +91,7 @@ jobs:
with: with:
context: ./backend context: ./backend
file: ./backend/Dockerfile file: ./backend/Dockerfile
target: production
load: true load: true
tags: baron_sso/backend:${{ steps.version.outputs.image_tag }} tags: baron_sso/backend:${{ steps.version.outputs.image_tag }}
provenance: false provenance: false

View File

@@ -76,6 +76,40 @@ describe("TenantProfilePage initial profile loading", () => {
expect(screen.getByTestId("tenant-org-unit-type-select")).toHaveValue("팀"); expect(screen.getByTestId("tenant-org-unit-type-select")).toHaveValue("팀");
expect(screen.getByLabelText("공개 범위")).toHaveValue("internal"); expect(screen.getByLabelText("공개 범위")).toHaveValue("internal");
expect(screen.getByLabelText("WORKS 연동")).toHaveValue("excluded"); 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();
}); });
}); });

View File

@@ -97,7 +97,7 @@ export function TenantProfilePage() {
const parentQuery = useQuery({ const parentQuery = useQuery({
queryKey: ["tenants", "list-all"], queryKey: ["tenants", "list-all"],
queryFn: () => fetchAllTenants(), queryFn: () => fetchAllTenants(),
enabled: !!tenantQuery.data && !hasPersistedOrgConfig, enabled: !!tenantQuery.data,
}); });
const allTenants = parentQuery.data?.items ?? []; const allTenants = parentQuery.data?.items ?? [];
const orgConfigCandidate = tenantQuery.data const orgConfigCandidate = tenantQuery.data

View File

@@ -284,7 +284,7 @@ describe("TenantWorksmobilePage comparison helpers", () => {
baron: true, baron: true,
baronOrg: true, baronOrg: true,
worksmobileId: false, worksmobileId: false,
externalKey: false, externalKey: true,
worksmobileDomain: true, worksmobileDomain: true,
worksmobile: true, worksmobile: true,
worksmobileOrg: true, worksmobileOrg: true,

View File

@@ -29,6 +29,13 @@ import {
DialogTrigger, DialogTrigger,
} from "../../../components/ui/dialog"; } from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input"; import { Input } from "../../../components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../../../components/ui/select";
import { import {
Table, Table,
TableBody, TableBody,
@@ -47,6 +54,7 @@ import {
fetchMe, fetchMe,
fetchWorksmobileComparison, fetchWorksmobileComparison,
fetchWorksmobileOverview, fetchWorksmobileOverview,
importWorksmobileUsersFromWorks,
retryWorksmobileJob, retryWorksmobileJob,
type WorksmobileComparisonItem, type WorksmobileComparisonItem,
type WorksmobileOutboxItem, type WorksmobileOutboxItem,
@@ -76,7 +84,7 @@ import {
getWorksmobileRowSelectionKey, getWorksmobileRowSelectionKey,
getWorksmobileSelectedActionIds, getWorksmobileSelectedActionIds,
getWorksmobileSelectedCreateUserIds, getWorksmobileSelectedCreateUserIds,
getWorksmobileSelectedUpdateUserIds, getWorksmobileSelectedImportUserIds,
getWorksmobileSelectedWorksOnlyOrgUnitIds, getWorksmobileSelectedWorksOnlyOrgUnitIds,
summarizeWorksmobileComparison, summarizeWorksmobileComparison,
type WorksmobileAccountStatusFilter, type WorksmobileAccountStatusFilter,
@@ -272,6 +280,7 @@ export function TenantWorksmobilePage() {
overviewQuery.refetch(); overviewQuery.refetch();
}, },
onError: (error) => { onError: (error) => {
overviewQuery.refetch();
toast.error("조직 Sync 작업 등록 실패", { toast.error("조직 Sync 작업 등록 실패", {
description: getErrorMessage(error), description: getErrorMessage(error),
}); });
@@ -285,6 +294,7 @@ export function TenantWorksmobilePage() {
overviewQuery.refetch(); overviewQuery.refetch();
}, },
onError: (error) => { onError: (error) => {
overviewQuery.refetch();
toast.error("구성원 Sync 작업 등록 실패", { toast.error("구성원 Sync 작업 등록 실패", {
description: getErrorMessage(error), description: getErrorMessage(error),
}); });
@@ -296,12 +306,15 @@ export function TenantWorksmobilePage() {
resourceKind, resourceKind,
ids, ids,
initialPassword, initialPassword,
initialPasswordUserIds,
}: { }: {
resourceKind: "users" | "groups"; resourceKind: "users" | "groups";
ids: string[]; ids: string[];
initialPassword?: string; initialPassword?: string;
initialPasswordUserIds?: string[];
}) => { }) => {
const trimmedInitialPassword = initialPassword?.trim(); const trimmedInitialPassword = initialPassword?.trim();
const passwordUserIdSet = new Set(initialPasswordUserIds ?? []);
const failures: string[] = []; const failures: string[] = [];
let successCount = 0; let successCount = 0;
for (const id of ids) { for (const id of ids) {
@@ -311,7 +324,7 @@ export function TenantWorksmobilePage() {
tenantId, tenantId,
id, id,
undefined, undefined,
trimmedInitialPassword, passwordUserIdSet.has(id) ? trimmedInitialPassword : undefined,
); );
} else { } else {
await enqueueWorksmobileOrgUnitSync(tenantId, id); await enqueueWorksmobileOrgUnitSync(tenantId, id);
@@ -355,12 +368,56 @@ export function TenantWorksmobilePage() {
comparisonQuery.refetch(); comparisonQuery.refetch();
}, },
onError: (error) => { onError: (error) => {
overviewQuery.refetch();
toast.error("WORKS 생성 작업 등록 실패", { toast.error("WORKS 생성 작업 등록 실패", {
description: getErrorMessage(error), 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({ const syncSelectedOrgUnitsMutation = useMutation({
mutationFn: async ({ mutationFn: async ({
baronIds, baronIds,
@@ -389,6 +446,7 @@ export function TenantWorksmobilePage() {
comparisonQuery.refetch(); comparisonQuery.refetch();
}, },
onError: (error) => { onError: (error) => {
overviewQuery.refetch();
toast.error("선택 조직 동기화 작업 등록 실패", { toast.error("선택 조직 동기화 작업 등록 실패", {
description: getErrorMessage(error), description: getErrorMessage(error),
}); });
@@ -561,6 +619,7 @@ export function TenantWorksmobilePage() {
<TableHead></TableHead> <TableHead></TableHead>
<TableHead> </TableHead> <TableHead> </TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead>
<TableHead>retry</TableHead> <TableHead>retry</TableHead>
<TableHead className="w-24" /> <TableHead className="w-24" />
</TableRow> </TableRow>
@@ -605,7 +664,29 @@ export function TenantWorksmobilePage() {
</details> </details>
)} )}
</TableCell> </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>{job.retryCount}</TableCell>
<TableCell> <TableCell>
<Button <Button
@@ -668,21 +749,24 @@ export function TenantWorksmobilePage() {
visibleColumns={userVisibleColumns} visibleColumns={userVisibleColumns}
onVisibleColumnsChange={setUserVisibleColumns} onVisibleColumnsChange={setUserVisibleColumns}
passwordManageTenantId={overview?.config.adminTenantId} passwordManageTenantId={overview?.config.adminTenantId}
actionLabel="선택 구성원 WORKS에 생성" actionLabel="Works에 정보 넣기"
actionDisabled={isCreatingUsers || createSelectedMutation.isPending} actionDisabled={
updateActionLabel="선택 구성원 업데이트 적용" isCreatingUsers ||
onCreateSelected={(ids, initialPassword) => createSelectedMutation.isPending ||
importSelectedUsersMutation.isPending
}
importActionLabel="Works정보 가져오기"
importActionDisabled={importSelectedUsersMutation.isPending}
onCreateSelected={(ids, initialPassword, initialPasswordUserIds) =>
createSelectedMutation.mutateAsync({ createSelectedMutation.mutateAsync({
resourceKind: "users", resourceKind: "users",
ids, ids,
initialPassword, initialPassword,
initialPasswordUserIds,
}) })
} }
onUpdateSelected={(ids) => onImportSelected={(ids) =>
createSelectedMutation.mutate({ importSelectedUsersMutation.mutate(ids)
resourceKind: "users",
ids,
})
} }
requireInitialPassword requireInitialPassword
/> />
@@ -1015,10 +1099,11 @@ function ComparisonTable({
showBaronIdColumn = true, showBaronIdColumn = true,
showManageColumn = true, showManageColumn = true,
actionLabel, actionLabel,
updateActionLabel, importActionLabel,
importActionDisabled = false,
actionDisabled, actionDisabled,
onCreateSelected, onCreateSelected,
onUpdateSelected, onImportSelected,
onRunSelected, onRunSelected,
deleteActionLabel, deleteActionLabel,
deleteActionDisabled = false, deleteActionDisabled = false,
@@ -1051,10 +1136,15 @@ function ComparisonTable({
showBaronIdColumn?: boolean; showBaronIdColumn?: boolean;
showManageColumn?: boolean; showManageColumn?: boolean;
actionLabel: string; actionLabel: string;
updateActionLabel?: string; importActionLabel?: string;
importActionDisabled?: boolean;
actionDisabled: boolean; actionDisabled: boolean;
onCreateSelected: (ids: string[], initialPassword?: string) => unknown; onCreateSelected: (
onUpdateSelected?: (ids: string[]) => void; ids: string[],
initialPassword?: string,
initialPasswordUserIds?: string[],
) => unknown;
onImportSelected?: (worksmobileUserIds: string[]) => void;
onRunSelected?: (actionIds: string[], deleteIds: string[]) => void; onRunSelected?: (actionIds: string[], deleteIds: string[]) => void;
deleteActionLabel?: string; deleteActionLabel?: string;
deleteActionDisabled?: boolean; deleteActionDisabled?: boolean;
@@ -1066,6 +1156,7 @@ function ComparisonTable({
const [initialPassword, setInitialPassword] = React.useState(""); const [initialPassword, setInitialPassword] = React.useState("");
const [pendingInitialPasswordIds, setPendingInitialPasswordIds] = const [pendingInitialPasswordIds, setPendingInitialPasswordIds] =
React.useState<string[]>([]); React.useState<string[]>([]);
const [pendingActionIds, setPendingActionIds] = React.useState<string[]>([]);
const tableViewportRef = React.useRef<HTMLDivElement>(null); const tableViewportRef = React.useRef<HTMLDivElement>(null);
const selectableKeys = rows const selectableKeys = rows
.filter(canSelectWorksmobileRow) .filter(canSelectWorksmobileRow)
@@ -1076,7 +1167,7 @@ function ComparisonTable({
rows, rows,
selectedKeys, selectedKeys,
); );
const selectedUpdateUserIds = getWorksmobileSelectedUpdateUserIds( const selectedImportUserIds = getWorksmobileSelectedImportUserIds(
rows, rows,
selectedKeys, selectedKeys,
); );
@@ -1090,7 +1181,7 @@ function ComparisonTable({
selectedActionIds.length === 0 && selectedActionIds.length === 0 &&
selectedDeleteIds.length > 0 && selectedDeleteIds.length > 0 &&
canRunDeleteAction; canRunDeleteAction;
const canRunUserUpdateAction = Boolean(onUpdateSelected); const canRunUserImportAction = Boolean(onImportSelected);
const selectedActionLabel = shouldRunDeleteAction const selectedActionLabel = shouldRunDeleteAction
? deleteActionLabel ? deleteActionLabel
: actionLabel; : actionLabel;
@@ -1102,11 +1193,9 @@ function ComparisonTable({
? selectedActionIds.length === 0 && selectedDeleteIds.length === 0 ? selectedActionIds.length === 0 && selectedDeleteIds.length === 0
: shouldRunDeleteAction : shouldRunDeleteAction
? selectedDeleteIds.length === 0 || deleteActionDisabled ? selectedDeleteIds.length === 0 || deleteActionDisabled
: requireInitialPassword : selectedActionIds.length === 0) || actionDisabled;
? selectedCreateUserIds.length === 0 const importActionButtonDisabled =
: selectedActionIds.length === 0) || actionDisabled; selectedImportUserIds.length === 0 || importActionDisabled;
const updateActionDisabled =
selectedUpdateUserIds.length === 0 || actionDisabled;
const allSelectableSelected = const allSelectableSelected =
selectableKeys.length > 0 && selectableKeys.length > 0 &&
selectableKeys.every((key) => selectedKeys.includes(key)); selectableKeys.every((key) => selectedKeys.includes(key));
@@ -1228,7 +1317,8 @@ function ComparisonTable({
onDeleteSelected(selectedDeleteIds); onDeleteSelected(selectedDeleteIds);
return; return;
} }
if (requireInitialPassword) { if (requireInitialPassword && selectedCreateUserIds.length > 0) {
setPendingActionIds(selectedActionIds);
setPendingInitialPasswordIds(selectedCreateUserIds); setPendingInitialPasswordIds(selectedCreateUserIds);
setInitialPassword(""); setInitialPassword("");
setInitialPasswordOpen(true); setInitialPasswordOpen(true);
@@ -1237,11 +1327,11 @@ function ComparisonTable({
onCreateSelected(selectedActionIds); onCreateSelected(selectedActionIds);
}; };
const runUpdateAction = () => { const runImportAction = () => {
if (!onUpdateSelected || selectedUpdateUserIds.length === 0) { if (!onImportSelected || selectedImportUserIds.length === 0) {
return; return;
} }
onUpdateSelected(selectedUpdateUserIds); onImportSelected(selectedImportUserIds);
}; };
const confirmInitialPassword = async () => { const confirmInitialPassword = async () => {
@@ -1251,13 +1341,18 @@ function ComparisonTable({
return; return;
} }
try { try {
await onCreateSelected(pendingInitialPasswordIds, password); await onCreateSelected(
pendingActionIds,
password,
pendingInitialPasswordIds,
);
} catch { } catch {
return; return;
} }
setInitialPasswordOpen(false); setInitialPasswordOpen(false);
setInitialPassword(""); setInitialPassword("");
setPendingInitialPasswordIds([]); setPendingInitialPasswordIds([]);
setPendingActionIds([]);
}; };
return ( return (
@@ -1300,27 +1395,28 @@ function ComparisonTable({
} }
/> />
{accountStatusFilter && onAccountStatusFilterChange ? ( {accountStatusFilter && onAccountStatusFilterChange ? (
<div <Select
className="flex flex-wrap items-center gap-2" value={accountStatusFilter}
role="tablist" onValueChange={(value) =>
aria-label="WORKS 계정 상태" onAccountStatusFilterChange(
value as WorksmobileAccountStatusFilter,
)
}
> >
{worksmobileAccountStatusFilterOptions.map((option) => ( <SelectTrigger
<Button className="h-9 w-[148px]"
key={option.value} aria-label="WORKS 계정 상태"
type="button" >
role="tab" <SelectValue />
size="sm" </SelectTrigger>
variant={ <SelectContent>
accountStatusFilter === option.value ? "default" : "outline" {worksmobileAccountStatusFilterOptions.map((option) => (
} <SelectItem key={option.value} value={option.value}>
aria-selected={accountStatusFilter === option.value} {option.label}
onClick={() => onAccountStatusFilterChange(option.value)} </SelectItem>
> ))}
{option.label} </SelectContent>
</Button> </Select>
))}
</div>
) : null} ) : null}
</div> </div>
<div className="flex shrink-0 flex-wrap items-center justify-start gap-2 xl:justify-end"> <div className="flex shrink-0 flex-wrap items-center justify-start gap-2 xl:justify-end">
@@ -1380,15 +1476,15 @@ function ComparisonTable({
> >
{selectedActionLabel} {selectedActionLabel}
</Button> </Button>
{canRunUserUpdateAction && ( {canRunUserImportAction && (
<Button <Button
type="button" type="button"
size="sm" size="sm"
variant="outline" variant="outline"
onClick={runUpdateAction} onClick={runImportAction}
disabled={updateActionDisabled} disabled={importActionButtonDisabled}
> >
{updateActionLabel || "선택 구성원 업데이트 적용"} {importActionLabel || "Works정보 가져오기"}
</Button> </Button>
)} )}
<Dialog <Dialog
@@ -1398,6 +1494,7 @@ function ComparisonTable({
if (!open) { if (!open) {
setInitialPassword(""); setInitialPassword("");
setPendingInitialPasswordIds([]); setPendingInitialPasswordIds([]);
setPendingActionIds([]);
} }
}} }}
> >
@@ -1437,7 +1534,7 @@ function ComparisonTable({
onClick={confirmInitialPassword} onClick={confirmInitialPassword}
disabled={actionDisabled} disabled={actionDisabled}
> >
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@@ -47,7 +47,7 @@ export function getDefaultWorksmobileComparisonColumns(): WorksmobileComparisonC
baron: true, baron: true,
baronOrg: true, baronOrg: true,
worksmobileId: false, worksmobileId: false,
externalKey: false, externalKey: true,
worksmobileDomain: true, worksmobileDomain: true,
worksmobile: true, worksmobile: true,
worksmobileOrg: true, worksmobileOrg: true,
@@ -212,6 +212,24 @@ export function getWorksmobileSelectedUpdateUserIds(
.filter((id): id is string => Boolean(id)); .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( export function formatWorksmobileSelectionFailureDescription(
successCount: number, successCount: number,
failures: string[], failures: string[],

View File

@@ -1005,6 +1005,23 @@ export type WorksmobileComparison = {
groups: WorksmobileComparisonItem[]; 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( export async function fetchUsers(
limit = 50, limit = 50,
offset = 0, offset = 0,
@@ -1194,6 +1211,17 @@ export async function enqueueWorksmobileUserSync(
return data; 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( export async function resetWorksmobileUserPassword(
tenantId: string, tenantId: string,
userId: string, userId: string,

View File

@@ -356,12 +356,10 @@ test.describe("Worksmobile tenant management", () => {
.getByRole("row", { name: /김누락/ }) .getByRole("row", { name: /김누락/ })
.getByRole("checkbox") .getByRole("checkbox")
.check(); .check();
await page await page.getByRole("button", { name: "Works에 정보 넣기" }).click();
.getByRole("button", { name: "선택 구성원 WORKS에 생성" })
.click();
await expect(page.getByText("WORKS 초기 비밀번호")).toBeVisible(); await expect(page.getByText("WORKS 초기 비밀번호")).toBeVisible();
await page.getByLabel("초기 비밀번호").fill("InitPass123!"); await page.getByLabel("초기 비밀번호").fill("InitPass123!");
await page.getByRole("button", { name: "생성 작업 등록" }).click(); await page.getByRole("button", { name: "작업 등록" }).click();
await expect await expect
.poll(() => syncRequests) .poll(() => syncRequests)
.toEqual([ .toEqual([
@@ -591,11 +589,11 @@ test.describe("Worksmobile tenant management", () => {
.check(); .check();
await userComparisonSection await userComparisonSection
.getByRole("button", { name: "선택 구성원 WORKS에 생성" }) .getByRole("button", { name: "Works에 정보 넣기" })
.click(); .click();
await expect(page.getByText("WORKS 초기 비밀번호")).toBeVisible(); await expect(page.getByText("WORKS 초기 비밀번호")).toBeVisible();
await page.getByLabel("초기 비밀번호").fill("InitPass123!"); await page.getByLabel("초기 비밀번호").fill("InitPass123!");
await page.getByRole("button", { name: "생성 작업 등록" }).click(); await page.getByRole("button", { name: "작업 등록" }).click();
await expect await expect
.poll(() => syncRequests) .poll(() => syncRequests)
.toEqual([ .toEqual([
@@ -603,6 +601,12 @@ test.describe("Worksmobile tenant management", () => {
userId: "user-missing", userId: "user-missing",
body: expect.objectContaining({ initialPassword: "InitPass123!" }), body: expect.objectContaining({ initialPassword: "InitPass123!" }),
}, },
{
userId: "user-update",
body: expect.not.objectContaining({
initialPassword: expect.anything(),
}),
},
]); ]);
const updateRowCheckbox = userComparisonSection const updateRowCheckbox = userComparisonSection
@@ -614,8 +618,9 @@ test.describe("Worksmobile tenant management", () => {
.getByRole("checkbox") .getByRole("checkbox")
.check(); .check();
await userComparisonSection await userComparisonSection
.getByRole("button", { name: "선택 구성원 업데이트 적용" }) .getByRole("button", { name: "Works에 정보 넣기" })
.click(); .click();
await expect(page.getByText("WORKS 초기 비밀번호")).not.toBeVisible();
await expect await expect
.poll(() => syncRequests) .poll(() => syncRequests)
.toEqual([ .toEqual([
@@ -629,6 +634,12 @@ test.describe("Worksmobile tenant management", () => {
initialPassword: expect.anything(), 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("row", { name: /실패 사용자/ })
.getByRole("checkbox") .getByRole("checkbox")
.check(); .check();
await page await page.getByRole("button", { name: "Works에 정보 넣기" }).click();
.getByRole("button", { name: "선택 구성원 WORKS에 생성" })
.click();
await page.getByLabel("초기 비밀번호").fill("InitPass123!"); 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(page.getByText("WORKS 초기 비밀번호")).toBeVisible();
await page.getByLabel("초기 비밀번호").fill("InitPass123!"); 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(page.getByText("WORKS 생성 작업 등록 실패")).toBeVisible();
await expect( await expect(
@@ -917,6 +926,90 @@ test.describe("Worksmobile tenant management", () => {
"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Origin": "*",
"Access-Control-Expose-Headers": "Content-Disposition", "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) => { await page.route("**/api/v1/**", async (route) => {
const url = new URL(route.request().url()); const url = new URL(route.request().url());
@@ -948,65 +1041,7 @@ test.describe("Worksmobile tenant management", () => {
tokenConfigured: true, tokenConfigured: true,
adminTenantId: "works-tenant-1", adminTenantId: "works-tenant-1",
}, },
recentJobs: [ recentJobs: buildRecentJobs(),
{
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",
},
},
},
],
}, },
headers, headers,
}); });
@@ -1068,6 +1103,20 @@ test.describe("Worksmobile tenant management", () => {
return route.fulfill({ json: { id: "job-org-sync" }, headers }); 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 ( if (
url.pathname.endsWith( url.pathname.endsWith(
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/users/user-1/sync", "/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.getByPlaceholder("orgUnit tenant UUID").fill("org-1");
await page.getByRole("button", { name: "조직 Sync" }).click(); await page.getByRole("button", { name: "조직 Sync" }).click();
await expect.poll(() => requests).toContain("org-sync"); 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.getByRole("tab", { name: "사용자" }).click();
await page.getByPlaceholder("Kratos user UUID").fill("user-1"); 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 expect.poll(() => requests).toContain("user-sync");
await page.getByRole("tab", { name: "이력" }).click(); 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( await expect(page.getByRole("row", { name: /변경 사용자/ })).toContainText(
"changed-user@example.com", "changed-user@example.com",
); );
@@ -1136,6 +1192,9 @@ test.describe("Worksmobile tenant management", () => {
.first(), .first(),
).toBeVisible(); ).toBeVisible();
const failedJobRow = page.getByRole("row", { name: /변경 사용자/ }); const failedJobRow = page.getByRole("row", { name: /변경 사용자/ });
await expect(failedJobRow).toContainText(
"worksmobile api failed status=400 body=invalid org",
);
await failedJobRow.getByText("payload").click(); await failedJobRow.getByText("payload").click();
await expect( await expect(
failedJobRow.getByText('"loginEmail": "changed-user@example.com"'), failedJobRow.getByText('"loginEmail": "changed-user@example.com"'),

9
backend/.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
.env
.env.*
/.codex
/reports
/tmp
/logs
/server
/main
*.log

View File

@@ -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 WORKDIR /app
# Install git for go mod download if needed
RUN apk add --no-cache git RUN apk add --no-cache git
# Pre-copy go.mod/sum to cache dependencies
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
# Copy source FROM base AS dev
COPY . . COPY . .
# Build for production (optional, can just run go run for dev) RUN --mount=type=cache,target=/root/.cache/go-build \
RUN go build -o main ./cmd/server CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w" -o /usr/local/bin/baron-backend-dev ./cmd/server
EXPOSE 3000 EXPOSE 3000
# Default command (can be overridden by compose) CMD ["/usr/local/bin/baron-backend-dev"]
CMD ["./main"]
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"]

View File

@@ -6,6 +6,7 @@ import (
"baron-sso-backend/internal/service" "baron-sso-backend/internal/service"
"context" "context"
"encoding/csv" "encoding/csv"
"encoding/json"
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
@@ -59,6 +60,9 @@ type worksmobileSyncConfig struct {
ComparisonOutput string ComparisonOutput string
AlignBaronFromWorksOutput string AlignBaronFromWorksOutput string
AlignBaronFromWorksExclude string AlignBaronFromWorksExclude string
ImportFromWorksEmails string
PatchWorksUserNameEmail string
PatchWorksUserName string
InspectOutput string InspectOutput string
CredentialBatchID string CredentialBatchID string
Process bool Process bool
@@ -202,6 +206,28 @@ func runWorksmobileSync(args []string) error {
return err 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 { if config.Process {
return processWorksmobileOutbox(ctx, db, outboxRepo, config) 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.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.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.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.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.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") 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 { if err := fs.Parse(args); err != nil {
return config, err 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 { 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, or --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 == "" { if config.ResetPendingUsersPassword != "" && config.ResetPendingUsersResultOutput == "" {
return config, fmt.Errorf("--reset-pending-users-result-output is required with --reset-pending-users-password") 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 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) { func enqueueWorksmobileOrgUnits(ctx context.Context, db *gorm.DB, syncService service.WorksmobileAdminService, rootID string) (int, int, int, error) {
tenantIDs, err := activeTenantSubtreeIDs(ctx, db, rootID) tenantIDs, err := activeTenantSubtreeIDs(ctx, db, rootID)
if err != nil { if err != nil {

View 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])
}

View File

@@ -329,6 +329,7 @@ func main() {
configureWorksmobileClientFromEnv(worksmobileClient) configureWorksmobileClientFromEnv(worksmobileClient)
worksmobileService := service.NewWorksmobileSyncService(tenantService, userRepo, worksmobileOutboxRepo, worksmobileClient) worksmobileService := service.NewWorksmobileSyncService(tenantService, userRepo, worksmobileOutboxRepo, worksmobileClient)
worksmobileService.SetIdentityMirror(redisService) worksmobileService.SetIdentityMirror(redisService)
worksmobileService.SetIdentityServices(service.NewIdentityWriteService(kratosAdminService, redisService), kratosAdminService)
worksmobileRelayClient := *worksmobileClient worksmobileRelayClient := *worksmobileClient
worksmobileRelayClient.RateLimiter = service.NewWorksmobileAPIRateLimiter(240, time.Minute) worksmobileRelayClient.RateLimiter = service.NewWorksmobileAPIRateLimiter(240, time.Minute)
worksmobileRelayWorker := service.NewWorksmobileRelayWorker(worksmobileOutboxRepo, &worksmobileRelayClient) worksmobileRelayWorker := service.NewWorksmobileRelayWorker(worksmobileOutboxRepo, &worksmobileRelayClient)
@@ -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/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/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/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/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.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) admin.Delete("/tenants/:tenantId/worksmobile/jobs/pending", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DeletePendingJobs)

View File

@@ -89,6 +89,26 @@ func (h *WorksmobileHandler) SyncUser(c *fiber.Ctx) error {
return c.Status(fiber.StatusAccepted).JSON(job) 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 { func (h *WorksmobileHandler) ResetUserPassword(c *fiber.Ctx) error {
userID := strings.TrimSpace(c.Params("userId")) userID := strings.TrimSpace(c.Params("userId"))
credentialBatchID, err := parseWorksmobileCredentialBatchID(c) credentialBatchID, err := parseWorksmobileCredentialBatchID(c)

View File

@@ -230,6 +230,10 @@ func (f *fakeWorksmobileAdminService) GetComparison(ctx context.Context, tenantI
return service.WorksmobileComparison{}, nil 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) { func (f *fakeWorksmobileAdminService) EnqueueBackfillDryRun(ctx context.Context, tenantID string) (service.WorksmobileBackfillDryRun, error) {
return service.WorksmobileBackfillDryRun{}, nil return service.WorksmobileBackfillDryRun{}, nil
} }

View File

@@ -12,6 +12,7 @@ import (
type WorksmobileOutboxRepository interface { type WorksmobileOutboxRepository interface {
Create(ctx context.Context, item *domain.WorksmobileOutbox) error Create(ctx context.Context, item *domain.WorksmobileOutbox) error
ListRecent(ctx context.Context, limit int) ([]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) ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error)
UpdatePayload(ctx context.Context, id string, payload domain.JSONMap) error UpdatePayload(ctx context.Context, id string, payload domain.JSONMap) error
DeletePendingByTenantRoot(ctx context.Context, tenantRootID string) (int64, 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 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) { func (r *worksmobileOutboxRepository) ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error) {
query := r.db.WithContext(ctx). query := r.db.WithContext(ctx).
Where("resource_type = ? AND payload ->> 'tenantRootId' = ? AND coalesce(payload ->> 'credentialBatchId', '') <> ?", domain.WorksmobileResourceUser, tenantRootID, "") Where("resource_type = ? AND payload ->> 'tenantRootId' = ? AND coalesce(payload ->> 'credentialBatchId', '') <> ?", domain.WorksmobileResourceUser, tenantRootID, "")

View File

@@ -69,6 +69,56 @@ func TestWorksmobileOutboxRepositoryDeletePendingByTenantRoot(t *testing.T) {
require.Equal(t, "00000000-0000-0000-0000-000000000104", remaining[2].ID) 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) { func TestWorksmobileOutboxRepositoryListReadyWaitsForPendingOrgUnitParent(t *testing.T) {
repo := NewWorksmobileOutboxRepository(testDB) repo := NewWorksmobileOutboxRepository(testDB)
ctx := context.Background() ctx := context.Background()

View File

@@ -1924,6 +1924,20 @@ func (f *fakeWorksmobileOutboxRepo) ListRecent(ctx context.Context, limit int) (
return f.recent, nil 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) { func (f *fakeWorksmobileOutboxRepo) ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error) {
rows := make([]domain.WorksmobileOutbox, 0) rows := make([]domain.WorksmobileOutbox, 0)
for _, row := range f.credentialBatchJobs { for _, row := range f.credentialBatchJobs {

View File

@@ -46,7 +46,8 @@ type WorksmobileUserPayload struct {
} }
type WorksmobileUserName struct { type WorksmobileUserName struct {
LastName string `json:"lastName,omitempty"` LastName string `json:"lastName,omitempty"`
FirstName string `json:"firstName,omitempty"`
} }
type WorksmobilePasswordConfig struct { type WorksmobilePasswordConfig struct {
@@ -61,6 +62,26 @@ func (c WorksmobilePasswordConfig) IsZero() bool {
c.ChangePasswordAtNextLogin == nil 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) { func (p WorksmobileUserPayload) MarshalJSON() ([]byte, error) {
type payloadJSON struct { type payloadJSON struct {
DomainID int64 `json:"domainId"` DomainID int64 `json:"domainId"`
@@ -299,7 +320,7 @@ func buildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain
DomainID: domainID, DomainID: domainID,
Email: strings.TrimSpace(user.Email), Email: strings.TrimSpace(user.Email),
UserExternalKey: user.ID, UserExternalKey: user.ID,
UserName: WorksmobileUserName{LastName: strings.TrimSpace(user.Name)}, UserName: worksmobileUserNameFromDisplayName(user.Name),
CellPhone: domain.NormalizePhoneNumber(user.Phone), CellPhone: domain.NormalizePhoneNumber(user.Phone),
EmployeeNumber: employeeNumber, EmployeeNumber: employeeNumber,
Locale: "ko_KR", Locale: "ko_KR",

View File

@@ -34,6 +34,7 @@ type WorksmobileSyncer interface {
type WorksmobileAdminService interface { type WorksmobileAdminService interface {
GetTenantOverview(ctx context.Context, tenantID string) (WorksmobileTenantOverview, error) GetTenantOverview(ctx context.Context, tenantID string) (WorksmobileTenantOverview, error)
GetComparison(ctx context.Context, tenantID string, includeMatched bool) (WorksmobileComparison, 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) EnqueueBackfillDryRun(ctx context.Context, tenantID string) (WorksmobileBackfillDryRun, error)
EnqueueOrgUnitSync(ctx context.Context, tenantID, orgUnitID string) (*domain.WorksmobileOutbox, error) EnqueueOrgUnitSync(ctx context.Context, tenantID, orgUnitID string) (*domain.WorksmobileOutbox, error)
EnqueueOrgUnitDelete(ctx context.Context, tenantID, worksmobileOrgUnitID 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"` 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 { type WorksmobileInitialPasswordCredential struct {
Email string `json:"email"` Email string `json:"email"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
@@ -178,6 +200,8 @@ type worksmobileSyncService struct {
outboxRepo repository.WorksmobileOutboxRepository outboxRepo repository.WorksmobileOutboxRepository
client WorksmobileDirectoryClient client WorksmobileDirectoryClient
identityMirror WorksmobileIdentityMirror identityMirror WorksmobileIdentityMirror
identityWriter IdentityWriteService
kratos KratosAdminService
} }
type WorksmobileIdentityMirror interface { type WorksmobileIdentityMirror interface {
@@ -201,18 +225,30 @@ func (s *worksmobileSyncService) SetIdentityMirror(source WorksmobileIdentityMir
s.identityMirror = source 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) { 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 { if err != nil {
return WorksmobileTenantOverview{}, err 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) jobs = redactWorksmobileOutboxPayloads(jobs)
return WorksmobileTenantOverview{ return WorksmobileTenantOverview{
Tenant: *tenant, Tenant: *root,
Config: WorksmobileConfigSummary{ Config: WorksmobileConfigSummary{
Enabled: WorksmobileEnabled(tenant.Config), Enabled: WorksmobileEnabled(root.Config),
DomainMappings: WorksmobileDomainMappings(tenant.Config), DomainMappings: WorksmobileDomainMappings(root.Config),
TokenConfigured: worksmobileDirectoryAuthConfigured(), TokenConfigured: worksmobileDirectoryAuthConfigured(),
AdminTenantID: strings.TrimSpace(os.Getenv("WORKS_ADMIN_TENANT_ID")), 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")) != "") 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 { func WorksmobileExcluded(config domain.JSONMap) bool {
rawValue, ok := config[worksmobileExcludedConfigKey] rawValue, ok := config[worksmobileExcludedConfigKey]
if !ok { if !ok {
@@ -403,6 +448,273 @@ func (s *worksmobileSyncService) GetComparison(ctx context.Context, tenantID str
}, nil }, 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) { func (s *worksmobileSyncService) comparisonUsers(ctx context.Context, tenantIDs []string, tenantByID map[string]domain.Tenant) ([]domain.User, error) {
if s.identityMirror != nil { if s.identityMirror != nil {
status, err := s.identityMirror.GetIdentityCacheStatus(ctx) status, err := s.identityMirror.GetIdentityCacheStatus(ctx)
@@ -586,8 +898,9 @@ func (s *worksmobileSyncService) EnqueueBackfillDryRun(ctx context.Context, tena
Action: domain.WorksmobileActionDryRun, Action: domain.WorksmobileActionDryRun,
DedupeKey: "backfill:dry-run:" + root.ID, DedupeKey: "backfill:dry-run:" + root.ID,
Payload: domain.JSONMap{ Payload: domain.JSONMap{
"tenantIds": orgUnitTenantIDs, "tenantRootId": root.ID,
"userCount": len(users), "tenantIds": orgUnitTenantIDs,
"userCount": len(users),
}, },
}) })
return WorksmobileBackfillDryRun{OrgUnitCount: len(orgUnitTenantIDs), UserCount: len(users)}, nil 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) tenantRoot, ok, err := s.rootForTenant(ctx, *tenant)
if err != nil { if err != nil {
if recordErr := s.recordRejectedOrgUnitSync(ctx, root.ID, *tenant, err); recordErr != nil {
return nil, errors.Join(err, recordErr)
}
return nil, err return nil, err
} }
if !ok || tenantRoot.ID != root.ID { 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) scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
if err != nil { if err != nil {
@@ -615,10 +935,18 @@ func (s *worksmobileSyncService) EnqueueOrgUnitSync(ctx context.Context, tenantI
} }
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...)) tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
if _, ok := tenantByID[tenant.ID]; !ok { 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) { 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) return s.enqueueOrgUnitUpsert(ctx, root, *tenant, scopeTenants)
} }
@@ -632,6 +960,9 @@ func (s *worksmobileSyncService) enqueueOrgUnitUpsert(ctx context.Context, root
0, 0,
) )
if err != nil { if err != nil {
if recordErr := s.recordRejectedOrgUnitSync(ctx, root.ID, tenant, err); recordErr != nil {
return nil, errors.Join(err, recordErr)
}
return nil, err return nil, err
} }
payload = normalizeWorksmobileOrgUnitParent(payload, tenant, tenantByID, root.ID) payload = normalizeWorksmobileOrgUnitParent(payload, tenant, tenantByID, root.ID)
@@ -641,6 +972,7 @@ func (s *worksmobileSyncService) enqueueOrgUnitUpsert(ctx context.Context, root
Action: domain.WorksmobileActionUpsert, Action: domain.WorksmobileActionUpsert,
DedupeKey: "orgunit:upsert:" + tenant.ID, DedupeKey: "orgunit:upsert:" + tenant.ID,
Payload: domain.JSONMap{ Payload: domain.JSONMap{
"tenantRootId": root.ID,
"request": payload, "request": payload,
"matchLocalPart": tenant.Slug, "matchLocalPart": tenant.Slug,
}, },
@@ -651,6 +983,36 @@ func (s *worksmobileSyncService) enqueueOrgUnitUpsert(ctx context.Context, root
return item, nil 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) { func (s *worksmobileSyncService) EnqueueOrgUnitDelete(ctx context.Context, tenantID, worksmobileOrgUnitID string) (*domain.WorksmobileOutbox, error) {
root, err := s.hanmacRoot(ctx, tenantID) root, err := s.hanmacRoot(ctx, tenantID)
if err != nil { if err != nil {
@@ -692,6 +1054,7 @@ func (s *worksmobileSyncService) EnqueueOrgUnitDelete(ctx context.Context, tenan
Action: domain.WorksmobileActionDelete, Action: domain.WorksmobileActionDelete,
DedupeKey: "orgunit:delete:works:" + worksmobileOrgUnitID, DedupeKey: "orgunit:delete:works:" + worksmobileOrgUnitID,
Payload: domain.JSONMap{ Payload: domain.JSONMap{
"tenantRootId": root.ID,
"worksmobileId": worksmobileOrgUnitID, "worksmobileId": worksmobileOrgUnitID,
"externalKey": target.ExternalID, "externalKey": target.ExternalID,
"domainId": target.DomainID, "domainId": target.DomainID,
@@ -756,7 +1119,7 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
return nil, err return nil, err
} }
action := WorksmobileUserStatusAction(user.Status) action := WorksmobileUserStatusAction(user.Status)
if action == domain.WorksmobileActionUpsert { if action == domain.WorksmobileActionUpsert && strings.TrimSpace(initialPassword) != "" {
payload.PasswordConfig = worksmobileAdminInitialPasswordConfig(initialPassword) payload.PasswordConfig = worksmobileAdminInitialPasswordConfig(initialPassword)
} }
item := &domain.WorksmobileOutbox{ item := &domain.WorksmobileOutbox{
@@ -768,7 +1131,7 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
} }
item.Payload["displayName"] = strings.TrimSpace(user.Name) item.Payload["displayName"] = strings.TrimSpace(user.Name)
item.Payload["primaryLeafOrgName"] = worksmobileUserPrimaryOrgName(*user, tenantByID) 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["credentialBatchId"] = batchID
item.Payload["credentialOperation"] = "worksmobile_user_sync" item.Payload["credentialOperation"] = "worksmobile_user_sync"
item.Payload["credentialBatchCreatedAt"] = time.Now().UTC().Format(time.RFC3339Nano) item.Payload["credentialBatchCreatedAt"] = time.Now().UTC().Format(time.RFC3339Nano)
@@ -783,7 +1146,7 @@ func (s *worksmobileSyncService) recordRejectedUserSync(ctx context.Context, roo
payload := WorksmobileUserPayload{ payload := WorksmobileUserPayload{
Email: strings.TrimSpace(user.Email), Email: strings.TrimSpace(user.Email),
UserExternalKey: user.ID, UserExternalKey: user.ID,
UserName: WorksmobileUserName{LastName: strings.TrimSpace(user.Name)}, UserName: worksmobileUserNameFromDisplayName(user.Name),
CellPhone: domain.NormalizePhoneNumber(user.Phone), CellPhone: domain.NormalizePhoneNumber(user.Phone),
EmployeeNumber: metadataEmployeeNumber(user.Metadata), EmployeeNumber: metadataEmployeeNumber(user.Metadata),
Locale: "ko_KR", Locale: "ko_KR",

View File

@@ -164,7 +164,7 @@ func TestWorksmobileSyncServiceEnqueuesUserCredentialBatchID(t *testing.T) {
require.True(t, *request.PasswordConfig.ChangePasswordAtNextLogin) require.True(t, *request.PasswordConfig.ChangePasswordAtNextLogin)
} }
func TestWorksmobileSyncServiceAutoGeneratesAdminInitialPassword(t *testing.T) { func TestWorksmobileSyncServiceSkipsAdminInitialPasswordWhenEmpty(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001") t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "root-tenant" rootID := "root-tenant"
tenantID := "saman-tenant" tenantID := "saman-tenant"
@@ -201,13 +201,13 @@ func TestWorksmobileSyncServiceAutoGeneratesAdminInitialPassword(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, item) require.NotNil(t, item)
initialPassword := stringValue(outboxRepo.created[0].Payload["initialPassword"]) initialPassword := stringValue(outboxRepo.created[0].Payload["initialPassword"])
require.NotEmpty(t, initialPassword) require.Empty(t, initialPassword)
request, ok := outboxRepo.created[0].Payload["request"].(WorksmobileUserPayload) request, ok := outboxRepo.created[0].Payload["request"].(WorksmobileUserPayload)
require.True(t, ok) require.True(t, ok)
require.Equal(t, "ADMIN", request.PasswordConfig.PasswordCreationType) require.Empty(t, request.PasswordConfig.PasswordCreationType)
require.Equal(t, initialPassword, request.PasswordConfig.Password) require.Empty(t, request.PasswordConfig.Password)
require.NotNil(t, request.PasswordConfig.ChangePasswordAtNextLogin) require.Nil(t, request.PasswordConfig.ChangePasswordAtNextLogin)
require.True(t, *request.PasswordConfig.ChangePasswordAtNextLogin) require.Empty(t, stringValue(outboxRepo.created[0].Payload["credentialBatchId"]))
} }
func TestWorksmobileSyncServiceCreatesDistinctUserSyncHistoryJobs(t *testing.T) { func TestWorksmobileSyncServiceCreatesDistinctUserSyncHistoryJobs(t *testing.T) {
@@ -661,6 +661,7 @@ func TestWorksmobileSyncServiceOverviewKeepsSafeRecentJobChangeLogPayload(t *tes
Action: domain.WorksmobileActionUpsert, Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusProcessed, Status: domain.WorksmobileOutboxStatusProcessed,
Payload: domain.JSONMap{ Payload: domain.JSONMap{
"tenantRootId": root.ID,
"loginEmail": "changed@example.com", "loginEmail": "changed@example.com",
"displayName": "변경 사용자", "displayName": "변경 사용자",
"primaryLeafOrgName": "인재성장", "primaryLeafOrgName": "인재성장",
@@ -680,6 +681,7 @@ func TestWorksmobileSyncServiceOverviewKeepsSafeRecentJobChangeLogPayload(t *tes
Action: domain.WorksmobileActionUpsert, Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusProcessed, Status: domain.WorksmobileOutboxStatusProcessed,
Payload: domain.JSONMap{ Payload: domain.JSONMap{
"tenantRootId": root.ID,
"matchLocalPart": "people-growth", "matchLocalPart": "people-growth",
"request": WorksmobileOrgUnitPayload{ "request": WorksmobileOrgUnitPayload{
OrgUnitName: "인재성장", OrgUnitName: "인재성장",
@@ -725,6 +727,67 @@ func TestWorksmobileSyncServiceOverviewKeepsSafeRecentJobChangeLogPayload(t *tes
}, orgPayload["requestSummary"]) }, 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) { func TestWorksmobileSyncServiceEnqueueTenantUpsertReflectsChangedParentOrgUnit(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001") t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "root-tenant" rootID := "root-tenant"
@@ -1041,7 +1104,12 @@ func TestWorksmobileSyncServiceRejectsDomainCompanyOrgUnitSync(t *testing.T) {
require.Nil(t, item) require.Nil(t, item)
require.Error(t, err) require.Error(t, err)
require.Contains(t, err.Error(), "worksmobile orgunit tenant") 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) { func TestWorksmobileSyncServiceEnqueuesBarongroupChildCompanyOrgUnitSync(t *testing.T) {
@@ -2046,7 +2114,13 @@ func TestWorksmobileSyncServiceRejectsExcludedOrgUnitSync(t *testing.T) {
require.Nil(t, item) require.Nil(t, item)
require.ErrorContains(t, err, "excluded from Worksmobile sync") 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) { func TestWorksmobileSyncServiceSkipsExcludedTenantAndUserEventSync(t *testing.T) {

View File

@@ -3,6 +3,7 @@ services:
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
target: dev
container_name: baron_backend container_name: baron_backend
env_file: env_file:
- .env - .env
@@ -42,14 +43,14 @@ services:
- ./backend:/app - ./backend:/app
- ./config:/app/config:ro - ./config:/app/config:ro
- ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro - ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro
command: ["go", "run", "./cmd/server"] command: ["/usr/local/bin/baron-backend-dev"]
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"] test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 3 retries: 12
start_period: 10s start_period: 60s
adminfront: adminfront:
build: build:

View File

@@ -3,6 +3,7 @@ services:
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
target: dev
container_name: baron_backend container_name: baron_backend
env_file: env_file:
- .env - .env
@@ -42,14 +43,14 @@ services:
- ./backend:/app - ./backend:/app
- ./config:/app/config:ro - ./config:/app/config:ro
- ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro - ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro
command: ["go", "run", "./cmd/server"] command: ["/usr/local/bin/baron-backend-dev"]
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"] test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 3 retries: 12
start_period: 10s start_period: 60s
adminfront: adminfront:
build: build:

View File

@@ -26,7 +26,7 @@ services:
- baron_net - baron_net
- ory-net - ory-net
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"] test: ["CMD", "/app/healthcheck"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 10 retries: 10

View File

@@ -26,11 +26,11 @@ services:
depends_on: depends_on:
- infra_check - infra_check
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"] test: ["CMD", "/app/healthcheck"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 3 retries: 12
start_period: 10s start_period: 60s
networks: networks:
- baron_net - baron_net

View File

@@ -364,6 +364,7 @@ services:
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
target: dev
container_name: baron_backend container_name: baron_backend
restart: unless-stopped restart: unless-stopped
env_file: env_file:
@@ -412,13 +413,13 @@ services:
volumes: volumes:
- ./backend:/app - ./backend:/app
- ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro - ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro
command: ["go", "run", "./cmd/server"] command: ["/usr/local/bin/baron-backend-dev"]
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"] test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 3 retries: 12
start_period: 10s start_period: 60s
adminfront: adminfront:
build: build:

View File

@@ -38,8 +38,13 @@ if ! grep -Eq "^go ${TARGET_GO_VERSION}$" "$GO_MOD"; then
exit 1 exit 1
fi fi
if ! grep -Eq "^FROM golang:${TARGET_GO_VERSION}-alpine$" "$BACKEND_DOCKERFILE"; then if ! grep -Eq "^FROM golang:${TARGET_GO_VERSION}-alpine AS base$" "$BACKEND_DOCKERFILE"; then
echo "ERROR: backend Dockerfile must use golang:${TARGET_GO_VERSION}-alpine." >&2 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 exit 1
fi fi