forked from baron/baron-sso
feat: update worksmobile sync and restore planning
This commit is contained in:
@@ -106,7 +106,7 @@ ensure_frontend_dependencies() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
if [ "$WORKSPACE_DIR" = "/workspace/common" ]; then
|
if [ "$WORKSPACE_DIR" = "/workspace/common" ]; then
|
||||||
(cd /workspace/common && CI=true pnpm install --filter "${APP_WORKSPACE_FILTER}..." --no-frozen-lockfile --ignore-scripts)
|
(cd /workspace/common && CI=true pnpm install --filter "${APP_WORKSPACE_FILTER}..." --frozen-lockfile --ignore-scripts)
|
||||||
else
|
else
|
||||||
npm ci
|
npm ci
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import {
|
||||||
|
cleanup,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from "@testing-library/react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createI18nMock } from "../../test/i18nMock";
|
import { createI18nMock } from "../../test/i18nMock";
|
||||||
|
import * as adminApi from "../../lib/adminApi";
|
||||||
import { TenantWorksmobilePage } from "../tenants/routes/TenantWorksmobilePage";
|
import { TenantWorksmobilePage } from "../tenants/routes/TenantWorksmobilePage";
|
||||||
import TenantListPage from "../tenants/routes/TenantListPage";
|
import TenantListPage from "../tenants/routes/TenantListPage";
|
||||||
import UserCreatePage from "../users/UserCreatePage";
|
import UserCreatePage from "../users/UserCreatePage";
|
||||||
@@ -198,7 +205,17 @@ vi.mock("../../lib/adminApi", () => ({
|
|||||||
baronId: "user-2",
|
baronId: "user-2",
|
||||||
baronName: "New User",
|
baronName: "New User",
|
||||||
baronEmail: "new@example.com",
|
baronEmail: "new@example.com",
|
||||||
status: "baron_only",
|
worksmobileJobStatus: "failed",
|
||||||
|
worksmobileJobRetryCount: 2,
|
||||||
|
worksmobileLastError: "worksmobile api failed",
|
||||||
|
status: "missing_in_worksmobile",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resourceType: "USER",
|
||||||
|
baronId: "user-3",
|
||||||
|
baronName: "Next User",
|
||||||
|
baronEmail: "next@example.com",
|
||||||
|
status: "missing_in_worksmobile",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
groups: [
|
groups: [
|
||||||
@@ -213,12 +230,55 @@ vi.mock("../../lib/adminApi", () => ({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
})),
|
})),
|
||||||
|
fetchWorksmobileCredentialBatches: vi.fn(async () => [
|
||||||
|
{
|
||||||
|
batchId: "credential-batch-1",
|
||||||
|
operation: "worksmobile_user_sync",
|
||||||
|
userCount: 1,
|
||||||
|
processedCount: 1,
|
||||||
|
failedCount: 1,
|
||||||
|
hasPasswords: true,
|
||||||
|
failures: [
|
||||||
|
{
|
||||||
|
userId: "failed-user",
|
||||||
|
email: "failed-user@samaneng.com",
|
||||||
|
status: "failed",
|
||||||
|
retryCount: 2,
|
||||||
|
lastError: "worksmobile api failed",
|
||||||
|
updatedAt: "2026-06-01T04:05:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: "2026-06-01T04:00:00Z",
|
||||||
|
updatedAt: "2026-06-01T04:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
batchId: "credential-batch-pending",
|
||||||
|
operation: "worksmobile_user_sync",
|
||||||
|
userCount: 2,
|
||||||
|
pendingCount: 1,
|
||||||
|
processingCount: 1,
|
||||||
|
processedCount: 0,
|
||||||
|
failedCount: 0,
|
||||||
|
hasPasswords: true,
|
||||||
|
createdAt: "2026-06-01T04:10:00Z",
|
||||||
|
updatedAt: "2026-06-01T04:10:00Z",
|
||||||
|
},
|
||||||
|
]),
|
||||||
enqueueWorksmobileBackfillDryRun: vi.fn(async () => ({ id: "job-dry" })),
|
enqueueWorksmobileBackfillDryRun: vi.fn(async () => ({ id: "job-dry" })),
|
||||||
retryWorksmobileJob: vi.fn(async () => ({ id: "job-retry" })),
|
retryWorksmobileJob: vi.fn(async () => ({ id: "job-retry" })),
|
||||||
downloadWorksmobileInitialPasswordsCSV: vi.fn(async () => new Blob(["id"])),
|
downloadWorksmobileInitialPasswordsCSV: vi.fn(async () => ({
|
||||||
|
blob: new Blob(["id"]),
|
||||||
|
filename: "worksmobile_initial_passwords.csv",
|
||||||
|
})),
|
||||||
enqueueWorksmobileOrgUnitSync: vi.fn(async () => ({ id: "job-org" })),
|
enqueueWorksmobileOrgUnitSync: vi.fn(async () => ({ id: "job-org" })),
|
||||||
enqueueWorksmobileOrgUnitDelete: vi.fn(async () => ({ id: "job-delete" })),
|
enqueueWorksmobileOrgUnitDelete: vi.fn(async () => ({ id: "job-delete" })),
|
||||||
enqueueWorksmobileUserSync: vi.fn(async () => ({ id: "job-user" })),
|
enqueueWorksmobileUserSync: vi.fn(async () => ({ id: "job-user" })),
|
||||||
|
resetWorksmobileUserPassword: vi.fn(async () => ({ id: "job-reset" })),
|
||||||
|
deleteWorksmobileCredentialBatchPasswords: vi.fn(async () => ({
|
||||||
|
batchId: "credential-batch-1",
|
||||||
|
userCount: 1,
|
||||||
|
hasPasswords: false,
|
||||||
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function renderWithProviders(ui: React.ReactElement, entry = "/") {
|
function renderWithProviders(ui: React.ReactElement, entry = "/") {
|
||||||
@@ -292,6 +352,165 @@ describe("adminfront large page coverage smoke", () => {
|
|||||||
|
|
||||||
expect(await screen.findByText("Worksmobile 연동")).toBeInTheDocument();
|
expect(await screen.findByText("Worksmobile 연동")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Baron / Works 비교")).toBeInTheDocument();
|
expect(screen.getByText("Baron / Works 비교")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
await screen.findByText("최근 실패: worksmobile api failed"),
|
||||||
|
).toBeInTheDocument();
|
||||||
expect(screen.getByText("Backfill Dry-run")).toBeInTheDocument();
|
expect(screen.getByText("Backfill Dry-run")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "초기 비밀번호 CSV" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not automatically download the selected Worksmobile user credential batch after create enqueue", async () => {
|
||||||
|
vi.spyOn(window.URL, "createObjectURL").mockReturnValue("blob:test");
|
||||||
|
vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {});
|
||||||
|
renderWithProviders(
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/tenants/:tenantId/worksmobile"
|
||||||
|
element={<TenantWorksmobilePage />}
|
||||||
|
/>
|
||||||
|
</Routes>,
|
||||||
|
"/tenants/tenant-company/worksmobile",
|
||||||
|
);
|
||||||
|
|
||||||
|
await screen.findByText("New User");
|
||||||
|
fireEvent.click(screen.getByRole("checkbox", { name: "New User 선택" }));
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "선택 구성원 WORKS에 생성" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenCalledWith(
|
||||||
|
"tenant-company",
|
||||||
|
"user-2",
|
||||||
|
expect.any(String),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const credentialBatchId = vi.mocked(
|
||||||
|
adminApi.enqueueWorksmobileUserSync,
|
||||||
|
).mock.calls[0][2];
|
||||||
|
expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues selected Worksmobile user create enqueue after one row fails", async () => {
|
||||||
|
vi.mocked(adminApi.enqueueWorksmobileUserSync)
|
||||||
|
.mockRejectedValueOnce(new Error("sync failed"))
|
||||||
|
.mockResolvedValueOnce({ id: "job-user-3" } as never);
|
||||||
|
vi.spyOn(window.URL, "createObjectURL").mockReturnValue("blob:test");
|
||||||
|
vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {});
|
||||||
|
renderWithProviders(
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/tenants/:tenantId/worksmobile"
|
||||||
|
element={<TenantWorksmobilePage />}
|
||||||
|
/>
|
||||||
|
</Routes>,
|
||||||
|
"/tenants/tenant-company/worksmobile",
|
||||||
|
);
|
||||||
|
|
||||||
|
await screen.findByText("New User");
|
||||||
|
fireEvent.click(screen.getByRole("checkbox", { name: "New User 선택" }));
|
||||||
|
fireEvent.click(screen.getByRole("checkbox", { name: "Next User 선택" }));
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "선택 구성원 WORKS에 생성" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenCalledTimes(2),
|
||||||
|
);
|
||||||
|
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"tenant-company",
|
||||||
|
"user-2",
|
||||||
|
expect.any(String),
|
||||||
|
);
|
||||||
|
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"tenant-company",
|
||||||
|
"user-3",
|
||||||
|
expect.any(String),
|
||||||
|
);
|
||||||
|
expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("downloads or deletes Worksmobile credential batches from history", async () => {
|
||||||
|
vi.spyOn(window.URL, "createObjectURL").mockReturnValue("blob:test");
|
||||||
|
vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {});
|
||||||
|
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||||
|
renderWithProviders(
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/tenants/:tenantId/worksmobile"
|
||||||
|
element={<TenantWorksmobilePage />}
|
||||||
|
/>
|
||||||
|
</Routes>,
|
||||||
|
"/tenants/tenant-company/worksmobile",
|
||||||
|
);
|
||||||
|
|
||||||
|
await screen.findByText("credential-batch-1");
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: "credential-batch-pending 비밀번호 CSV 다운로드",
|
||||||
|
}),
|
||||||
|
).toBeDisabled();
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: "credential-batch-1 비밀번호 CSV 다운로드",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
adminApi.downloadWorksmobileInitialPasswordsCSV,
|
||||||
|
).toHaveBeenCalledWith("tenant-company", "credential-batch-1"),
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: "credential-batch-1 비밀번호 값 삭제",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
adminApi.deleteWorksmobileCredentialBatchPasswords,
|
||||||
|
).toHaveBeenCalledWith("tenant-company", "credential-batch-1"),
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: "credential-batch-1 실패 사유 보기",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(await screen.findByText("failed-user@samaneng.com")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("worksmobile api failed")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enqueues Worksmobile password reset as a credential batch", async () => {
|
||||||
|
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||||
|
renderWithProviders(
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/tenants/:tenantId/worksmobile"
|
||||||
|
element={<TenantWorksmobilePage />}
|
||||||
|
/>
|
||||||
|
</Routes>,
|
||||||
|
"/tenants/tenant-company/worksmobile",
|
||||||
|
);
|
||||||
|
|
||||||
|
await screen.findByText("Worksmobile 연동");
|
||||||
|
fireEvent.click(screen.getAllByRole("button", { name: "양쪽 다 있음" })[0]);
|
||||||
|
await screen.findAllByText("Engineer User");
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: "Engineer User 비밀번호 재설정",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(adminApi.resetWorksmobileUserPassword).toHaveBeenCalledWith(
|
||||||
|
"tenant-company",
|
||||||
|
"user-1",
|
||||||
|
expect.any(String),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
formatWorksmobilePersonName,
|
formatWorksmobilePersonName,
|
||||||
formatWorksmobileUpdateDetails,
|
formatWorksmobileUpdateDetails,
|
||||||
getDefaultGroupComparisonFilters,
|
getDefaultGroupComparisonFilters,
|
||||||
|
getDefaultUserComparisonFilters,
|
||||||
getDefaultWorksmobileComparisonColumns,
|
getDefaultWorksmobileComparisonColumns,
|
||||||
getWorksmobileComparisonStatusLabel,
|
getWorksmobileComparisonStatusLabel,
|
||||||
getWorksmobileRowSelectionKey,
|
getWorksmobileRowSelectionKey,
|
||||||
@@ -460,6 +461,17 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
|||||||
).toEqual([rows[0]]);
|
).toEqual([rows[0]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows update-needed user rows by default", () => {
|
||||||
|
const rows = [
|
||||||
|
{ resourceType: "USER", status: "needs_update", baronId: "user-1" },
|
||||||
|
{ resourceType: "USER", status: "matched", baronId: "user-2" },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
filterWorksmobileComparisonRows(rows, getDefaultUserComparisonFilters()),
|
||||||
|
).toEqual([rows[0]]);
|
||||||
|
});
|
||||||
|
|
||||||
it("formats update details for changed organization rows", () => {
|
it("formats update details for changed organization rows", () => {
|
||||||
expect(
|
expect(
|
||||||
formatWorksmobileUpdateDetails({
|
formatWorksmobileUpdateDetails({
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
Download,
|
Download,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Settings2,
|
Settings2,
|
||||||
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
@@ -38,15 +41,19 @@ import {
|
|||||||
} from "../../../components/ui/table";
|
} from "../../../components/ui/table";
|
||||||
import { toast } from "../../../components/ui/use-toast";
|
import { toast } from "../../../components/ui/use-toast";
|
||||||
import {
|
import {
|
||||||
|
deleteWorksmobileCredentialBatchPasswords,
|
||||||
downloadWorksmobileInitialPasswordsCSV,
|
downloadWorksmobileInitialPasswordsCSV,
|
||||||
enqueueWorksmobileBackfillDryRun,
|
enqueueWorksmobileBackfillDryRun,
|
||||||
enqueueWorksmobileOrgUnitDelete,
|
enqueueWorksmobileOrgUnitDelete,
|
||||||
enqueueWorksmobileOrgUnitSync,
|
enqueueWorksmobileOrgUnitSync,
|
||||||
enqueueWorksmobileUserSync,
|
enqueueWorksmobileUserSync,
|
||||||
fetchWorksmobileComparison,
|
fetchWorksmobileComparison,
|
||||||
|
fetchWorksmobileCredentialBatches,
|
||||||
fetchWorksmobileOverview,
|
fetchWorksmobileOverview,
|
||||||
|
resetWorksmobileUserPassword,
|
||||||
retryWorksmobileJob,
|
retryWorksmobileJob,
|
||||||
type WorksmobileComparisonItem,
|
type WorksmobileComparisonItem,
|
||||||
|
type WorksmobileCredentialBatch,
|
||||||
} from "../../../lib/adminApi";
|
} from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
import {
|
import {
|
||||||
@@ -61,11 +68,13 @@ import {
|
|||||||
formatWorksmobilePersonName,
|
formatWorksmobilePersonName,
|
||||||
formatWorksmobileUpdateDetails,
|
formatWorksmobileUpdateDetails,
|
||||||
getDefaultGroupComparisonFilters,
|
getDefaultGroupComparisonFilters,
|
||||||
|
getDefaultUserComparisonFilters,
|
||||||
getDefaultWorksmobileComparisonColumns,
|
getDefaultWorksmobileComparisonColumns,
|
||||||
getWorksmobileComparisonStatusLabel,
|
getWorksmobileComparisonStatusLabel,
|
||||||
getWorksmobileRowSelectionKey,
|
getWorksmobileRowSelectionKey,
|
||||||
getWorksmobileSelectedActionIds,
|
getWorksmobileSelectedActionIds,
|
||||||
getWorksmobileSelectedWorksOnlyOrgUnitIds,
|
getWorksmobileSelectedWorksOnlyOrgUnitIds,
|
||||||
|
isImmutableWorksmobileAccount,
|
||||||
summarizeWorksmobileComparison,
|
summarizeWorksmobileComparison,
|
||||||
type WorksmobileComparisonColumnKey,
|
type WorksmobileComparisonColumnKey,
|
||||||
type WorksmobileComparisonColumnVisibility,
|
type WorksmobileComparisonColumnVisibility,
|
||||||
@@ -73,6 +82,17 @@ import {
|
|||||||
type WorksmobileComparisonSummary,
|
type WorksmobileComparisonSummary,
|
||||||
} from "./worksmobileComparison";
|
} from "./worksmobileComparison";
|
||||||
|
|
||||||
|
type InitialPasswordDownloadVariables = {
|
||||||
|
batchId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createWorksmobileCredentialBatchId() {
|
||||||
|
if (globalThis.crypto?.randomUUID) {
|
||||||
|
return globalThis.crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `worksmobile-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function TenantWorksmobilePage() {
|
export function TenantWorksmobilePage() {
|
||||||
const params = useParams<{ tenantId: string }>();
|
const params = useParams<{ tenantId: string }>();
|
||||||
const tenantId = params.tenantId ?? "";
|
const tenantId = params.tenantId ?? "";
|
||||||
@@ -80,7 +100,7 @@ export function TenantWorksmobilePage() {
|
|||||||
const [userId, setUserId] = React.useState("");
|
const [userId, setUserId] = React.useState("");
|
||||||
const [userFilters, setUserFilters] = React.useState<
|
const [userFilters, setUserFilters] = React.useState<
|
||||||
WorksmobileComparisonFilter[]
|
WorksmobileComparisonFilter[]
|
||||||
>(["baron_only", "works_only"]);
|
>(getDefaultUserComparisonFilters);
|
||||||
const [groupFilters, setGroupFilters] = React.useState<
|
const [groupFilters, setGroupFilters] = React.useState<
|
||||||
WorksmobileComparisonFilter[]
|
WorksmobileComparisonFilter[]
|
||||||
>(getDefaultGroupComparisonFilters);
|
>(getDefaultGroupComparisonFilters);
|
||||||
@@ -115,6 +135,12 @@ export function TenantWorksmobilePage() {
|
|||||||
enabled: tenantId.length > 0,
|
enabled: tenantId.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const credentialBatchesQuery = useQuery({
|
||||||
|
queryKey: ["worksmobile-credential-batches", tenantId],
|
||||||
|
queryFn: () => fetchWorksmobileCredentialBatches(tenantId),
|
||||||
|
enabled: tenantId.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
const dryRunMutation = useMutation({
|
const dryRunMutation = useMutation({
|
||||||
mutationFn: () => enqueueWorksmobileBackfillDryRun(tenantId),
|
mutationFn: () => enqueueWorksmobileBackfillDryRun(tenantId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -142,7 +168,8 @@ export function TenantWorksmobilePage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const initialPasswordDownloadMutation = useMutation({
|
const initialPasswordDownloadMutation = useMutation({
|
||||||
mutationFn: () => downloadWorksmobileInitialPasswordsCSV(tenantId),
|
mutationFn: (variables?: InitialPasswordDownloadVariables) =>
|
||||||
|
downloadWorksmobileInitialPasswordsCSV(tenantId, variables?.batchId),
|
||||||
onSuccess: ({ blob, filename }) => {
|
onSuccess: ({ blob, filename }) => {
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
@@ -160,6 +187,20 @@ export function TenantWorksmobilePage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deleteCredentialBatchPasswordsMutation = useMutation({
|
||||||
|
mutationFn: (batchId: string) =>
|
||||||
|
deleteWorksmobileCredentialBatchPasswords(tenantId, batchId),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("비밀번호 값을 삭제했습니다.");
|
||||||
|
credentialBatchesQuery.refetch();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("비밀번호 값 삭제 실패", {
|
||||||
|
description: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const orgUnitSyncMutation = useMutation({
|
const orgUnitSyncMutation = useMutation({
|
||||||
mutationFn: () => enqueueWorksmobileOrgUnitSync(tenantId, orgUnitId.trim()),
|
mutationFn: () => enqueueWorksmobileOrgUnitSync(tenantId, orgUnitId.trim()),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -194,26 +235,60 @@ export function TenantWorksmobilePage() {
|
|||||||
resourceKind: "users" | "groups";
|
resourceKind: "users" | "groups";
|
||||||
ids: string[];
|
ids: string[];
|
||||||
}) => {
|
}) => {
|
||||||
|
const credentialBatchId =
|
||||||
|
resourceKind === "users"
|
||||||
|
? createWorksmobileCredentialBatchId()
|
||||||
|
: undefined;
|
||||||
|
const failures: string[] = [];
|
||||||
|
let successCount = 0;
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
if (resourceKind === "users") {
|
try {
|
||||||
await enqueueWorksmobileUserSync(tenantId, id);
|
if (resourceKind === "users") {
|
||||||
} else {
|
await enqueueWorksmobileUserSync(tenantId, id, credentialBatchId);
|
||||||
await enqueueWorksmobileOrgUnitSync(tenantId, id);
|
} else {
|
||||||
|
await enqueueWorksmobileOrgUnitSync(tenantId, id);
|
||||||
|
}
|
||||||
|
successCount += 1;
|
||||||
|
} catch (error) {
|
||||||
|
failures.push(`${id}: ${getErrorMessage(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { resourceKind, count: ids.length };
|
|
||||||
|
if (successCount === 0 && failures.length > 0) {
|
||||||
|
throw new Error(failures.slice(0, 3).join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
resourceKind,
|
||||||
|
count: successCount,
|
||||||
|
failureCount: failures.length,
|
||||||
|
credentialBatchId:
|
||||||
|
resourceKind === "users" && successCount > 0
|
||||||
|
? credentialBatchId
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
onSuccess: ({ resourceKind, count }) => {
|
onSuccess: ({ resourceKind, count, failureCount }) => {
|
||||||
if (resourceKind === "users") {
|
if (resourceKind === "users") {
|
||||||
setSelectedUserRowKeys([]);
|
setSelectedUserRowKeys([]);
|
||||||
} else {
|
} else {
|
||||||
setSelectedGroupRowKeys([]);
|
setSelectedGroupRowKeys([]);
|
||||||
}
|
}
|
||||||
toast.success("WORKS 생성 작업을 등록했습니다.", {
|
if (failureCount > 0) {
|
||||||
description: `${count}건`,
|
toast.error("일부 WORKS 생성 작업 등록 실패", {
|
||||||
});
|
description: `성공 ${count}건, 실패 ${failureCount}건`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.success("WORKS 생성 작업을 등록했습니다.", {
|
||||||
|
description:
|
||||||
|
resourceKind === "users"
|
||||||
|
? `${count}건, 비밀번호 CSV는 배치 처리 완료 후 히스토리에서 다운로드할 수 있습니다.`
|
||||||
|
: `${count}건`,
|
||||||
|
});
|
||||||
|
}
|
||||||
overviewQuery.refetch();
|
overviewQuery.refetch();
|
||||||
comparisonQuery.refetch();
|
comparisonQuery.refetch();
|
||||||
|
credentialBatchesQuery.refetch();
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error("WORKS 생성 작업 등록 실패", {
|
toast.error("WORKS 생성 작업 등록 실패", {
|
||||||
@@ -222,6 +297,30 @@ export function TenantWorksmobilePage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const resetWorksmobilePasswordMutation = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
userId,
|
||||||
|
credentialBatchId,
|
||||||
|
}: {
|
||||||
|
userId: string;
|
||||||
|
credentialBatchId: string;
|
||||||
|
}) => resetWorksmobileUserPassword(tenantId, userId, credentialBatchId),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("WORKS 비밀번호 재설정 작업을 등록했습니다.", {
|
||||||
|
description:
|
||||||
|
"비밀번호 CSV는 배치 처리 완료 후 히스토리에서 다운로드할 수 있습니다.",
|
||||||
|
});
|
||||||
|
overviewQuery.refetch();
|
||||||
|
comparisonQuery.refetch();
|
||||||
|
credentialBatchesQuery.refetch();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("WORKS 비밀번호 재설정 등록 실패", {
|
||||||
|
description: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const syncSelectedOrgUnitsMutation = useMutation({
|
const syncSelectedOrgUnitsMutation = useMutation({
|
||||||
mutationFn: async ({
|
mutationFn: async ({
|
||||||
baronIds,
|
baronIds,
|
||||||
@@ -294,7 +393,10 @@ export function TenantWorksmobilePage() {
|
|||||||
createSelectedMutation.isPending &&
|
createSelectedMutation.isPending &&
|
||||||
createSelectedMutation.variables?.resourceKind === "users";
|
createSelectedMutation.variables?.resourceKind === "users";
|
||||||
const isSyncingGroups = syncSelectedOrgUnitsMutation.isPending;
|
const isSyncingGroups = syncSelectedOrgUnitsMutation.isPending;
|
||||||
const isRefreshing = overviewQuery.isFetching || comparisonQuery.isFetching;
|
const isRefreshing =
|
||||||
|
overviewQuery.isFetching ||
|
||||||
|
comparisonQuery.isFetching ||
|
||||||
|
credentialBatchesQuery.isFetching;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0 max-w-full space-y-6">
|
<div className="min-w-0 max-w-full space-y-6">
|
||||||
@@ -311,24 +413,13 @@ export function TenantWorksmobilePage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => initialPasswordDownloadMutation.mutate()}
|
|
||||||
disabled={initialPasswordDownloadMutation.isPending}
|
|
||||||
>
|
|
||||||
<Download size={16} />
|
|
||||||
{t(
|
|
||||||
"ui.admin.tenants.worksmobile.initial_password_csv",
|
|
||||||
"초기 비밀번호 CSV",
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
overviewQuery.refetch();
|
overviewQuery.refetch();
|
||||||
comparisonQuery.refetch();
|
comparisonQuery.refetch();
|
||||||
|
credentialBatchesQuery.refetch();
|
||||||
}}
|
}}
|
||||||
disabled={isRefreshing}
|
disabled={isRefreshing}
|
||||||
>
|
>
|
||||||
@@ -346,6 +437,29 @@ export function TenantWorksmobilePage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<CredentialBatchHistory
|
||||||
|
batches={credentialBatchesQuery.data ?? []}
|
||||||
|
loading={credentialBatchesQuery.isLoading}
|
||||||
|
downloadingBatchId={
|
||||||
|
initialPasswordDownloadMutation.isPending
|
||||||
|
? initialPasswordDownloadMutation.variables?.batchId
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
deletingBatchId={deleteCredentialBatchPasswordsMutation.variables}
|
||||||
|
onDownload={(batchId) =>
|
||||||
|
initialPasswordDownloadMutation.mutate({ batchId })
|
||||||
|
}
|
||||||
|
onDelete={(batchId) => {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
"이 배치의 실제 비밀번호 값을 삭제할까요? 생성 이력은 유지됩니다.",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
deleteCredentialBatchPasswordsMutation.mutate(batchId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<Card className="min-w-0 overflow-hidden">
|
<Card className="min-w-0 overflow-hidden">
|
||||||
<CardHeader className="flex flex-row items-center justify-between gap-3">
|
<CardHeader className="flex flex-row items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -408,6 +522,23 @@ export function TenantWorksmobilePage() {
|
|||||||
ids,
|
ids,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
resettingPasswordUserId={
|
||||||
|
resetWorksmobilePasswordMutation.isPending
|
||||||
|
? resetWorksmobilePasswordMutation.variables?.userId
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onResetUserPassword={(userId) => {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
"선택한 WORKS 계정의 비밀번호를 재설정할까요? 새 비밀번호는 배치 처리 완료 후 히스토리에서 CSV로 다운로드할 수 있습니다.",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
resetWorksmobilePasswordMutation.mutate({
|
||||||
|
userId,
|
||||||
|
credentialBatchId: createWorksmobileCredentialBatchId(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<ComparisonTable
|
<ComparisonTable
|
||||||
title={t(
|
title={t(
|
||||||
@@ -605,6 +736,216 @@ function getWorksmobileComparisonStatusVariant(status: string) {
|
|||||||
return "secondary";
|
return "secondary";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatCredentialBatchDate(value?: string) {
|
||||||
|
if (!value) return "-";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return "-";
|
||||||
|
return date.toLocaleString("ko-KR", {
|
||||||
|
timeZone: "Asia/Seoul",
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function CredentialBatchHistory({
|
||||||
|
batches,
|
||||||
|
loading,
|
||||||
|
downloadingBatchId,
|
||||||
|
deletingBatchId,
|
||||||
|
onDownload,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
batches: WorksmobileCredentialBatch[];
|
||||||
|
loading: boolean;
|
||||||
|
downloadingBatchId?: string;
|
||||||
|
deletingBatchId?: string;
|
||||||
|
onDownload: (batchId: string) => void;
|
||||||
|
onDelete: (batchId: string) => void;
|
||||||
|
}) {
|
||||||
|
const [expandedBatchIds, setExpandedBatchIds] = React.useState<string[]>([]);
|
||||||
|
const toggleExpanded = (batchId: string) => {
|
||||||
|
setExpandedBatchIds((current) =>
|
||||||
|
current.includes(batchId)
|
||||||
|
? current.filter((id) => id !== batchId)
|
||||||
|
: [...current, batchId],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="min-w-0 overflow-hidden">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">비밀번호 파일 히스토리</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
생성 배치별 CSV를 다시 받거나 전달 완료된 배치의 실제 비밀번호 값을
|
||||||
|
삭제합니다.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="w-full max-w-full overflow-x-auto rounded-md border">
|
||||||
|
<Table className="min-w-max">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="min-w-56 whitespace-nowrap">
|
||||||
|
배치
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-24 whitespace-nowrap">사용자</TableHead>
|
||||||
|
<TableHead className="min-w-36 whitespace-nowrap">
|
||||||
|
상태
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="min-w-44 whitespace-nowrap">
|
||||||
|
생성
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="min-w-44 whitespace-nowrap">
|
||||||
|
삭제
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-24 whitespace-nowrap">관리</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-muted-foreground">
|
||||||
|
불러오는 중...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{!loading && batches.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-muted-foreground">
|
||||||
|
생성된 비밀번호 배치가 없습니다.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{batches.map((batch) => {
|
||||||
|
const isComplete =
|
||||||
|
(batch.pendingCount ?? 0) === 0 &&
|
||||||
|
(batch.processingCount ?? 0) === 0;
|
||||||
|
const isExpanded = expandedBatchIds.includes(batch.batchId);
|
||||||
|
const failures = batch.failures ?? [];
|
||||||
|
return (
|
||||||
|
<React.Fragment key={batch.batchId}>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{failures.length > 0 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`${batch.batchId} 실패 사유 보기`}
|
||||||
|
onClick={() => toggleExpanded(batch.batchId)}
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
) : (
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<span>{batch.batchId}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono">
|
||||||
|
{batch.userCount}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap text-xs">
|
||||||
|
<span className="mr-2">
|
||||||
|
성공 {batch.processedCount ?? 0}
|
||||||
|
</span>
|
||||||
|
<span className="mr-2">
|
||||||
|
대기 {batch.pendingCount ?? 0}
|
||||||
|
</span>
|
||||||
|
<span className="mr-2">
|
||||||
|
처리 {batch.processingCount ?? 0}
|
||||||
|
</span>
|
||||||
|
<span>실패 {batch.failedCount ?? 0}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap text-xs">
|
||||||
|
{formatCredentialBatchDate(batch.createdAt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap text-xs">
|
||||||
|
{batch.hasPasswords
|
||||||
|
? "보관 중"
|
||||||
|
: formatCredentialBatchDate(batch.deletedAt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`${batch.batchId} 비밀번호 CSV 다운로드`}
|
||||||
|
disabled={
|
||||||
|
!batch.hasPasswords ||
|
||||||
|
!isComplete ||
|
||||||
|
downloadingBatchId === batch.batchId
|
||||||
|
}
|
||||||
|
onClick={() => onDownload(batch.batchId)}
|
||||||
|
>
|
||||||
|
<Download size={16} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`${batch.batchId} 비밀번호 값 삭제`}
|
||||||
|
disabled={
|
||||||
|
!batch.hasPasswords ||
|
||||||
|
deletingBatchId === batch.batchId
|
||||||
|
}
|
||||||
|
onClick={() => onDelete(batch.batchId)}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{isExpanded && failures.length > 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="bg-muted/30">
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
{failures.map((failure) => (
|
||||||
|
<div
|
||||||
|
key={`${failure.userId ?? failure.email}:${failure.lastError}`}
|
||||||
|
className="grid gap-1 md:grid-cols-[minmax(12rem,1fr)_5rem_minmax(18rem,2fr)]"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{failure.email ?? failure.userId ?? "-"}
|
||||||
|
</div>
|
||||||
|
{failure.userId && (
|
||||||
|
<div className="font-mono text-muted-foreground">
|
||||||
|
{failure.userId}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
{failure.status} / retry{" "}
|
||||||
|
{failure.retryCount ?? 0}
|
||||||
|
</div>
|
||||||
|
<div className="break-words">
|
||||||
|
{failure.lastError ?? "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ComparisonSummary({
|
function ComparisonSummary({
|
||||||
title,
|
title,
|
||||||
summary,
|
summary,
|
||||||
@@ -740,6 +1081,8 @@ function ComparisonTable({
|
|||||||
deleteActionLabel,
|
deleteActionLabel,
|
||||||
deleteActionDisabled = false,
|
deleteActionDisabled = false,
|
||||||
onDeleteSelected,
|
onDeleteSelected,
|
||||||
|
resettingPasswordUserId,
|
||||||
|
onResetUserPassword,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
rows: WorksmobileComparisonItem[];
|
rows: WorksmobileComparisonItem[];
|
||||||
@@ -768,6 +1111,8 @@ function ComparisonTable({
|
|||||||
deleteActionLabel?: string;
|
deleteActionLabel?: string;
|
||||||
deleteActionDisabled?: boolean;
|
deleteActionDisabled?: boolean;
|
||||||
onDeleteSelected?: (ids: string[]) => void;
|
onDeleteSelected?: (ids: string[]) => void;
|
||||||
|
resettingPasswordUserId?: string;
|
||||||
|
onResetUserPassword?: (userId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [columnSettingsOpen, setColumnSettingsOpen] = React.useState(false);
|
const [columnSettingsOpen, setColumnSettingsOpen] = React.useState(false);
|
||||||
const selectableKeys = rows
|
const selectableKeys = rows
|
||||||
@@ -841,6 +1186,15 @@ function ComparisonTable({
|
|||||||
window.open(url, "_blank", "noopener,noreferrer");
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const canResetPassword = (row: WorksmobileComparisonItem) =>
|
||||||
|
Boolean(
|
||||||
|
onResetUserPassword &&
|
||||||
|
row.resourceType === "USER" &&
|
||||||
|
row.baronId &&
|
||||||
|
row.status !== "missing_in_worksmobile" &&
|
||||||
|
!isImmutableWorksmobileAccount(row),
|
||||||
|
);
|
||||||
|
|
||||||
const toggleColumn = (key: WorksmobileComparisonColumnKey) => {
|
const toggleColumn = (key: WorksmobileComparisonColumnKey) => {
|
||||||
onVisibleColumnsChange((current) => ({
|
onVisibleColumnsChange((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@@ -1169,21 +1523,40 @@ function ComparisonTable({
|
|||||||
{showManageColumn && isColumnVisible("manage") && (
|
{showManageColumn && isColumnVisible("manage") && (
|
||||||
<TableCell className="whitespace-nowrap">
|
<TableCell className="whitespace-nowrap">
|
||||||
{row.resourceType === "USER" && (
|
{row.resourceType === "USER" && (
|
||||||
<Button
|
<div className="flex items-center gap-1">
|
||||||
type="button"
|
<Button
|
||||||
variant="ghost"
|
type="button"
|
||||||
size="sm"
|
variant="ghost"
|
||||||
aria-label={`${row.worksmobileName ?? row.baronName ?? row.worksmobileId ?? "WORKS user"} 비밀번호 관리`}
|
size="sm"
|
||||||
disabled={
|
aria-label={`${row.worksmobileName ?? row.baronName ?? row.worksmobileId ?? "WORKS user"} 비밀번호 관리`}
|
||||||
!canOpenWorksmobilePasswordManage(
|
disabled={
|
||||||
row,
|
!canOpenWorksmobilePasswordManage(
|
||||||
passwordManageTenantId,
|
row,
|
||||||
)
|
passwordManageTenantId,
|
||||||
}
|
)
|
||||||
onClick={() => openPasswordManage(row)}
|
}
|
||||||
>
|
onClick={() => openPasswordManage(row)}
|
||||||
<KeyRound size={16} />
|
>
|
||||||
</Button>
|
<KeyRound size={16} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label={`${row.worksmobileName ?? row.baronName ?? row.worksmobileId ?? "WORKS user"} 비밀번호 재설정`}
|
||||||
|
disabled={
|
||||||
|
!canResetPassword(row) ||
|
||||||
|
resettingPasswordUserId === row.baronId
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
if (row.baronId) {
|
||||||
|
onResetUserPassword?.(row.baronId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCcw size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -300,6 +300,9 @@ export function formatWorksmobileOrgDetails(row: WorksmobileComparisonItem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
|
export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
|
||||||
|
if (row.status === "missing_in_worksmobile" && row.worksmobileLastError) {
|
||||||
|
return [`최근 실패: ${row.worksmobileLastError}`];
|
||||||
|
}
|
||||||
if (row.status !== "needs_update") {
|
if (row.status !== "needs_update") {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -310,6 +313,21 @@ export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
|
|||||||
if (baronName && worksmobileName && baronName !== worksmobileName) {
|
if (baronName && worksmobileName && baronName !== worksmobileName) {
|
||||||
details.push(`이름: ${worksmobileName} -> ${baronName}`);
|
details.push(`이름: ${worksmobileName} -> ${baronName}`);
|
||||||
}
|
}
|
||||||
|
if (row.resourceType === "USER") {
|
||||||
|
const expectedExternalKey = row.baronId?.trim() ?? "";
|
||||||
|
const actualExternalKey = row.externalKey?.trim() ?? "";
|
||||||
|
if (expectedExternalKey && expectedExternalKey !== actualExternalKey) {
|
||||||
|
details.push(
|
||||||
|
`external_key: ${actualExternalKey || "없음"} -> ${expectedExternalKey}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const expectedEmail = row.baronEmail?.trim().toLowerCase() ?? "";
|
||||||
|
const actualEmail = row.worksmobileEmail?.trim().toLowerCase() ?? "";
|
||||||
|
if (expectedEmail && actualEmail && expectedEmail !== actualEmail) {
|
||||||
|
details.push(`이메일: ${actualEmail} -> ${expectedEmail}`);
|
||||||
|
}
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
|
||||||
const expectedParent =
|
const expectedParent =
|
||||||
row.baronParentWorksmobileName ??
|
row.baronParentWorksmobileName ??
|
||||||
@@ -395,6 +413,10 @@ export const comparisonFilterOptions: Array<{
|
|||||||
|
|
||||||
export const userFilterOptions = comparisonFilterOptions;
|
export const userFilterOptions = comparisonFilterOptions;
|
||||||
|
|
||||||
|
export function getDefaultUserComparisonFilters(): WorksmobileComparisonFilter[] {
|
||||||
|
return ["baron_only", "needs_update", "works_only"];
|
||||||
|
}
|
||||||
|
|
||||||
export function getDefaultGroupComparisonFilters(): WorksmobileComparisonFilter[] {
|
export function getDefaultGroupComparisonFilters(): WorksmobileComparisonFilter[] {
|
||||||
return ["baron_only", "needs_update", "works_only"];
|
return ["baron_only", "needs_update", "works_only"];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createI18nMock } from "../../test/i18nMock";
|
||||||
|
import UserDetailPage from "./UserDetailPage";
|
||||||
|
|
||||||
|
const updateUserMock = vi.hoisted(() => vi.fn());
|
||||||
|
const profileRoleMock = vi.hoisted(() => ({ role: "super_admin" }));
|
||||||
|
|
||||||
|
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||||
|
|
||||||
|
vi.mock("../../lib/adminApi", () => ({
|
||||||
|
deleteUser: vi.fn(),
|
||||||
|
fetchAllTenants: vi.fn(async () => ({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "tenant-hanmac",
|
||||||
|
type: "COMPANY",
|
||||||
|
name: "한맥기술",
|
||||||
|
slug: "hanmac",
|
||||||
|
description: "",
|
||||||
|
status: "active",
|
||||||
|
memberCount: 1,
|
||||||
|
createdAt: "2026-06-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-06-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
})),
|
||||||
|
fetchMe: vi.fn(async () => ({
|
||||||
|
id: "admin-user",
|
||||||
|
role: profileRoleMock.role,
|
||||||
|
name: "Admin",
|
||||||
|
email: "admin@example.com",
|
||||||
|
})),
|
||||||
|
fetchPasswordPolicy: vi.fn(async () => ({ minLength: 12 })),
|
||||||
|
fetchTenant: vi.fn(),
|
||||||
|
fetchUser: vi.fn(async () => ({
|
||||||
|
id: "user-1",
|
||||||
|
email: "user@example.com",
|
||||||
|
name: "사용자",
|
||||||
|
phone: "01012345678",
|
||||||
|
role: "user",
|
||||||
|
status: "active",
|
||||||
|
tenantSlug: "hanmac",
|
||||||
|
tenant: {
|
||||||
|
id: "tenant-hanmac",
|
||||||
|
type: "COMPANY",
|
||||||
|
name: "한맥기술",
|
||||||
|
slug: "hanmac",
|
||||||
|
description: "",
|
||||||
|
status: "active",
|
||||||
|
memberCount: 1,
|
||||||
|
createdAt: "2026-06-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-06-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
joinedTenants: [],
|
||||||
|
metadata: {
|
||||||
|
employee_id: {
|
||||||
|
"0": "h",
|
||||||
|
"1": "j",
|
||||||
|
"2": "k",
|
||||||
|
"3": "w",
|
||||||
|
"4": "o",
|
||||||
|
"5": "n",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createdAt: "2026-06-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-06-01T00:00:00Z",
|
||||||
|
})),
|
||||||
|
fetchUserRpHistory: vi.fn(async () => []),
|
||||||
|
updateUser: updateUserMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function renderUserDetailPage() {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter initialEntries={["/users/user-1"]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/users/:id" element={<UserDetailPage />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("UserDetailPage Worksmobile employee number", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
updateUserMock.mockReset();
|
||||||
|
updateUserMock.mockResolvedValue({});
|
||||||
|
profileRoleMock.role = "super_admin";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows and saves metadata employee_id from the user edit form", async () => {
|
||||||
|
renderUserDetailPage();
|
||||||
|
|
||||||
|
const employeeInput = await screen.findByLabelText("사번");
|
||||||
|
|
||||||
|
expect(employeeInput).toHaveValue("hjkwon");
|
||||||
|
|
||||||
|
fireEvent.change(employeeInput, { target: { value: "EMP001" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /저장하기/ }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(updateUserMock).toHaveBeenCalled());
|
||||||
|
expect(updateUserMock).toHaveBeenCalledWith(
|
||||||
|
"user-1",
|
||||||
|
expect.objectContaining({
|
||||||
|
metadata: expect.objectContaining({ employee_id: "EMP001" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows super admin to save a changed email", async () => {
|
||||||
|
renderUserDetailPage();
|
||||||
|
|
||||||
|
const emailInput = await screen.findByLabelText("이메일");
|
||||||
|
fireEvent.change(emailInput, { target: { value: "changed@example.com" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /저장하기/ }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(updateUserMock).toHaveBeenCalled());
|
||||||
|
expect(updateUserMock).toHaveBeenCalledWith(
|
||||||
|
"user-1",
|
||||||
|
expect.objectContaining({
|
||||||
|
email: "changed@example.com",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps email read-only for non-super admin", async () => {
|
||||||
|
profileRoleMock.role = "tenant_admin";
|
||||||
|
renderUserDetailPage();
|
||||||
|
|
||||||
|
const emailInput = await screen.findByLabelText("이메일");
|
||||||
|
|
||||||
|
expect(emailInput).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes metadata employee_id when the field is cleared", async () => {
|
||||||
|
renderUserDetailPage();
|
||||||
|
|
||||||
|
const employeeInput = await screen.findByLabelText("사번");
|
||||||
|
|
||||||
|
fireEvent.change(employeeInput, { target: { value: "" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /저장하기/ }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(updateUserMock).toHaveBeenCalled());
|
||||||
|
const payload = updateUserMock.mock.calls[0][1];
|
||||||
|
expect(payload.metadata).not.toHaveProperty("employee_id");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -95,6 +95,7 @@ import { resolvePersonalTenant } from "./utils/personalTenant";
|
|||||||
type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
|
type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
|
||||||
email: string;
|
email: string;
|
||||||
metadata: Record<string, unknown> & {
|
metadata: Record<string, unknown> & {
|
||||||
|
employee_id?: string;
|
||||||
sub_email?: string | string[];
|
sub_email?: string | string[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -130,6 +131,29 @@ function cleanMetadataValue(value: unknown): unknown {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeEmployeeIDMetadataValue(value: unknown) {
|
||||||
|
if (typeof value === "string" || typeof value === "number") {
|
||||||
|
return String(value).trim();
|
||||||
|
}
|
||||||
|
if (!isMetadataRecord(value)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const entries = Object.entries(value)
|
||||||
|
.map(([key, fieldValue]) => ({
|
||||||
|
index: Number(key),
|
||||||
|
value: typeof fieldValue === "string" ? fieldValue : "",
|
||||||
|
}))
|
||||||
|
.filter((entry) => Number.isInteger(entry.index) && entry.value.length > 0)
|
||||||
|
.sort((a, b) => a.index - b.index);
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
.map((entry) => entry.value)
|
||||||
|
.join("")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeSubEmails(value: unknown): string[] {
|
function normalizeSubEmails(value: unknown): string[] {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return value
|
return value
|
||||||
@@ -699,6 +723,9 @@ function UserDetailPage() {
|
|||||||
string,
|
string,
|
||||||
Record<string, string | number | boolean>
|
Record<string, string | number | boolean>
|
||||||
>) || {}),
|
>) || {}),
|
||||||
|
employee_id: normalizeEmployeeIDMetadataValue(
|
||||||
|
user.metadata?.employee_id,
|
||||||
|
),
|
||||||
sub_email: Array.isArray(user.metadata?.sub_email)
|
sub_email: Array.isArray(user.metadata?.sub_email)
|
||||||
? user.metadata.sub_email
|
? user.metadata.sub_email
|
||||||
: typeof user.metadata?.sub_email === "string"
|
: typeof user.metadata?.sub_email === "string"
|
||||||
@@ -837,15 +864,22 @@ function UserDetailPage() {
|
|||||||
...safeMetadata,
|
...safeMetadata,
|
||||||
...(subEmail.length > 0 ? { sub_email: subEmail } : { sub_email: [] }),
|
...(subEmail.length > 0 ? { sub_email: subEmail } : { sub_email: [] }),
|
||||||
};
|
};
|
||||||
|
const employeeID = String(data.metadata?.employee_id ?? "").trim();
|
||||||
|
if (employeeID) {
|
||||||
|
metadata.employee_id = employeeID;
|
||||||
|
} else {
|
||||||
|
delete metadata.employee_id;
|
||||||
|
}
|
||||||
|
|
||||||
const payload: UserUpdateRequest = {
|
const payload: UserUpdateRequest = {
|
||||||
...data,
|
...data,
|
||||||
metadata,
|
metadata,
|
||||||
};
|
};
|
||||||
// email cannot be updated directly via this API in current backend implementation,
|
if (profileRole !== "super_admin") {
|
||||||
// so we delete it from payload if it spread
|
delete payload.email;
|
||||||
// @ts-expect-error
|
} else {
|
||||||
delete payload.email;
|
payload.email = data.email.trim();
|
||||||
|
}
|
||||||
payload.role = undefined;
|
payload.role = undefined;
|
||||||
|
|
||||||
if (userCategory === "personal") {
|
if (userCategory === "personal") {
|
||||||
@@ -1107,9 +1141,19 @@ function UserDetailPage() {
|
|||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
value={user.email}
|
type="email"
|
||||||
disabled
|
disabled={profileRole !== "super_admin"}
|
||||||
className="bg-muted/50 border-none font-medium h-11"
|
{...register("email", {
|
||||||
|
required: t(
|
||||||
|
"msg.admin.users.detail.email_required",
|
||||||
|
"이메일을 입력하세요.",
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
className={
|
||||||
|
profileRole === "super_admin"
|
||||||
|
? "h-11 shadow-sm"
|
||||||
|
: "bg-muted/50 border-none font-medium h-11"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -1146,6 +1190,37 @@ function UserDetailPage() {
|
|||||||
className="h-11 shadow-sm"
|
className="h-11 shadow-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="metadata_employee_id"
|
||||||
|
className="text-xs font-bold uppercase text-muted-foreground"
|
||||||
|
>
|
||||||
|
사번
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="metadata_employee_id"
|
||||||
|
maxLength={20}
|
||||||
|
{...register("metadata.employee_id", {
|
||||||
|
setValueAs: (value) =>
|
||||||
|
typeof value === "string" ? value.trim() : value,
|
||||||
|
maxLength: {
|
||||||
|
value: 20,
|
||||||
|
message:
|
||||||
|
"Worksmobile 사번은 20자 이하로 입력해야 합니다.",
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
className="h-11 shadow-sm"
|
||||||
|
/>
|
||||||
|
{errors.metadata?.employee_id && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{String(errors.metadata.employee_id.message)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-1">
|
||||||
|
Worksmobile employeeNumber로 전송됩니다. 1~20자만
|
||||||
|
허용됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-8 md:grid-cols-2 pt-6 border-t border-dashed">
|
<div className="grid gap-8 md:grid-cols-2 pt-6 border-t border-dashed">
|
||||||
|
|||||||
@@ -115,6 +115,13 @@ function hanmacEmailStatusLabel(preview?: HanmacImportEmailPreview) {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function userImportErrorLabel(user: BulkUserItem) {
|
||||||
|
if (!user.importErrors?.includes("duplicateEmail")) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return "중복 이메일";
|
||||||
|
}
|
||||||
|
|
||||||
function hanmacEmailStatusClass(preview?: HanmacImportEmailPreview) {
|
function hanmacEmailStatusClass(preview?: HanmacImportEmailPreview) {
|
||||||
if (!preview) return "text-muted-foreground";
|
if (!preview) return "text-muted-foreground";
|
||||||
if (preview.status === "blockingError") return "text-destructive";
|
if (preview.status === "blockingError") return "text-destructive";
|
||||||
@@ -355,6 +362,9 @@ export function UserBulkUploadModal({
|
|||||||
const hasBlockingHanmacEmailRows = hanmacEmailPreviews.some(
|
const hasBlockingHanmacEmailRows = hanmacEmailPreviews.some(
|
||||||
(preview) => preview?.status === "blockingError",
|
(preview) => preview?.status === "blockingError",
|
||||||
);
|
);
|
||||||
|
const hasBlockingImportRows = previewData.some(
|
||||||
|
(user) => (user.importErrors?.length ?? 0) > 0,
|
||||||
|
);
|
||||||
|
|
||||||
const triggerProps = {
|
const triggerProps = {
|
||||||
disabled: mutation.isPending,
|
disabled: mutation.isPending,
|
||||||
@@ -576,11 +586,22 @@ export function UserBulkUploadModal({
|
|||||||
<td className="p-2">{u.name}</td>
|
<td className="p-2">{u.name}</td>
|
||||||
<td className="p-2">{u.tenantSlug || "-"}</td>
|
<td className="p-2">{u.tenantSlug || "-"}</td>
|
||||||
<td
|
<td
|
||||||
className={`p-2 text-xs ${hanmacEmailStatusClass(
|
className={`p-2 text-xs ${
|
||||||
hanmacEmailPreviews[index],
|
u.importErrors?.length
|
||||||
)}`}
|
? "text-destructive"
|
||||||
|
: hanmacEmailStatusClass(
|
||||||
|
hanmacEmailPreviews[index],
|
||||||
|
)
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{hanmacEmailStatusLabel(hanmacEmailPreviews[index])}
|
{u.importErrors?.length
|
||||||
|
? "오류"
|
||||||
|
: hanmacEmailStatusLabel(
|
||||||
|
hanmacEmailPreviews[index],
|
||||||
|
)}
|
||||||
|
{u.importErrors?.length ? (
|
||||||
|
<div>{userImportErrorLabel(u)}</div>
|
||||||
|
) : null}
|
||||||
{hanmacEmailPreviews[index]?.reason && (
|
{hanmacEmailPreviews[index]?.reason && (
|
||||||
<div>{hanmacEmailPreviews[index]?.reason}</div>
|
<div>{hanmacEmailPreviews[index]?.reason}</div>
|
||||||
)}
|
)}
|
||||||
@@ -665,7 +686,8 @@ export function UserBulkUploadModal({
|
|||||||
previewData.length === 0 ||
|
previewData.length === 0 ||
|
||||||
mutation.isPending ||
|
mutation.isPending ||
|
||||||
preparing ||
|
preparing ||
|
||||||
hasBlockingHanmacEmailRows
|
hasBlockingHanmacEmailRows ||
|
||||||
|
hasBlockingImportRows
|
||||||
}
|
}
|
||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
data-testid="bulk-start-btn"
|
data-testid="bulk-start-btn"
|
||||||
|
|||||||
@@ -97,6 +97,22 @@ test@test.com,Test,local-tenant-id,missing-slug,Missing Tenant,COMPANY,parent-sl
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should preserve exported user_id for UUID based restore", () => {
|
||||||
|
const csv = `user_id,email,name,tenant_id,tenant_slug
|
||||||
|
9f8cc1b1-af8d-45d4-946c-924a529c2556,restore@test.com,Restore User,tenant-id,restore-tenant`;
|
||||||
|
|
||||||
|
const result = parseUserCSV(csv);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
userId: "9f8cc1b1-af8d-45d4-946c-924a529c2556",
|
||||||
|
email: "restore@test.com",
|
||||||
|
name: "Restore User",
|
||||||
|
tenantId: "tenant-id",
|
||||||
|
tenantSlug: "restore-tenant",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("should parse one nullable additional appointment from numbered columns", () => {
|
it("should parse one nullable additional appointment from numbered columns", () => {
|
||||||
const csv = `email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1
|
const csv = `email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1
|
||||||
dual@test.com,Dual User,010-0000-0000,user,primary-tenant,개발팀,책임,팀장,Backend,EMP001,second-tenant,센터,수석,,Architecture,EMP002
|
dual@test.com,Dual User,010-0000-0000,user,primary-tenant,개발팀,책임,팀장,Backend,EMP001,second-tenant,센터,수석,,Architecture,EMP002
|
||||||
@@ -146,4 +162,28 @@ primary@samaneng.com,Primary User,rnd-saman,EMP001,secondary@hanmaceng.co.kr`;
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should mark duplicate bulk alias emails as blocking import errors", () => {
|
||||||
|
const csv = `email,name,tenant_slug,sub_email
|
||||||
|
user1@samaneng.com,User One,rnd-saman,shared@hanmaceng.co.kr
|
||||||
|
user2@samaneng.com,User Two,rnd-saman,shared@hanmaceng.co.kr`;
|
||||||
|
|
||||||
|
const result = parseUserCSV(csv);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].importErrors).toContain("duplicateEmail");
|
||||||
|
expect(result[1].importErrors).toContain("duplicateEmail");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should mark a primary email reused as a sub email as a blocking import error", () => {
|
||||||
|
const csv = `email,name,tenant_slug,sub_email
|
||||||
|
user1@samaneng.com,User One,rnd-saman,user2@samaneng.com
|
||||||
|
user2@samaneng.com,User Two,rnd-saman,alias@hanmaceng.co.kr`;
|
||||||
|
|
||||||
|
const result = parseUserCSV(csv);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].importErrors).toContain("duplicateEmail");
|
||||||
|
expect(result[1].importErrors).toContain("duplicateEmail");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ export function parseUserCSV(text: string): BulkUserItem[] {
|
|||||||
const value = values[index];
|
const value = values[index];
|
||||||
if (value === undefined || value === "") continue;
|
if (value === undefined || value === "") continue;
|
||||||
|
|
||||||
if (header === "email") {
|
if (header === "user_id") {
|
||||||
|
item.userId = value;
|
||||||
|
} else if (header === "email") {
|
||||||
item.email = value;
|
item.email = value;
|
||||||
} else if (header === "name") {
|
} else if (header === "name") {
|
||||||
item.name = value;
|
item.name = value;
|
||||||
@@ -186,7 +188,7 @@ export function parseUserCSV(text: string): BulkUserItem[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return markBulkEmailDuplicateErrors(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanAdditionalAppointment(
|
function cleanAdditionalAppointment(
|
||||||
@@ -335,6 +337,66 @@ function uniqueEmails(values: string[]) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bulkUserImportErrorList(user: BulkUserItem) {
|
||||||
|
return Array.isArray(user.importErrors) ? user.importErrors : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function withBulkUserImportError(user: BulkUserItem, error: string) {
|
||||||
|
const errors = Array.from(new Set([...bulkUserImportErrorList(user), error]));
|
||||||
|
return { ...user, importErrors: errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
function bulkUserAliasEmails(user: BulkUserItem) {
|
||||||
|
return uniqueEmails([
|
||||||
|
...metadataEmailList(user.metadata.sub_email),
|
||||||
|
...metadataEmailList(user.metadata.aliasEmails),
|
||||||
|
...metadataEmailList(user.metadata.secondary_emails),
|
||||||
|
...metadataEmailList(user.metadata.worksmobileAliasEmails),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markBulkEmailDuplicateErrors(users: BulkUserItem[]) {
|
||||||
|
const duplicateIndexes = new Set<number>();
|
||||||
|
const owners = new Map<string, Set<number>>();
|
||||||
|
|
||||||
|
users.forEach((user, index) => {
|
||||||
|
const primaryEmail = user.email.trim().toLowerCase();
|
||||||
|
const aliases = bulkUserAliasEmails(user);
|
||||||
|
const rowEmails = new Set<string>();
|
||||||
|
|
||||||
|
if (primaryEmail) {
|
||||||
|
rowEmails.add(primaryEmail);
|
||||||
|
}
|
||||||
|
for (const alias of aliases) {
|
||||||
|
if (primaryEmail && alias === primaryEmail) {
|
||||||
|
duplicateIndexes.add(index);
|
||||||
|
}
|
||||||
|
rowEmails.add(alias);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const email of rowEmails) {
|
||||||
|
const existing = owners.get(email) ?? new Set<number>();
|
||||||
|
existing.add(index);
|
||||||
|
owners.set(email, existing);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const indexes of owners.values()) {
|
||||||
|
if (indexes.size < 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const index of indexes) {
|
||||||
|
duplicateIndexes.add(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return users.map((user, index) =>
|
||||||
|
duplicateIndexes.has(index)
|
||||||
|
? withBulkUserImportError(user, "duplicateEmail")
|
||||||
|
: user,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function addWorksmobileAliasEmails(
|
function addWorksmobileAliasEmails(
|
||||||
item: Partial<BulkUserItem> & { metadata: Record<string, unknown> },
|
item: Partial<BulkUserItem> & { metadata: Record<string, unknown> },
|
||||||
emails: string[],
|
emails: string[],
|
||||||
|
|||||||
@@ -73,7 +73,12 @@ describe("adminApi endpoint contracts", () => {
|
|||||||
await adminApi.fetchUser("user-1");
|
await adminApi.fetchUser("user-1");
|
||||||
await adminApi.fetchWorksmobileOverview("tenant-1");
|
await adminApi.fetchWorksmobileOverview("tenant-1");
|
||||||
await adminApi.fetchWorksmobileComparison("tenant-1", true);
|
await adminApi.fetchWorksmobileComparison("tenant-1", true);
|
||||||
|
await adminApi.fetchWorksmobileCredentialBatches("tenant-1");
|
||||||
await adminApi.downloadWorksmobileInitialPasswordsCSV("tenant-1");
|
await adminApi.downloadWorksmobileInitialPasswordsCSV("tenant-1");
|
||||||
|
await adminApi.downloadWorksmobileInitialPasswordsCSV(
|
||||||
|
"tenant-1",
|
||||||
|
"credential-batch-1",
|
||||||
|
);
|
||||||
await adminApi.fetchPasswordPolicy();
|
await adminApi.fetchPasswordPolicy();
|
||||||
await adminApi.fetchUserRpHistory("user-1");
|
await adminApi.fetchUserRpHistory("user-1");
|
||||||
await adminApi.fetchMe();
|
await adminApi.fetchMe();
|
||||||
@@ -104,6 +109,16 @@ describe("adminApi endpoint contracts", () => {
|
|||||||
"/v1/admin/tenants/tenant-1/worksmobile/comparison",
|
"/v1/admin/tenants/tenant-1/worksmobile/comparison",
|
||||||
{ params: { includeMatched: true } },
|
{ params: { includeMatched: true } },
|
||||||
);
|
);
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith(
|
||||||
|
"/v1/admin/tenants/tenant-1/worksmobile/credential-batches",
|
||||||
|
);
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith(
|
||||||
|
"/v1/admin/tenants/tenant-1/worksmobile/initial-passwords.csv",
|
||||||
|
{
|
||||||
|
params: { batchId: "credential-batch-1" },
|
||||||
|
responseType: "blob",
|
||||||
|
},
|
||||||
|
);
|
||||||
expect(await adminApi.exportTenantsCSV(true, "parent-1")).toMatchObject({
|
expect(await adminApi.exportTenantsCSV(true, "parent-1")).toMatchObject({
|
||||||
filename: "export.csv",
|
filename: "export.csv",
|
||||||
});
|
});
|
||||||
@@ -148,6 +163,20 @@ describe("adminApi endpoint contracts", () => {
|
|||||||
await adminApi.enqueueWorksmobileOrgUnitSync("tenant-1", "org/unit");
|
await adminApi.enqueueWorksmobileOrgUnitSync("tenant-1", "org/unit");
|
||||||
await adminApi.enqueueWorksmobileOrgUnitDelete("tenant-1", "org/unit");
|
await adminApi.enqueueWorksmobileOrgUnitDelete("tenant-1", "org/unit");
|
||||||
await adminApi.enqueueWorksmobileUserSync("tenant-1", "user-1");
|
await adminApi.enqueueWorksmobileUserSync("tenant-1", "user-1");
|
||||||
|
await adminApi.enqueueWorksmobileUserSync(
|
||||||
|
"tenant-1",
|
||||||
|
"user-2",
|
||||||
|
"credential-batch-1",
|
||||||
|
);
|
||||||
|
await adminApi.resetWorksmobileUserPassword(
|
||||||
|
"tenant-1",
|
||||||
|
"user-2",
|
||||||
|
"credential-batch-2",
|
||||||
|
);
|
||||||
|
await adminApi.deleteWorksmobileCredentialBatchPasswords(
|
||||||
|
"tenant-1",
|
||||||
|
"credential-batch-1",
|
||||||
|
);
|
||||||
await adminApi.retryWorksmobileJob("tenant-1", "job-1");
|
await adminApi.retryWorksmobileJob("tenant-1", "job-1");
|
||||||
await adminApi.bulkUpdateUsers({ userIds: ["user-1"], status: "inactive" });
|
await adminApi.bulkUpdateUsers({ userIds: ["user-1"], status: "inactive" });
|
||||||
await adminApi.bulkDeleteUsers(["user-1"]);
|
await adminApi.bulkDeleteUsers(["user-1"]);
|
||||||
@@ -178,6 +207,17 @@ describe("adminApi endpoint contracts", () => {
|
|||||||
expect(apiClient.post).toHaveBeenCalledWith(
|
expect(apiClient.post).toHaveBeenCalledWith(
|
||||||
"/v1/admin/tenants/tenant-1/worksmobile/orgunits/org%2Funit/sync",
|
"/v1/admin/tenants/tenant-1/worksmobile/orgunits/org%2Funit/sync",
|
||||||
);
|
);
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith(
|
||||||
|
"/v1/admin/tenants/tenant-1/worksmobile/users/user-2/sync",
|
||||||
|
{ credentialBatchId: "credential-batch-1" },
|
||||||
|
);
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith(
|
||||||
|
"/v1/admin/tenants/tenant-1/worksmobile/users/user-2/password/reset",
|
||||||
|
{ credentialBatchId: "credential-batch-2" },
|
||||||
|
);
|
||||||
|
expect(apiClient.delete).toHaveBeenCalledWith(
|
||||||
|
"/v1/admin/tenants/tenant-1/worksmobile/credential-batches/credential-batch-1/passwords",
|
||||||
|
);
|
||||||
expect(apiClient.delete).toHaveBeenCalledWith(
|
expect(apiClient.delete).toHaveBeenCalledWith(
|
||||||
"/v1/admin/relying-parties/client-1/owners/User:user-1",
|
"/v1/admin/relying-parties/client-1/owners/User:user-1",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -676,6 +676,7 @@ export type UserCreateResponse = UserSummary & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type UserUpdateRequest = {
|
export type UserUpdateRequest = {
|
||||||
|
email?: string;
|
||||||
loginId?: string;
|
loginId?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -725,6 +726,7 @@ export type BulkUserAppointment = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type BulkUserItem = {
|
export type BulkUserItem = {
|
||||||
|
userId?: string;
|
||||||
email: string;
|
email: string;
|
||||||
loginId?: string;
|
loginId?: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -750,6 +752,7 @@ export type BulkUserItem = {
|
|||||||
emailDomain?: string;
|
emailDomain?: string;
|
||||||
};
|
};
|
||||||
metadata: Record<string, unknown>;
|
metadata: Record<string, unknown>;
|
||||||
|
importErrors?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BulkUserResult = {
|
export type BulkUserResult = {
|
||||||
@@ -790,6 +793,30 @@ export type WorksmobileOverview = {
|
|||||||
recentJobs: WorksmobileOutboxItem[];
|
recentJobs: WorksmobileOutboxItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WorksmobileCredentialBatch = {
|
||||||
|
batchId: string;
|
||||||
|
operation?: string;
|
||||||
|
userCount: number;
|
||||||
|
pendingCount?: number;
|
||||||
|
processingCount?: number;
|
||||||
|
processedCount?: number;
|
||||||
|
failedCount?: number;
|
||||||
|
hasPasswords: boolean;
|
||||||
|
deletedAt?: string;
|
||||||
|
failures?: WorksmobileCredentialBatchFailure[];
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorksmobileCredentialBatchFailure = {
|
||||||
|
userId?: string;
|
||||||
|
email?: string;
|
||||||
|
status: string;
|
||||||
|
retryCount: number;
|
||||||
|
lastError?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type WorksmobileComparisonItem = {
|
export type WorksmobileComparisonItem = {
|
||||||
resourceType: string;
|
resourceType: string;
|
||||||
baronId?: string;
|
baronId?: string;
|
||||||
@@ -823,6 +850,10 @@ export type WorksmobileComparisonItem = {
|
|||||||
worksmobileParentName?: string;
|
worksmobileParentName?: string;
|
||||||
worksmobileParentEmail?: string;
|
worksmobileParentEmail?: string;
|
||||||
worksmobileParentExternalKey?: string;
|
worksmobileParentExternalKey?: string;
|
||||||
|
worksmobileJobStatus?: string;
|
||||||
|
worksmobileJobRetryCount?: number;
|
||||||
|
worksmobileLastError?: string;
|
||||||
|
worksmobileLastAttemptAt?: string;
|
||||||
status: string;
|
status: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -906,10 +937,22 @@ export async function fetchWorksmobileComparison(
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadWorksmobileInitialPasswordsCSV(tenantId: string) {
|
export async function fetchWorksmobileCredentialBatches(tenantId: string) {
|
||||||
|
const { data } = await apiClient.get<WorksmobileCredentialBatch[]>(
|
||||||
|
`/v1/admin/tenants/${tenantId}/worksmobile/credential-batches`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadWorksmobileInitialPasswordsCSV(
|
||||||
|
tenantId: string,
|
||||||
|
batchId?: string,
|
||||||
|
) {
|
||||||
|
const trimmedBatchId = batchId?.trim();
|
||||||
const response = await apiClient.get<Blob>(
|
const response = await apiClient.get<Blob>(
|
||||||
`/v1/admin/tenants/${tenantId}/worksmobile/initial-passwords.csv`,
|
`/v1/admin/tenants/${tenantId}/worksmobile/initial-passwords.csv`,
|
||||||
{
|
{
|
||||||
|
...(trimmedBatchId ? { params: { batchId: trimmedBatchId } } : {}),
|
||||||
responseType: "blob",
|
responseType: "blob",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -924,6 +967,16 @@ export async function downloadWorksmobileInitialPasswordsCSV(tenantId: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteWorksmobileCredentialBatchPasswords(
|
||||||
|
tenantId: string,
|
||||||
|
batchId: string,
|
||||||
|
) {
|
||||||
|
const { data } = await apiClient.delete<WorksmobileCredentialBatch>(
|
||||||
|
`/v1/admin/tenants/${tenantId}/worksmobile/credential-batches/${encodeURIComponent(batchId)}/passwords`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function enqueueWorksmobileBackfillDryRun(tenantId: string) {
|
export async function enqueueWorksmobileBackfillDryRun(tenantId: string) {
|
||||||
const { data } = await apiClient.post(
|
const { data } = await apiClient.post(
|
||||||
`/v1/admin/tenants/${tenantId}/worksmobile/backfill/dry-run`,
|
`/v1/admin/tenants/${tenantId}/worksmobile/backfill/dry-run`,
|
||||||
@@ -954,10 +1007,30 @@ export async function enqueueWorksmobileOrgUnitDelete(
|
|||||||
export async function enqueueWorksmobileUserSync(
|
export async function enqueueWorksmobileUserSync(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
credentialBatchId?: string,
|
||||||
) {
|
) {
|
||||||
const { data } = await apiClient.post<WorksmobileOutboxItem>(
|
const trimmedBatchId = credentialBatchId?.trim();
|
||||||
`/v1/admin/tenants/${tenantId}/worksmobile/users/${userId}/sync`,
|
const path = `/v1/admin/tenants/${tenantId}/worksmobile/users/${userId}/sync`;
|
||||||
);
|
const { data } = trimmedBatchId
|
||||||
|
? await apiClient.post<WorksmobileOutboxItem>(path, {
|
||||||
|
credentialBatchId: trimmedBatchId,
|
||||||
|
})
|
||||||
|
: await apiClient.post<WorksmobileOutboxItem>(path);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetWorksmobileUserPassword(
|
||||||
|
tenantId: string,
|
||||||
|
userId: string,
|
||||||
|
credentialBatchId?: string,
|
||||||
|
) {
|
||||||
|
const trimmedBatchId = credentialBatchId?.trim();
|
||||||
|
const path = `/v1/admin/tenants/${tenantId}/worksmobile/users/${userId}/password/reset`;
|
||||||
|
const { data } = trimmedBatchId
|
||||||
|
? await apiClient.post<WorksmobileOutboxItem>(path, {
|
||||||
|
credentialBatchId: trimmedBatchId,
|
||||||
|
})
|
||||||
|
: await apiClient.post<WorksmobileOutboxItem>(path);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -750,11 +750,14 @@ func main() {
|
|||||||
|
|
||||||
admin.Get("/tenants/:tenantId/worksmobile", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.GetOverview)
|
admin.Get("/tenants/:tenantId/worksmobile", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.GetOverview)
|
||||||
admin.Get("/tenants/:tenantId/worksmobile/comparison", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.GetComparison)
|
admin.Get("/tenants/:tenantId/worksmobile/comparison", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.GetComparison)
|
||||||
|
admin.Get("/tenants/:tenantId/worksmobile/credential-batches", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.ListCredentialBatches)
|
||||||
|
admin.Delete("/tenants/:tenantId/worksmobile/credential-batches/:batchId/passwords", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DeleteCredentialBatchPasswords)
|
||||||
admin.Get("/tenants/:tenantId/worksmobile/initial-passwords.csv", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DownloadInitialPasswordsCSV)
|
admin.Get("/tenants/:tenantId/worksmobile/initial-passwords.csv", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DownloadInitialPasswordsCSV)
|
||||||
admin.Post("/tenants/:tenantId/worksmobile/backfill/dry-run", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.BackfillDryRun)
|
admin.Post("/tenants/:tenantId/worksmobile/backfill/dry-run", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.BackfillDryRun)
|
||||||
admin.Post("/tenants/:tenantId/worksmobile/orgunits/:orgUnitId/sync", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.SyncOrgUnit)
|
admin.Post("/tenants/:tenantId/worksmobile/orgunits/:orgUnitId/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/: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)
|
||||||
|
|
||||||
// Organization & Org-Chart Management (Tenant Admin/Super Admin)
|
// Organization & Org-Chart Management (Tenant Admin/Super Admin)
|
||||||
|
|||||||
@@ -20,10 +20,11 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
WorksmobileActionUpsert = "UPSERT"
|
WorksmobileActionUpsert = "UPSERT"
|
||||||
WorksmobileActionDelete = "DELETE"
|
WorksmobileActionDelete = "DELETE"
|
||||||
WorksmobileActionDryRun = "DRY_RUN"
|
WorksmobileActionDryRun = "DRY_RUN"
|
||||||
WorksmobileActionSuspend = "SUSPEND"
|
WorksmobileActionSuspend = "SUSPEND"
|
||||||
|
WorksmobileActionPasswordReset = "PASSWORD_RESET"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WorksmobileOutbox struct {
|
type WorksmobileOutbox struct {
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/mail"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -850,6 +852,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type bulkUserItem struct {
|
type bulkUserItem struct {
|
||||||
|
UserID string `json:"userId"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
LoginID string `json:"loginId"`
|
LoginID string `json:"loginId"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -906,6 +909,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
var hanmacScope *hanmacEmailScope
|
var hanmacScope *hanmacEmailScope
|
||||||
var hanmacLocalParts map[string]bool
|
var hanmacLocalParts map[string]bool
|
||||||
hanmacScopeLoaded := false
|
hanmacScopeLoaded := false
|
||||||
|
bulkEmailErrors := validateBulkUserEmailUniqueness(req.Users)
|
||||||
|
|
||||||
// Pre-fetch tenant data to avoid redundant DB calls
|
// Pre-fetch tenant data to avoid redundant DB calls
|
||||||
type tenantCacheItem struct {
|
type tenantCacheItem struct {
|
||||||
@@ -1011,7 +1015,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
return cacheTenantItem(buildTenantCacheItem(tenant)), nil
|
return cacheTenantItem(buildTenantCacheItem(tenant)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, item := range req.Users {
|
for index, item := range req.Users {
|
||||||
email := strings.TrimSpace(item.Email)
|
email := strings.TrimSpace(item.Email)
|
||||||
name := strings.TrimSpace(item.Name)
|
name := strings.TrimSpace(item.Name)
|
||||||
tenantID := strings.TrimSpace(item.TenantID)
|
tenantID := strings.TrimSpace(item.TenantID)
|
||||||
@@ -1026,6 +1030,10 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
results = append(results, bulkUserResult{Email: email, Success: false, Message: tenantSlugErr.Error()})
|
results = append(results, bulkUserResult{Email: email, Success: false, Message: tenantSlugErr.Error()})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if message, exists := bulkEmailErrors[index]; exists {
|
||||||
|
results = append(results, bulkUserResult{Email: email, Success: false, Status: "blockingError", Message: message})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
var tItem tenantCacheItem
|
var tItem tenantCacheItem
|
||||||
var err error
|
var err error
|
||||||
@@ -1192,6 +1200,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
item.Metadata["additionalAppointments"] = resolvedAppointments
|
item.Metadata["additionalAppointments"] = resolvedAppointments
|
||||||
}
|
}
|
||||||
|
normalizeBulkUserAliasMetadata(item.Metadata)
|
||||||
item.Metadata = sanitizeUserMetadata(item.Metadata)
|
item.Metadata = sanitizeUserMetadata(item.Metadata)
|
||||||
|
|
||||||
password, _ := utils.GeneratePasswordWithPolicy(policy)
|
password, _ := utils.GeneratePasswordWithPolicy(policy)
|
||||||
@@ -1252,6 +1261,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
identityID, err := h.OryProvider.CreateUser(&domain.BrokerUser{
|
identityID, err := h.OryProvider.CreateUser(&domain.BrokerUser{
|
||||||
|
ID: strings.TrimSpace(item.UserID),
|
||||||
Email: userEmail,
|
Email: userEmail,
|
||||||
Name: item.Name,
|
Name: item.Name,
|
||||||
PhoneNumber: userPhone,
|
PhoneNumber: userPhone,
|
||||||
@@ -1845,6 +1855,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
|
Email *string `json:"email"`
|
||||||
LoginID *string `json:"loginId"`
|
LoginID *string `json:"loginId"`
|
||||||
Password *string `json:"password"`
|
Password *string `json:"password"`
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
@@ -1948,6 +1959,31 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
if traits == nil {
|
if traits == nil {
|
||||||
traits = map[string]interface{}{}
|
traits = map[string]interface{}{}
|
||||||
}
|
}
|
||||||
|
if req.Email != nil {
|
||||||
|
currentEmail := strings.TrimSpace(extractTraitString(traits, "email"))
|
||||||
|
nextEmail := strings.ToLower(strings.TrimSpace(*req.Email))
|
||||||
|
if nextEmail == "" {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, "email is required")
|
||||||
|
}
|
||||||
|
parsed, parseErr := mail.ParseAddress(nextEmail)
|
||||||
|
if parseErr != nil || !strings.EqualFold(parsed.Address, nextEmail) {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, "invalid email")
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(currentEmail, nextEmail) {
|
||||||
|
if requester == nil || domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
|
||||||
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can change user email")
|
||||||
|
}
|
||||||
|
if h.UserRepo != nil {
|
||||||
|
if existing, err := h.UserRepo.FindByEmail(c.Context(), nextEmail); err == nil && existing != nil && existing.ID != userID {
|
||||||
|
return errorJSON(c, fiber.StatusConflict, "email is already used by another user")
|
||||||
|
}
|
||||||
|
if taken, err := h.UserRepo.IsLoginIDTaken(c.Context(), nextEmail); err == nil && taken {
|
||||||
|
return errorJSON(c, fiber.StatusConflict, "email is already used as a login ID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
traits["email"] = nextEmail
|
||||||
|
}
|
||||||
|
}
|
||||||
delete(traits, "hanmacFamily")
|
delete(traits, "hanmacFamily")
|
||||||
delete(traits, "userType")
|
delete(traits, "userType")
|
||||||
|
|
||||||
@@ -2048,6 +2084,13 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if subEmailRaw, exists := req.Metadata["sub_email"]; exists {
|
||||||
|
subEmails := normalizeUserSubEmailValues(subEmailRaw)
|
||||||
|
traits["sub_email"] = subEmails
|
||||||
|
traits["aliasEmails"] = subEmails
|
||||||
|
traits["secondary_emails"] = subEmails
|
||||||
|
traits["worksmobileAliasEmails"] = subEmails
|
||||||
|
}
|
||||||
|
|
||||||
// [LoginID Sync based on Tenant Settings]
|
// [LoginID Sync based on Tenant Settings]
|
||||||
// Perform sync AFTER metadata merge to ensure traits contains current values
|
// Perform sync AFTER metadata merge to ensure traits contains current values
|
||||||
@@ -2860,6 +2903,156 @@ func normalizeCustomLoginIDsTrait(traits map[string]interface{}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeUserSubEmailValues(raw any) []interface{} {
|
||||||
|
values := make([]string, 0)
|
||||||
|
switch typed := raw.(type) {
|
||||||
|
case []string:
|
||||||
|
values = append(values, typed...)
|
||||||
|
case []interface{}:
|
||||||
|
for _, item := range typed {
|
||||||
|
values = append(values, fmt.Sprint(item))
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
values = append(values, typed)
|
||||||
|
default:
|
||||||
|
if raw != nil {
|
||||||
|
values = append(values, fmt.Sprint(raw))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := map[string]bool{}
|
||||||
|
result := make([]interface{}, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
for _, part := range strings.Split(value, ",") {
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(part))
|
||||||
|
if normalized == "" || seen[normalized] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[normalized] = true
|
||||||
|
result = append(result, normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateBulkUserEmailUniqueness(users []bulkUserItem) map[int]string {
|
||||||
|
owners := map[string]map[int]bool{}
|
||||||
|
errorsByIndex := map[int]string{}
|
||||||
|
|
||||||
|
for index, user := range users {
|
||||||
|
primaryEmail := normalizeBulkUserEmail(user.Email)
|
||||||
|
aliases := bulkUserAliasEmailSet(user.Metadata)
|
||||||
|
rowEmails := map[string]bool{}
|
||||||
|
if primaryEmail != "" {
|
||||||
|
rowEmails[primaryEmail] = true
|
||||||
|
}
|
||||||
|
for alias := range aliases {
|
||||||
|
if primaryEmail != "" && alias == primaryEmail {
|
||||||
|
errorsByIndex[index] = "duplicate email in bulk request: " + alias
|
||||||
|
}
|
||||||
|
rowEmails[alias] = true
|
||||||
|
}
|
||||||
|
for email := range rowEmails {
|
||||||
|
if owners[email] == nil {
|
||||||
|
owners[email] = map[int]bool{}
|
||||||
|
}
|
||||||
|
owners[email][index] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for email, indexes := range owners {
|
||||||
|
if len(indexes) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for index := range indexes {
|
||||||
|
errorsByIndex[index] = "duplicate email in bulk request: " + email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errorsByIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeBulkUserAliasMetadata(metadata map[string]any) {
|
||||||
|
if metadata == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
aliases := bulkUserAliasEmailSet(metadata)
|
||||||
|
hasAliasField := false
|
||||||
|
for _, key := range []string{"sub_email", "aliasEmails", "secondary_emails", "worksmobileAliasEmails"} {
|
||||||
|
if _, exists := metadata[key]; exists {
|
||||||
|
hasAliasField = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasAliasField {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
values := make([]interface{}, 0, len(aliases))
|
||||||
|
for alias := range aliases {
|
||||||
|
values = append(values, alias)
|
||||||
|
}
|
||||||
|
sort.Slice(values, func(i, j int) bool {
|
||||||
|
return fmt.Sprint(values[i]) < fmt.Sprint(values[j])
|
||||||
|
})
|
||||||
|
metadata["sub_email"] = values
|
||||||
|
metadata["aliasEmails"] = values
|
||||||
|
metadata["secondary_emails"] = values
|
||||||
|
metadata["worksmobileAliasEmails"] = values
|
||||||
|
}
|
||||||
|
|
||||||
|
func bulkUserAliasEmailSet(metadata map[string]any) map[string]bool {
|
||||||
|
aliases := map[string]bool{}
|
||||||
|
for _, key := range []string{"sub_email", "aliasEmails", "secondary_emails", "worksmobileAliasEmails"} {
|
||||||
|
for _, value := range bulkUserEmailValues(metadata[key]) {
|
||||||
|
aliases[value] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return aliases
|
||||||
|
}
|
||||||
|
|
||||||
|
func bulkUserEmailValues(raw any) []string {
|
||||||
|
values := make([]string, 0)
|
||||||
|
switch typed := raw.(type) {
|
||||||
|
case []string:
|
||||||
|
values = append(values, typed...)
|
||||||
|
case []interface{}:
|
||||||
|
for _, item := range typed {
|
||||||
|
values = append(values, fmt.Sprint(item))
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
values = append(values, typed)
|
||||||
|
default:
|
||||||
|
if raw != nil {
|
||||||
|
values = append(values, fmt.Sprint(raw))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
for _, token := range strings.FieldsFunc(value, func(r rune) bool {
|
||||||
|
return r == ',' || r == ';' || r == '\n' || r == '\r' || r == '\t'
|
||||||
|
}) {
|
||||||
|
email := normalizeBulkUserEmail(token)
|
||||||
|
if email != "" {
|
||||||
|
result = append(result, email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeBulkUserEmail(value string) string {
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||||
|
if normalized == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parsed, err := mail.ParseAddress(normalized)
|
||||||
|
if err != nil {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
return strings.ToLower(strings.TrimSpace(parsed.Address))
|
||||||
|
}
|
||||||
|
|
||||||
func formatTime(value time.Time) string {
|
func formatTime(value time.Time) string {
|
||||||
if value.IsZero() {
|
if value.IsZero() {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -600,6 +600,171 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUserHandler_BulkCreateUsersPreservesRequestedUserID(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
mockKratos := new(MockKratosAdmin)
|
||||||
|
mockOry := new(MockOryProvider)
|
||||||
|
mockTenant := new(MockTenantServiceForUser)
|
||||||
|
const requestedUserID = "9f8cc1b1-af8d-45d4-946c-924a529c2556"
|
||||||
|
|
||||||
|
h := &UserHandler{
|
||||||
|
KratosAdmin: mockKratos,
|
||||||
|
OryProvider: mockOry,
|
||||||
|
TenantService: mockTenant,
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Post("/users/bulk", h.BulkCreateUsers)
|
||||||
|
|
||||||
|
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
|
||||||
|
mockTenant.On("GetTenant", mock.Anything, "tenant-123").Return(&domain.Tenant{
|
||||||
|
ID: "tenant-123",
|
||||||
|
Slug: "restore-tenant",
|
||||||
|
Config: domain.JSONMap{},
|
||||||
|
}, nil).Once()
|
||||||
|
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
|
||||||
|
return user != nil && user.ID == requestedUserID && user.Email == "restore@test.com"
|
||||||
|
}), mock.Anything).Return(requestedUserID, nil).Once()
|
||||||
|
|
||||||
|
payload := map[string]any{
|
||||||
|
"users": []map[string]any{
|
||||||
|
{
|
||||||
|
"userId": requestedUserID,
|
||||||
|
"email": "restore@test.com",
|
||||||
|
"name": "Restore User",
|
||||||
|
"tenantId": "tenant-123",
|
||||||
|
"tenantSlug": "restore-tenant",
|
||||||
|
"metadata": map[string]any{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, _ := app.Test(req)
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var result map[string]any
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
|
||||||
|
results := result["results"].([]any)
|
||||||
|
require.Len(t, results, 1)
|
||||||
|
row := results[0].(map[string]any)
|
||||||
|
assert.True(t, row["success"].(bool))
|
||||||
|
assert.Equal(t, requestedUserID, row["userId"])
|
||||||
|
mockOry.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserHandler_BulkCreateUsersRejectsDuplicateAliasEmailsInBatch(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
mockKratos := new(MockKratosAdmin)
|
||||||
|
mockOry := new(MockOryProvider)
|
||||||
|
mockTenant := new(MockTenantServiceForUser)
|
||||||
|
h := &UserHandler{
|
||||||
|
KratosAdmin: mockKratos,
|
||||||
|
OryProvider: mockOry,
|
||||||
|
TenantService: mockTenant,
|
||||||
|
}
|
||||||
|
app.Post("/users/bulk", h.BulkCreateUsers)
|
||||||
|
|
||||||
|
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil).Once()
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"users": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"email": "user1@samaneng.com",
|
||||||
|
"name": "User One",
|
||||||
|
"tenantSlug": "rnd-saman",
|
||||||
|
"metadata": map[string]interface{}{
|
||||||
|
"sub_email": []interface{}{"shared@hanmaceng.co.kr"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"email": "user2@samaneng.com",
|
||||||
|
"name": "User Two",
|
||||||
|
"tenantSlug": "rnd-saman",
|
||||||
|
"metadata": map[string]interface{}{
|
||||||
|
"worksmobileAliasEmails": []interface{}{"shared@hanmaceng.co.kr"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/users/bulk", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
|
||||||
|
results := result["results"].([]interface{})
|
||||||
|
require.Len(t, results, 2)
|
||||||
|
for _, item := range results {
|
||||||
|
row := item.(map[string]interface{})
|
||||||
|
require.False(t, row["success"].(bool))
|
||||||
|
require.Equal(t, "blockingError", row["status"])
|
||||||
|
require.Contains(t, row["message"].(string), "duplicate email")
|
||||||
|
}
|
||||||
|
mockOry.AssertExpectations(t)
|
||||||
|
mockOry.AssertNotCalled(t, "CreateUser", mock.Anything, mock.Anything)
|
||||||
|
mockTenant.AssertNotCalled(t, "GetTenantBySlug", mock.Anything, mock.Anything)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserHandler_BulkCreateUsersRejectsPrimaryEmailUsedAsSubEmail(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
mockKratos := new(MockKratosAdmin)
|
||||||
|
mockOry := new(MockOryProvider)
|
||||||
|
mockTenant := new(MockTenantServiceForUser)
|
||||||
|
h := &UserHandler{
|
||||||
|
KratosAdmin: mockKratos,
|
||||||
|
OryProvider: mockOry,
|
||||||
|
TenantService: mockTenant,
|
||||||
|
}
|
||||||
|
app.Post("/users/bulk", h.BulkCreateUsers)
|
||||||
|
|
||||||
|
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil).Once()
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"users": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"email": "user1@samaneng.com",
|
||||||
|
"name": "User One",
|
||||||
|
"tenantSlug": "rnd-saman",
|
||||||
|
"metadata": map[string]interface{}{
|
||||||
|
"sub_email": []interface{}{"user2@samaneng.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"email": "user2@samaneng.com",
|
||||||
|
"name": "User Two",
|
||||||
|
"tenantSlug": "rnd-saman",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/users/bulk", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
|
||||||
|
results := result["results"].([]interface{})
|
||||||
|
require.Len(t, results, 2)
|
||||||
|
for _, item := range results {
|
||||||
|
row := item.(map[string]interface{})
|
||||||
|
require.False(t, row["success"].(bool))
|
||||||
|
require.Equal(t, "blockingError", row["status"])
|
||||||
|
require.Contains(t, row["message"].(string), "duplicate email")
|
||||||
|
}
|
||||||
|
mockOry.AssertExpectations(t)
|
||||||
|
mockOry.AssertNotCalled(t, "CreateUser", mock.Anything, mock.Anything)
|
||||||
|
mockTenant.AssertNotCalled(t, "GetTenantBySlug", mock.Anything, mock.Anything)
|
||||||
|
}
|
||||||
|
|
||||||
func TestUserHandler_BulkCreateUsers_ResolvesAdditionalAppointment(t *testing.T) {
|
func TestUserHandler_BulkCreateUsers_ResolvesAdditionalAppointment(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
mockKratos := new(MockKratosAdmin)
|
mockKratos := new(MockKratosAdmin)
|
||||||
@@ -1429,6 +1594,138 @@ func TestUserHandler_UpdateUser_RejectsDeprecatedAdminRoles(t *testing.T) {
|
|||||||
mockKratos.AssertExpectations(t)
|
mockKratos.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUserHandler_UpdateUser_AllowsSuperAdminEmailChange(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
mockKratos := new(MockKratosAdmin)
|
||||||
|
h := &UserHandler{KratosAdmin: mockKratos}
|
||||||
|
app.Put("/users/:id", func(c *fiber.Ctx) error {
|
||||||
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin-1", Role: domain.RoleSuperAdmin})
|
||||||
|
return h.UpdateUser(c)
|
||||||
|
})
|
||||||
|
|
||||||
|
userID := "u-1"
|
||||||
|
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
|
||||||
|
ID: userID,
|
||||||
|
Traits: map[string]interface{}{
|
||||||
|
"email": "old@example.com",
|
||||||
|
"name": "사용자",
|
||||||
|
"role": domain.RoleUser,
|
||||||
|
},
|
||||||
|
State: "active",
|
||||||
|
}, nil).Once()
|
||||||
|
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool {
|
||||||
|
return traits["email"] == "new@example.com"
|
||||||
|
}), "").Return(&service.KratosIdentity{
|
||||||
|
ID: userID,
|
||||||
|
Traits: map[string]interface{}{
|
||||||
|
"email": "new@example.com",
|
||||||
|
"name": "사용자",
|
||||||
|
"role": domain.RoleUser,
|
||||||
|
},
|
||||||
|
State: "active",
|
||||||
|
}, nil).Once()
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]interface{}{"email": "new@example.com"})
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/users/"+userID, bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
mockKratos.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserHandler_UpdateUserClearsWorksmobileAliasMetadataWhenSubEmailIsCleared(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
mockKratos := new(MockKratosAdmin)
|
||||||
|
h := &UserHandler{KratosAdmin: mockKratos}
|
||||||
|
app.Put("/users/:id", func(c *fiber.Ctx) error {
|
||||||
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin-1", Role: domain.RoleSuperAdmin})
|
||||||
|
return h.UpdateUser(c)
|
||||||
|
})
|
||||||
|
|
||||||
|
userID := "u-1"
|
||||||
|
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
|
||||||
|
ID: userID,
|
||||||
|
Traits: map[string]interface{}{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"name": "사용자",
|
||||||
|
"role": domain.RoleUser,
|
||||||
|
"sub_email": []interface{}{"alias@hanmaceng.co.kr"},
|
||||||
|
"aliasEmails": []interface{}{"alias@hanmaceng.co.kr"},
|
||||||
|
"secondary_emails": []interface{}{"alias@hanmaceng.co.kr"},
|
||||||
|
"worksmobileAliasEmails": []interface{}{"alias@hanmaceng.co.kr"},
|
||||||
|
},
|
||||||
|
State: "active",
|
||||||
|
}, nil).Once()
|
||||||
|
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool {
|
||||||
|
for _, key := range []string{"sub_email", "aliasEmails", "secondary_emails", "worksmobileAliasEmails"} {
|
||||||
|
values, ok := traits[key].([]interface{})
|
||||||
|
if !ok || len(values) != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}), "").Return(&service.KratosIdentity{
|
||||||
|
ID: userID,
|
||||||
|
Traits: map[string]interface{}{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"name": "사용자",
|
||||||
|
"role": domain.RoleUser,
|
||||||
|
"sub_email": []interface{}{},
|
||||||
|
"aliasEmails": []interface{}{},
|
||||||
|
"secondary_emails": []interface{}{},
|
||||||
|
"worksmobileAliasEmails": []interface{}{},
|
||||||
|
},
|
||||||
|
State: "active",
|
||||||
|
}, nil).Once()
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"metadata": map[string]interface{}{
|
||||||
|
"sub_email": []interface{}{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/users/"+userID, bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
mockKratos.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserHandler_UpdateUser_RejectsNonSuperAdminEmailChange(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
mockKratos := new(MockKratosAdmin)
|
||||||
|
h := &UserHandler{KratosAdmin: mockKratos}
|
||||||
|
app.Put("/users/:id", func(c *fiber.Ctx) error {
|
||||||
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-2", Role: domain.RoleUser})
|
||||||
|
return h.UpdateUser(c)
|
||||||
|
})
|
||||||
|
|
||||||
|
userID := "u-1"
|
||||||
|
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
|
||||||
|
ID: userID,
|
||||||
|
Traits: map[string]interface{}{
|
||||||
|
"email": "old@example.com",
|
||||||
|
"name": "사용자",
|
||||||
|
"role": domain.RoleUser,
|
||||||
|
},
|
||||||
|
State: "active",
|
||||||
|
}, nil).Once()
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]interface{}{"email": "new@example.com"})
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/users/"+userID, bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
mockKratos.AssertExpectations(t)
|
||||||
|
mockKratos.AssertNotCalled(t, "UpdateIdentity", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||||
|
}
|
||||||
|
|
||||||
func TestSyncCustomLoginIDs_IgnoresFlatMetadataMaps(t *testing.T) {
|
func TestSyncCustomLoginIDs_IgnoresFlatMetadataMaps(t *testing.T) {
|
||||||
mockTenant := new(MockTenantServiceForUser)
|
mockTenant := new(MockTenantServiceForUser)
|
||||||
tenantID := "tenant-uuid"
|
tenantID := "tenant-uuid"
|
||||||
|
|||||||
@@ -72,13 +72,30 @@ func (h *WorksmobileHandler) DeleteOrgUnit(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
func (h *WorksmobileHandler) SyncUser(c *fiber.Ctx) error {
|
func (h *WorksmobileHandler) SyncUser(c *fiber.Ctx) error {
|
||||||
userID := strings.TrimSpace(c.Params("userId"))
|
userID := strings.TrimSpace(c.Params("userId"))
|
||||||
job, err := h.Service.EnqueueUserSync(c.Context(), strings.TrimSpace(c.Params("tenantId")), userID)
|
credentialBatchID, err := parseWorksmobileCredentialBatchID(c)
|
||||||
|
if err != nil {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||||
|
}
|
||||||
|
job, err := h.Service.EnqueueUserSync(c.Context(), strings.TrimSpace(c.Params("tenantId")), userID, credentialBatchID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return worksmobileGuardError(c, err, "sync_user", "user_id", userID)
|
return worksmobileGuardError(c, err, "sync_user", "user_id", userID)
|
||||||
}
|
}
|
||||||
return c.Status(fiber.StatusAccepted).JSON(job)
|
return c.Status(fiber.StatusAccepted).JSON(job)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *WorksmobileHandler) ResetUserPassword(c *fiber.Ctx) error {
|
||||||
|
userID := strings.TrimSpace(c.Params("userId"))
|
||||||
|
credentialBatchID, err := parseWorksmobileCredentialBatchID(c)
|
||||||
|
if err != nil {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||||
|
}
|
||||||
|
job, err := h.Service.EnqueueUserPasswordReset(c.Context(), strings.TrimSpace(c.Params("tenantId")), userID, credentialBatchID)
|
||||||
|
if err != nil {
|
||||||
|
return worksmobileGuardError(c, err, "reset_user_password", "user_id", userID)
|
||||||
|
}
|
||||||
|
return c.Status(fiber.StatusAccepted).JSON(job)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *WorksmobileHandler) RetryJob(c *fiber.Ctx) error {
|
func (h *WorksmobileHandler) RetryJob(c *fiber.Ctx) error {
|
||||||
jobID := strings.TrimSpace(c.Params("jobId"))
|
jobID := strings.TrimSpace(c.Params("jobId"))
|
||||||
job, err := h.Service.RetryJob(c.Context(), strings.TrimSpace(c.Params("tenantId")), jobID)
|
job, err := h.Service.RetryJob(c.Context(), strings.TrimSpace(c.Params("tenantId")), jobID)
|
||||||
@@ -89,18 +106,18 @@ func (h *WorksmobileHandler) RetryJob(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *WorksmobileHandler) DownloadInitialPasswordsCSV(c *fiber.Ctx) error {
|
func (h *WorksmobileHandler) DownloadInitialPasswordsCSV(c *fiber.Ctx) error {
|
||||||
credentials, err := h.Service.ListInitialPasswordCredentials(c.Context(), strings.TrimSpace(c.Params("tenantId")))
|
credentials, err := h.Service.ListInitialPasswordCredentials(c.Context(), strings.TrimSpace(c.Params("tenantId")), strings.TrimSpace(c.Query("batchId")))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return worksmobileGuardError(c, err, "download_initial_passwords")
|
return worksmobileGuardError(c, err, "download_initial_passwords")
|
||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
writer := csv.NewWriter(&buf)
|
writer := csv.NewWriter(&buf)
|
||||||
if err := writer.Write([]string{"email", "initialPassword", "status", "lastError"}); err != nil {
|
if err := writer.Write([]string{"email", "name", "primaryLeafOrgName", "initialPassword", "status", "lastError"}); err != nil {
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
for _, credential := range credentials {
|
for _, credential := range credentials {
|
||||||
if err := writer.Write([]string{credential.Email, credential.InitialPassword, credential.Status, credential.LastError}); err != nil {
|
if err := writer.Write([]string{credential.Email, credential.Name, credential.PrimaryLeafOrgName, credential.InitialPassword, credential.Status, credential.LastError}); err != nil {
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,6 +131,42 @@ func (h *WorksmobileHandler) DownloadInitialPasswordsCSV(c *fiber.Ctx) error {
|
|||||||
return c.Send(buf.Bytes())
|
return c.Send(buf.Bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *WorksmobileHandler) ListCredentialBatches(c *fiber.Ctx) error {
|
||||||
|
batches, err := h.Service.ListCredentialBatches(c.Context(), strings.TrimSpace(c.Params("tenantId")))
|
||||||
|
if err != nil {
|
||||||
|
return worksmobileGuardError(c, err, "list_credential_batches")
|
||||||
|
}
|
||||||
|
return c.JSON(batches)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WorksmobileHandler) DeleteCredentialBatchPasswords(c *fiber.Ctx) error {
|
||||||
|
batchID := strings.TrimSpace(c.Params("batchId"))
|
||||||
|
batch, err := h.Service.DeleteCredentialBatchPasswords(c.Context(), strings.TrimSpace(c.Params("tenantId")), batchID)
|
||||||
|
if err != nil {
|
||||||
|
return worksmobileGuardError(c, err, "delete_credential_batch_passwords", "batch_id", batchID)
|
||||||
|
}
|
||||||
|
return c.JSON(batch)
|
||||||
|
}
|
||||||
|
|
||||||
|
type worksmobileCredentialBatchRequest struct {
|
||||||
|
CredentialBatchID string `json:"credentialBatchId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseWorksmobileCredentialBatchID(c *fiber.Ctx) (string, error) {
|
||||||
|
batchID := strings.TrimSpace(c.Query("credentialBatchId"))
|
||||||
|
if len(bytes.TrimSpace(c.Body())) == 0 {
|
||||||
|
return batchID, nil
|
||||||
|
}
|
||||||
|
var req worksmobileCredentialBatchRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if bodyBatchID := strings.TrimSpace(req.CredentialBatchID); bodyBatchID != "" {
|
||||||
|
return bodyBatchID, nil
|
||||||
|
}
|
||||||
|
return batchID, nil
|
||||||
|
}
|
||||||
|
|
||||||
func worksmobileOverviewAllowed(overview service.WorksmobileTenantOverview) bool {
|
func worksmobileOverviewAllowed(overview service.WorksmobileTenantOverview) bool {
|
||||||
return overview.Tenant.Slug == service.HanmacFamilyTenantSlug && overview.Tenant.ParentID == nil
|
return overview.Tenant.Slug == service.HanmacFamilyTenantSlug && overview.Tenant.ParentID == nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
@@ -51,7 +52,13 @@ func TestWorksmobileHandlerReturnsOverviewForHanmacTenant(t *testing.T) {
|
|||||||
func TestWorksmobileHandlerDownloadsInitialPasswordCSV(t *testing.T) {
|
func TestWorksmobileHandlerDownloadsInitialPasswordCSV(t *testing.T) {
|
||||||
h := NewWorksmobileHandler(&fakeWorksmobileAdminService{
|
h := NewWorksmobileHandler(&fakeWorksmobileAdminService{
|
||||||
credentials: []service.WorksmobileInitialPasswordCredential{
|
credentials: []service.WorksmobileInitialPasswordCredential{
|
||||||
{Email: "user@hanmaceng.co.kr", InitialPassword: "Aa1!Aa1!Aa1!Aa1!", Status: "processed"},
|
{
|
||||||
|
Email: "user@hanmaceng.co.kr",
|
||||||
|
Name: "홍길동",
|
||||||
|
PrimaryLeafOrgName: "인재성장",
|
||||||
|
InitialPassword: "Aa1!Aa1!Aa1!Aa1!",
|
||||||
|
Status: "processed",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
@@ -63,8 +70,87 @@ func TestWorksmobileHandlerDownloadsInitialPasswordCSV(t *testing.T) {
|
|||||||
require.Contains(t, resp.Header.Get("Content-Disposition"), "worksmobile_initial_passwords.csv")
|
require.Contains(t, resp.Header.Get("Content-Disposition"), "worksmobile_initial_passwords.csv")
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Contains(t, string(body), "email,initialPassword,status,lastError")
|
require.Contains(t, string(body), "email,name,primaryLeafOrgName,initialPassword,status,lastError")
|
||||||
require.Contains(t, string(body), "user@hanmaceng.co.kr,Aa1!Aa1!Aa1!Aa1!,processed,")
|
require.Contains(t, string(body), "user@hanmaceng.co.kr,홍길동,인재성장,Aa1!Aa1!Aa1!Aa1!,processed,")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorksmobileHandlerPassesInitialPasswordBatchID(t *testing.T) {
|
||||||
|
fakeService := &fakeWorksmobileAdminService{
|
||||||
|
credentials: []service.WorksmobileInitialPasswordCredential{
|
||||||
|
{Email: "batch-user@hanmaceng.co.kr", InitialPassword: "BatchPass1!", Status: "pending"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := NewWorksmobileHandler(fakeService)
|
||||||
|
app := fiber.New()
|
||||||
|
app.Get("/tenants/:tenantId/worksmobile/initial-passwords.csv", h.DownloadInitialPasswordsCSV)
|
||||||
|
|
||||||
|
resp, err := app.Test(httptest.NewRequest("GET", "/tenants/hanmac-id/worksmobile/initial-passwords.csv?batchId=batch-1", nil))
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, fiber.StatusOK, resp.StatusCode)
|
||||||
|
require.Equal(t, "batch-1", fakeService.downloadCredentialBatchID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorksmobileHandlerPassesSyncUserCredentialBatchID(t *testing.T) {
|
||||||
|
fakeService := &fakeWorksmobileAdminService{}
|
||||||
|
h := NewWorksmobileHandler(fakeService)
|
||||||
|
app := fiber.New()
|
||||||
|
app.Post("/tenants/:tenantId/worksmobile/users/:userId/sync", h.SyncUser)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/tenants/hanmac-id/worksmobile/users/user-1/sync", strings.NewReader(`{"credentialBatchId":"batch-1"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, fiber.StatusAccepted, resp.StatusCode)
|
||||||
|
require.Equal(t, "batch-1", fakeService.syncUserCredentialBatchID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorksmobileHandlerPassesPasswordResetCredentialBatchID(t *testing.T) {
|
||||||
|
fakeService := &fakeWorksmobileAdminService{}
|
||||||
|
h := NewWorksmobileHandler(fakeService)
|
||||||
|
app := fiber.New()
|
||||||
|
app.Post("/tenants/:tenantId/worksmobile/users/:userId/password/reset", h.ResetUserPassword)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/tenants/hanmac-id/worksmobile/users/user-1/password/reset", strings.NewReader(`{"credentialBatchId":"batch-1"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, fiber.StatusAccepted, resp.StatusCode)
|
||||||
|
require.Equal(t, "batch-1", fakeService.resetPasswordCredentialBatchID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorksmobileHandlerReturnsCredentialBatchHistory(t *testing.T) {
|
||||||
|
h := NewWorksmobileHandler(&fakeWorksmobileAdminService{
|
||||||
|
credentialBatches: []service.WorksmobileCredentialBatch{
|
||||||
|
{BatchID: "batch-1", UserCount: 2, HasPasswords: true},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
app := fiber.New()
|
||||||
|
app.Get("/tenants/:tenantId/worksmobile/credential-batches", h.ListCredentialBatches)
|
||||||
|
|
||||||
|
resp, err := app.Test(httptest.NewRequest("GET", "/tenants/hanmac-id/worksmobile/credential-batches", nil))
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, fiber.StatusOK, resp.StatusCode)
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, string(body), `"batchId":"batch-1"`)
|
||||||
|
require.Contains(t, string(body), `"userCount":2`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorksmobileHandlerDeletesCredentialBatchPasswords(t *testing.T) {
|
||||||
|
fakeService := &fakeWorksmobileAdminService{}
|
||||||
|
h := NewWorksmobileHandler(fakeService)
|
||||||
|
app := fiber.New()
|
||||||
|
app.Delete("/tenants/:tenantId/worksmobile/credential-batches/:batchId/passwords", h.DeleteCredentialBatchPasswords)
|
||||||
|
|
||||||
|
resp, err := app.Test(httptest.NewRequest("DELETE", "/tenants/hanmac-id/worksmobile/credential-batches/batch-1/passwords", nil))
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, fiber.StatusOK, resp.StatusCode)
|
||||||
|
require.Equal(t, "batch-1", fakeService.deletedCredentialBatchID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWorksmobileHandlerLogsActionFailures(t *testing.T) {
|
func TestWorksmobileHandlerLogsActionFailures(t *testing.T) {
|
||||||
@@ -91,9 +177,14 @@ func TestWorksmobileHandlerLogsActionFailures(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type fakeWorksmobileAdminService struct {
|
type fakeWorksmobileAdminService struct {
|
||||||
overview service.WorksmobileTenantOverview
|
overview service.WorksmobileTenantOverview
|
||||||
credentials []service.WorksmobileInitialPasswordCredential
|
credentials []service.WorksmobileInitialPasswordCredential
|
||||||
syncUserErr error
|
syncUserErr error
|
||||||
|
syncUserCredentialBatchID string
|
||||||
|
resetPasswordCredentialBatchID string
|
||||||
|
downloadCredentialBatchID string
|
||||||
|
deletedCredentialBatchID string
|
||||||
|
credentialBatches []service.WorksmobileCredentialBatch
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeWorksmobileAdminService) GetTenantOverview(ctx context.Context, tenantID string) (service.WorksmobileTenantOverview, error) {
|
func (f *fakeWorksmobileAdminService) GetTenantOverview(ctx context.Context, tenantID string) (service.WorksmobileTenantOverview, error) {
|
||||||
@@ -116,17 +207,33 @@ func (f *fakeWorksmobileAdminService) EnqueueOrgUnitDelete(ctx context.Context,
|
|||||||
return &domain.WorksmobileOutbox{ID: "job-orgunit-delete", ResourceID: orgUnitID, Action: domain.WorksmobileActionDelete}, nil
|
return &domain.WorksmobileOutbox{ID: "job-orgunit-delete", ResourceID: orgUnitID, Action: domain.WorksmobileActionDelete}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeWorksmobileAdminService) EnqueueUserSync(ctx context.Context, tenantID, userID string) (*domain.WorksmobileOutbox, error) {
|
func (f *fakeWorksmobileAdminService) EnqueueUserSync(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) {
|
||||||
|
f.syncUserCredentialBatchID = credentialBatchID
|
||||||
if f.syncUserErr != nil {
|
if f.syncUserErr != nil {
|
||||||
return nil, f.syncUserErr
|
return nil, f.syncUserErr
|
||||||
}
|
}
|
||||||
return &domain.WorksmobileOutbox{ID: "job-user", ResourceID: userID}, nil
|
return &domain.WorksmobileOutbox{ID: "job-user", ResourceID: userID}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeWorksmobileAdminService) EnqueueUserPasswordReset(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) {
|
||||||
|
f.resetPasswordCredentialBatchID = credentialBatchID
|
||||||
|
return &domain.WorksmobileOutbox{ID: "job-user-password-reset", ResourceID: userID}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeWorksmobileAdminService) RetryJob(ctx context.Context, tenantID, jobID string) (*domain.WorksmobileOutbox, error) {
|
func (f *fakeWorksmobileAdminService) RetryJob(ctx context.Context, tenantID, jobID string) (*domain.WorksmobileOutbox, error) {
|
||||||
return &domain.WorksmobileOutbox{ID: jobID}, nil
|
return &domain.WorksmobileOutbox{ID: jobID}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeWorksmobileAdminService) ListInitialPasswordCredentials(ctx context.Context, tenantID string) ([]service.WorksmobileInitialPasswordCredential, error) {
|
func (f *fakeWorksmobileAdminService) ListInitialPasswordCredentials(ctx context.Context, tenantID, credentialBatchID string) ([]service.WorksmobileInitialPasswordCredential, error) {
|
||||||
|
f.downloadCredentialBatchID = credentialBatchID
|
||||||
return f.credentials, nil
|
return f.credentials, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeWorksmobileAdminService) ListCredentialBatches(ctx context.Context, tenantID string) ([]service.WorksmobileCredentialBatch, error) {
|
||||||
|
return f.credentialBatches, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeWorksmobileAdminService) DeleteCredentialBatchPasswords(ctx context.Context, tenantID, credentialBatchID string) (service.WorksmobileCredentialBatch, error) {
|
||||||
|
f.deletedCredentialBatchID = credentialBatchID
|
||||||
|
return service.WorksmobileCredentialBatch{BatchID: credentialBatchID}, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import (
|
|||||||
type WorksmobileOutboxRepository interface {
|
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)
|
||||||
|
ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error)
|
||||||
|
UpdatePayload(ctx context.Context, id string, payload domain.JSONMap) error
|
||||||
ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error)
|
ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error)
|
||||||
FindByID(ctx context.Context, id string) (*domain.WorksmobileOutbox, error)
|
FindByID(ctx context.Context, id string) (*domain.WorksmobileOutbox, error)
|
||||||
MarkRetry(ctx context.Context, id string) error
|
MarkRetry(ctx context.Context, id string) error
|
||||||
@@ -56,6 +58,24 @@ func (r *worksmobileOutboxRepository) ListRecent(ctx context.Context, limit int)
|
|||||||
return rows, err
|
return rows, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *worksmobileOutboxRepository) ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error) {
|
||||||
|
query := r.db.WithContext(ctx).
|
||||||
|
Where("resource_type = ? AND payload ->> 'tenantRootId' = ? AND coalesce(payload ->> 'credentialBatchId', '') <> ?", domain.WorksmobileResourceUser, tenantRootID, "")
|
||||||
|
if credentialBatchID != "" {
|
||||||
|
query = query.Where("payload ->> 'credentialBatchId' = ?", credentialBatchID)
|
||||||
|
}
|
||||||
|
var rows []domain.WorksmobileOutbox
|
||||||
|
err := query.Order("created_at desc").Find(&rows).Error
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *worksmobileOutboxRepository) UpdatePayload(ctx context.Context, id string, payload domain.JSONMap) error {
|
||||||
|
return r.db.WithContext(ctx).Model(&domain.WorksmobileOutbox{}).Where("id = ?", id).Updates(map[string]any{
|
||||||
|
"payload": payload,
|
||||||
|
"updated_at": time.Now(),
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
func (r *worksmobileOutboxRepository) ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) {
|
func (r *worksmobileOutboxRepository) ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) {
|
||||||
if limit <= 0 || limit > 100 {
|
if limit <= 0 || limit > 100 {
|
||||||
limit = 20
|
limit = 20
|
||||||
|
|||||||
@@ -290,6 +290,9 @@ func (s *kratosAdminService) CreateUser(ctx context.Context, user *domain.Broker
|
|||||||
},
|
},
|
||||||
"state": "active",
|
"state": "active",
|
||||||
}
|
}
|
||||||
|
if requestedID := strings.TrimSpace(user.ID); requestedID != "" {
|
||||||
|
payload["id"] = requestedID
|
||||||
|
}
|
||||||
|
|
||||||
body, _ := json.Marshal(payload)
|
body, _ := json.Marshal(payload)
|
||||||
endpoint := strings.TrimRight(s.AdminURL, "/") + "/admin/identities"
|
endpoint := strings.TrimRight(s.AdminURL, "/") + "/admin/identities"
|
||||||
@@ -316,6 +319,9 @@ func (s *kratosAdminService) CreateUser(ctx context.Context, user *domain.Broker
|
|||||||
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
if requestedID := strings.TrimSpace(user.ID); requestedID != "" && created.ID != requestedID {
|
||||||
|
return "", fmt.Errorf("kratos admin: requested identity id was not preserved requested=%s actual=%s", requestedID, created.ID)
|
||||||
|
}
|
||||||
|
|
||||||
return created.ID, nil
|
return created.ID, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,6 +134,9 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if requestedID := strings.TrimSpace(user.ID); requestedID != "" {
|
||||||
|
payload["id"] = requestedID
|
||||||
|
}
|
||||||
verifiable := []map[string]interface{}{
|
verifiable := []map[string]interface{}{
|
||||||
{
|
{
|
||||||
"value": user.Email,
|
"value": user.Email,
|
||||||
@@ -179,6 +182,9 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
|
|||||||
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
|
||||||
return "", fmt.Errorf("ory provider: decode create identity response failed: %w", err)
|
return "", fmt.Errorf("ory provider: decode create identity response failed: %w", err)
|
||||||
}
|
}
|
||||||
|
if requestedID := strings.TrimSpace(user.ID); requestedID != "" && created.ID != requestedID {
|
||||||
|
return "", fmt.Errorf("ory provider: requested identity id was not preserved requested=%s actual=%s", requestedID, created.ID)
|
||||||
|
}
|
||||||
|
|
||||||
slog.Info("Ory identity created", "identity_id", created.ID, "email", user.Email)
|
slog.Info("Ory identity created", "identity_id", created.ID, "email", user.Email)
|
||||||
return created.ID, nil
|
return created.ID, nil
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
@@ -35,6 +36,76 @@ type roundTripperFunc func(req *http.Request) (*http.Response, error)
|
|||||||
|
|
||||||
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
|
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
|
||||||
|
|
||||||
|
func TestCreateUserSendsRequestedIdentityID(t *testing.T) {
|
||||||
|
const requestedID = "9f8cc1b1-af8d-45d4-946c-924a529c2556"
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/admin/identities" && r.Method == http.MethodGet:
|
||||||
|
_ = json.NewEncoder(w).Encode([]map[string]string{})
|
||||||
|
return
|
||||||
|
case r.URL.Path == "/admin/identities" && r.Method == http.MethodPost:
|
||||||
|
var payload map[string]interface{}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("failed to decode payload: %v", err)
|
||||||
|
}
|
||||||
|
if payload["id"] != requestedID {
|
||||||
|
t.Fatalf("expected id=%s, got=%v", requestedID, payload["id"])
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"id": requestedID})
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
provider := &OryProvider{
|
||||||
|
KratosAdminURL: "http://kratos-admin.local",
|
||||||
|
HTTPClient: clientForHandler(handler),
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := provider.CreateUser(&domain.BrokerUser{
|
||||||
|
ID: requestedID,
|
||||||
|
Email: "restore@test.com",
|
||||||
|
Name: "Restore User",
|
||||||
|
}, "Sup3rStr0ng!Pass#2026")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser returned error: %v", err)
|
||||||
|
}
|
||||||
|
if id != requestedID {
|
||||||
|
t.Fatalf("expected %s, got %s", requestedID, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateUserRejectsRequestedIdentityIDMismatch(t *testing.T) {
|
||||||
|
const requestedID = "9f8cc1b1-af8d-45d4-946c-924a529c2556"
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/admin/identities" && r.Method == http.MethodGet:
|
||||||
|
_ = json.NewEncoder(w).Encode([]map[string]string{})
|
||||||
|
return
|
||||||
|
case r.URL.Path == "/admin/identities" && r.Method == http.MethodPost:
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"id": "generated-id"})
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
provider := &OryProvider{
|
||||||
|
KratosAdminURL: "http://kratos-admin.local",
|
||||||
|
HTTPClient: clientForHandler(handler),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := provider.CreateUser(&domain.BrokerUser{
|
||||||
|
ID: requestedID,
|
||||||
|
Email: "restore@test.com",
|
||||||
|
Name: "Restore User",
|
||||||
|
}, "Sup3rStr0ng!Pass#2026")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "requested identity id was not preserved") {
|
||||||
|
t.Fatalf("expected requested identity id mismatch error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUpdateUserPassword_Success(t *testing.T) {
|
func TestUpdateUserPassword_Success(t *testing.T) {
|
||||||
const (
|
const (
|
||||||
loginID = "user@example.com"
|
loginID = "user@example.com"
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ type WorksmobileDirectoryClient interface {
|
|||||||
DeleteOrgUnit(ctx context.Context, orgUnitID string) error
|
DeleteOrgUnit(ctx context.Context, orgUnitID string) error
|
||||||
CreateUser(ctx context.Context, payload WorksmobileUserPayload) error
|
CreateUser(ctx context.Context, payload WorksmobileUserPayload) error
|
||||||
UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error
|
UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error
|
||||||
|
AddUserAliasEmail(ctx context.Context, userID string, email string) error
|
||||||
|
ResetUserPassword(ctx context.Context, userID string, password string) error
|
||||||
DeleteUser(ctx context.Context, userID string) error
|
DeleteUser(ctx context.Context, userID string) error
|
||||||
SetUserActive(ctx context.Context, userID string, active bool) error
|
SetUserActive(ctx context.Context, userID string, active bool) error
|
||||||
ListUsers(ctx context.Context) ([]WorksmobileRemoteUser, error)
|
ListUsers(ctx context.Context) ([]WorksmobileRemoteUser, error)
|
||||||
@@ -283,6 +285,45 @@ func (c *WorksmobileHTTPClient) UpsertUser(ctx context.Context, payload Worksmob
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *WorksmobileHTTPClient) AddUserAliasEmail(ctx context.Context, userID string, email string) error {
|
||||||
|
userID = strings.TrimSpace(userID)
|
||||||
|
email = strings.TrimSpace(email)
|
||||||
|
if userID == "" {
|
||||||
|
return fmt.Errorf("worksmobile user id is required")
|
||||||
|
}
|
||||||
|
if email == "" {
|
||||||
|
return fmt.Errorf("worksmobile alias email is required")
|
||||||
|
}
|
||||||
|
err := c.sendDirectoryJSON(
|
||||||
|
ctx,
|
||||||
|
http.MethodPost,
|
||||||
|
"/v1.0/users/"+url.PathEscape(userID)+"/alias-emails/"+url.PathEscape(email),
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if apiErr, ok := err.(WorksmobileHTTPError); ok && apiErr.StatusCode == http.StatusConflict {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WorksmobileHTTPClient) ResetUserPassword(ctx context.Context, userID string, password string) error {
|
||||||
|
userID = strings.TrimSpace(userID)
|
||||||
|
password = strings.TrimSpace(password)
|
||||||
|
if userID == "" {
|
||||||
|
return fmt.Errorf("worksmobile user id is required")
|
||||||
|
}
|
||||||
|
if password == "" {
|
||||||
|
return fmt.Errorf("worksmobile password is required")
|
||||||
|
}
|
||||||
|
payload := map[string]any{
|
||||||
|
"passwordConfig": WorksmobilePasswordConfig{
|
||||||
|
PasswordCreationType: "ADMIN",
|
||||||
|
Password: password,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return c.sendDirectoryJSON(ctx, http.MethodPatch, "/v1.0/users/"+url.PathEscape(userID), payload)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *WorksmobileHTTPClient) PatchUser(ctx context.Context, identifier string, payload WorksmobileUserPatchPayload) error {
|
func (c *WorksmobileHTTPClient) PatchUser(ctx context.Context, identifier string, payload WorksmobileUserPatchPayload) error {
|
||||||
identifier = strings.TrimSpace(identifier)
|
identifier = strings.TrimSpace(identifier)
|
||||||
if identifier == "" {
|
if identifier == "" {
|
||||||
@@ -756,22 +797,23 @@ type WorksmobileOrgUnitPatchPayload struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type WorksmobileRemoteUser struct {
|
type WorksmobileRemoteUser struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ExternalID string `json:"externalId"`
|
ExternalID string `json:"externalId"`
|
||||||
UserName string `json:"userName"`
|
UserName string `json:"userName"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
LevelID string `json:"levelId"`
|
LevelID string `json:"levelId"`
|
||||||
LevelName string `json:"levelName"`
|
LevelName string `json:"levelName"`
|
||||||
Task string `json:"task"`
|
Task string `json:"task"`
|
||||||
DomainID int64 `json:"domainId"`
|
DomainID int64 `json:"domainId"`
|
||||||
DomainName string `json:"domainName"`
|
DomainName string `json:"domainName"`
|
||||||
PrimaryOrgUnitID string `json:"primaryOrgUnitId"`
|
PrimaryOrgUnitID string `json:"primaryOrgUnitId"`
|
||||||
PrimaryOrgUnitName string `json:"primaryOrgUnitName"`
|
PrimaryOrgUnitName string `json:"primaryOrgUnitName"`
|
||||||
PrimaryOrgUnitPositionID string `json:"primaryOrgUnitPositionId"`
|
PrimaryOrgUnitPositionID string `json:"primaryOrgUnitPositionId"`
|
||||||
PrimaryOrgUnitPositionName string `json:"primaryOrgUnitPositionName"`
|
PrimaryOrgUnitPositionName string `json:"primaryOrgUnitPositionName"`
|
||||||
PrimaryOrgUnitIsManager *bool `json:"primaryOrgUnitIsManager,omitempty"`
|
PrimaryOrgUnitIsManager *bool `json:"primaryOrgUnitIsManager,omitempty"`
|
||||||
Active bool `json:"active"`
|
OrgUnitManagers map[string]*bool `json:"orgUnitManagers,omitempty"`
|
||||||
|
Active bool `json:"active"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorksmobileRemoteGroup struct {
|
type WorksmobileRemoteGroup struct {
|
||||||
@@ -907,6 +949,7 @@ func parseWorksmobileDirectoryUser(resource map[string]any) WorksmobileRemoteUse
|
|||||||
user.PrimaryOrgUnitPositionID = primaryOrgUnit.PositionID
|
user.PrimaryOrgUnitPositionID = primaryOrgUnit.PositionID
|
||||||
user.PrimaryOrgUnitPositionName = primaryOrgUnit.PositionName
|
user.PrimaryOrgUnitPositionName = primaryOrgUnit.PositionName
|
||||||
user.PrimaryOrgUnitIsManager = primaryOrgUnit.IsManager
|
user.PrimaryOrgUnitIsManager = primaryOrgUnit.IsManager
|
||||||
|
user.OrgUnitManagers = parseWorksmobileOrgUnitManagers(resource)
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1029,6 +1072,43 @@ func parseWorksmobilePrimaryOrgUnitDetail(resource map[string]any) worksmobileOr
|
|||||||
return worksmobileOrgUnitDetail{}
|
return worksmobileOrgUnitDetail{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseWorksmobileOrgUnitManagers(resource map[string]any) map[string]*bool {
|
||||||
|
result := map[string]*bool{}
|
||||||
|
collectWorksmobileOrgUnitManagers(resource["organizations"], result)
|
||||||
|
collectWorksmobileOrgUnitManagers(resource["orgUnits"], result)
|
||||||
|
for key, raw := range resource {
|
||||||
|
if !strings.Contains(strings.ToLower(key), "works") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if values, ok := raw.(map[string]any); ok {
|
||||||
|
collectWorksmobileOrgUnitManagers(values["organizations"], result)
|
||||||
|
collectWorksmobileOrgUnitManagers(values["orgUnits"], result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(result) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectWorksmobileOrgUnitManagers(raw any, result map[string]*bool) {
|
||||||
|
values, ok := raw.([]any)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, item := range values {
|
||||||
|
orgUnit, ok := item.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if id := firstStringFromMap(orgUnit, "orgUnitId", "id", "value"); id != "" {
|
||||||
|
result[id] = boolPointerFromMap(orgUnit, "isManager", "manager")
|
||||||
|
}
|
||||||
|
collectWorksmobileOrgUnitManagers(orgUnit["organizations"], result)
|
||||||
|
collectWorksmobileOrgUnitManagers(orgUnit["orgUnits"], result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func parseWorksmobileParentOrgUnit(resource map[string]any) (string, string) {
|
func parseWorksmobileParentOrgUnit(resource map[string]any) (string, string) {
|
||||||
id := firstStringFromMap(resource, "parentOrgUnitId", "parentId")
|
id := firstStringFromMap(resource, "parentOrgUnitId", "parentId")
|
||||||
name := firstStringFromMap(resource, "parentOrgUnitName", "parentName")
|
name := firstStringFromMap(resource, "parentOrgUnitName", "parentName")
|
||||||
|
|||||||
@@ -113,6 +113,50 @@ func TestWorksmobileHTTPClientUpsertUserPatchesOnCreateConflictWithoutPasswordOr
|
|||||||
require.Equal(t, "user-1", patchPayload["userExternalKey"])
|
require.Equal(t, "user-1", patchPayload["userExternalKey"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWorksmobileHTTPClientAddUserAliasEmailPostsDirectoryAliasEndpoint(t *testing.T) {
|
||||||
|
transport := &captureRoundTripper{
|
||||||
|
statusCode: http.StatusCreated,
|
||||||
|
body: `{}`,
|
||||||
|
}
|
||||||
|
client := &WorksmobileHTTPClient{
|
||||||
|
BaseURL: "https://works.example.test",
|
||||||
|
DirectoryToken: "directory-token-1",
|
||||||
|
HTTPClient: &http.Client{Transport: transport},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := client.AddUserAliasEmail(context.Background(), "ypshim@samaneng.com", "ypshim@hanmaceng.co.kr")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, transport.request)
|
||||||
|
require.Equal(t, http.MethodPost, transport.request.Method)
|
||||||
|
require.Equal(t, "/v1.0/users/ypshim@samaneng.com/alias-emails/ypshim@hanmaceng.co.kr", transport.request.URL.Path)
|
||||||
|
require.Equal(t, "Bearer directory-token-1", transport.request.Header.Get("Authorization"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorksmobileHTTPClientResetUserPasswordPatchesPasswordConfig(t *testing.T) {
|
||||||
|
transport := &captureRoundTripper{
|
||||||
|
statusCode: http.StatusOK,
|
||||||
|
body: `{}`,
|
||||||
|
}
|
||||||
|
client := &WorksmobileHTTPClient{
|
||||||
|
BaseURL: "https://works.example.test",
|
||||||
|
DirectoryToken: "directory-token-1",
|
||||||
|
HTTPClient: &http.Client{Transport: transport},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := client.ResetUserPassword(context.Background(), "target@samaneng.com", "Aa1!Aa1!Aa1!Aa1!")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, transport.request)
|
||||||
|
require.Equal(t, http.MethodPatch, transport.request.Method)
|
||||||
|
require.Equal(t, "/v1.0/users/target@samaneng.com", transport.request.URL.Path)
|
||||||
|
var payload map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(transport.requestBody, &payload))
|
||||||
|
passwordConfig := payload["passwordConfig"].(map[string]any)
|
||||||
|
require.Equal(t, "ADMIN", passwordConfig["passwordCreationType"])
|
||||||
|
require.Equal(t, "Aa1!Aa1!Aa1!Aa1!", passwordConfig["password"])
|
||||||
|
}
|
||||||
|
|
||||||
func TestWorksmobileHTTPClientCreateUserRequiresDirectoryToken(t *testing.T) {
|
func TestWorksmobileHTTPClientCreateUserRequiresDirectoryToken(t *testing.T) {
|
||||||
client := &WorksmobileHTTPClient{
|
client := &WorksmobileHTTPClient{
|
||||||
BaseURL: "https://works.example.test",
|
BaseURL: "https://works.example.test",
|
||||||
@@ -472,6 +516,71 @@ func TestWorksmobileRelayWorkerProcessesUserCreateAndMarksProcessed(t *testing.T
|
|||||||
require.Equal(t, "tester@samaneng.com", client.createdUsers[0].Email)
|
require.Equal(t, "tester@samaneng.com", client.createdUsers[0].Email)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWorksmobileRelayWorkerRegistersAliasEmailsAfterUserUpsert(t *testing.T) {
|
||||||
|
repo := &fakeWorksmobileOutboxRepo{
|
||||||
|
ready: []domain.WorksmobileOutbox{
|
||||||
|
{
|
||||||
|
ID: "job-1",
|
||||||
|
ResourceType: domain.WorksmobileResourceUser,
|
||||||
|
ResourceID: "user-1",
|
||||||
|
Action: domain.WorksmobileActionUpsert,
|
||||||
|
Status: domain.WorksmobileOutboxStatusPending,
|
||||||
|
Payload: worksmobileUserOutboxPayload("root-1", WorksmobileUserPayload{
|
||||||
|
Email: "ypshim@samaneng.com",
|
||||||
|
UserExternalKey: "user-1",
|
||||||
|
AliasEmails: []string{"ypshim@hanmaceng.co.kr"},
|
||||||
|
PasswordConfig: WorksmobilePasswordConfig{
|
||||||
|
PasswordCreationType: "ADMIN",
|
||||||
|
Password: "Aa1!Aa1!Aa1!Aa1!",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client := &fakeWorksmobileDirectoryClient{}
|
||||||
|
worker := NewWorksmobileRelayWorker(repo, client)
|
||||||
|
|
||||||
|
err := worker.ProcessOnce(context.Background())
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, []string{"job-1"}, repo.processedIDs)
|
||||||
|
require.Equal(t, "ypshim@samaneng.com", client.createdUsers[0].Email)
|
||||||
|
require.Empty(t, client.createdUsers[0].AliasEmails)
|
||||||
|
require.Equal(t, []string{"ypshim@samaneng.com:ypshim@hanmaceng.co.kr"}, client.aliasEmails)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorksmobileRelayWorkerProcessesUserPasswordResetAndMarksProcessed(t *testing.T) {
|
||||||
|
repo := &fakeWorksmobileOutboxRepo{
|
||||||
|
ready: []domain.WorksmobileOutbox{
|
||||||
|
{
|
||||||
|
ID: "job-reset",
|
||||||
|
ResourceType: domain.WorksmobileResourceUser,
|
||||||
|
ResourceID: "user-1",
|
||||||
|
Action: domain.WorksmobileActionPasswordReset,
|
||||||
|
Status: domain.WorksmobileOutboxStatusPending,
|
||||||
|
Payload: domain.JSONMap{
|
||||||
|
"loginEmail": "target@samaneng.com",
|
||||||
|
"request": map[string]any{
|
||||||
|
"email": "target@samaneng.com",
|
||||||
|
"passwordConfig": map[string]any{
|
||||||
|
"passwordCreationType": "ADMIN",
|
||||||
|
"password": "Aa1!Aa1!Aa1!Aa1!",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client := &fakeWorksmobileDirectoryClient{}
|
||||||
|
worker := NewWorksmobileRelayWorker(repo, client)
|
||||||
|
|
||||||
|
err := worker.ProcessOnce(context.Background())
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, []string{"job-reset"}, repo.processedIDs)
|
||||||
|
require.Equal(t, []string{"target@samaneng.com:Aa1!Aa1!Aa1!Aa1!"}, client.passwordResets)
|
||||||
|
}
|
||||||
|
|
||||||
func TestWorksmobileRelayWorkerProcessesUserSuspendAndMarksProcessed(t *testing.T) {
|
func TestWorksmobileRelayWorkerProcessesUserSuspendAndMarksProcessed(t *testing.T) {
|
||||||
repo := &fakeWorksmobileOutboxRepo{
|
repo := &fakeWorksmobileOutboxRepo{
|
||||||
ready: []domain.WorksmobileOutbox{
|
ready: []domain.WorksmobileOutbox{
|
||||||
@@ -615,7 +724,7 @@ func TestCompareWorksmobileUsersIncludesBaronAndWorksPrimaryOrg(t *testing.T) {
|
|||||||
require.Equal(t, "WORKS 기술기획", items[0].WorksmobilePrimaryOrgName)
|
require.Equal(t, "WORKS 기술기획", items[0].WorksmobilePrimaryOrgName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCompareWorksmobileUsersMatchesByEmailWhenDirectoryAPIOmitsExternalID(t *testing.T) {
|
func TestCompareWorksmobileUsersMarksEmailMatchWithoutExternalIDNeedsUpdate(t *testing.T) {
|
||||||
localUsers := []domain.User{
|
localUsers := []domain.User{
|
||||||
{ID: "user-1", Email: "tester@samaneng.com", Name: "Tester"},
|
{ID: "user-1", Email: "tester@samaneng.com", Name: "Tester"},
|
||||||
}
|
}
|
||||||
@@ -626,13 +735,37 @@ func TestCompareWorksmobileUsersMatchesByEmailWhenDirectoryAPIOmitsExternalID(t
|
|||||||
diffOnly := compareWorksmobileUsers(localUsers, remoteUsers, false, nil)
|
diffOnly := compareWorksmobileUsers(localUsers, remoteUsers, false, nil)
|
||||||
all := compareWorksmobileUsers(localUsers, remoteUsers, true, nil)
|
all := compareWorksmobileUsers(localUsers, remoteUsers, true, nil)
|
||||||
|
|
||||||
require.Empty(t, diffOnly)
|
require.Len(t, diffOnly, 1)
|
||||||
|
require.Equal(t, "needs_update", diffOnly[0].Status)
|
||||||
require.Len(t, all, 1)
|
require.Len(t, all, 1)
|
||||||
require.Equal(t, "matched", all[0].Status)
|
require.Equal(t, "needs_update", all[0].Status)
|
||||||
require.Equal(t, "works-1", all[0].WorksmobileID)
|
require.Equal(t, "works-1", all[0].WorksmobileID)
|
||||||
require.Empty(t, all[0].ExternalKey)
|
require.Empty(t, all[0].ExternalKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCompareWorksmobileUsersIncludesRecentFailedJobForMissingUser(t *testing.T) {
|
||||||
|
localUsers := []domain.User{
|
||||||
|
{ID: "user-1", Email: "missing@samaneng.com", Name: "Missing"},
|
||||||
|
}
|
||||||
|
jobSummaries := map[string]worksmobileUserJobSummary{
|
||||||
|
"user-1": {
|
||||||
|
Status: domain.WorksmobileOutboxStatusFailed,
|
||||||
|
RetryCount: 3,
|
||||||
|
LastError: "worksmobile api failed",
|
||||||
|
LastAttemptAt: "2026-06-01T05:00:00Z",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
items := compareWorksmobileUsers(localUsers, nil, false, nil, jobSummaries)
|
||||||
|
|
||||||
|
require.Len(t, items, 1)
|
||||||
|
require.Equal(t, "missing_in_worksmobile", items[0].Status)
|
||||||
|
require.Equal(t, domain.WorksmobileOutboxStatusFailed, items[0].WorksmobileJobStatus)
|
||||||
|
require.Equal(t, 3, items[0].WorksmobileJobRetryCount)
|
||||||
|
require.Equal(t, "worksmobile api failed", items[0].WorksmobileLastError)
|
||||||
|
require.Equal(t, "2026-06-01T05:00:00Z", items[0].WorksmobileLastAttemptAt)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCompareWorksmobileUsersIncludesWorksOnlyRowsWithoutExternalIDWhenIncludingMatched(t *testing.T) {
|
func TestCompareWorksmobileUsersIncludesWorksOnlyRowsWithoutExternalIDWhenIncludingMatched(t *testing.T) {
|
||||||
remoteUsers := []WorksmobileRemoteUser{
|
remoteUsers := []WorksmobileRemoteUser{
|
||||||
{ID: "works-1", ExternalID: "", Email: "works-only@samaneng.com", DisplayName: "Works Only"},
|
{ID: "works-1", ExternalID: "", Email: "works-only@samaneng.com", DisplayName: "Works Only"},
|
||||||
@@ -894,6 +1027,41 @@ func TestParseWorksmobileDirectoryUserIncludesFullNameLevelAndOrgRole(t *testing
|
|||||||
require.Equal(t, "팀장", user.PrimaryOrgUnitPositionName)
|
require.Equal(t, "팀장", user.PrimaryOrgUnitPositionName)
|
||||||
require.NotNil(t, user.PrimaryOrgUnitIsManager)
|
require.NotNil(t, user.PrimaryOrgUnitIsManager)
|
||||||
require.True(t, *user.PrimaryOrgUnitIsManager)
|
require.True(t, *user.PrimaryOrgUnitIsManager)
|
||||||
|
require.NotNil(t, user.OrgUnitManagers["works-org-1"])
|
||||||
|
require.True(t, *user.OrgUnitManagers["works-org-1"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseWorksmobileDirectoryUserIncludesAllOrgUnitManagerFlags(t *testing.T) {
|
||||||
|
user := parseWorksmobileDirectoryUser(map[string]any{
|
||||||
|
"userId": "works-user",
|
||||||
|
"email": "tester@samaneng.com",
|
||||||
|
"userName": map[string]any{
|
||||||
|
"lastName": "홍길동",
|
||||||
|
},
|
||||||
|
"organizations": []any{
|
||||||
|
map[string]any{
|
||||||
|
"primary": true,
|
||||||
|
"orgUnits": []any{
|
||||||
|
map[string]any{
|
||||||
|
"orgUnitId": "externalKey:primary-org",
|
||||||
|
"primary": true,
|
||||||
|
"isManager": false,
|
||||||
|
},
|
||||||
|
map[string]any{
|
||||||
|
"orgUnitId": "externalKey:secondary-org",
|
||||||
|
"primary": false,
|
||||||
|
"isManager": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Len(t, user.OrgUnitManagers, 2)
|
||||||
|
require.NotNil(t, user.OrgUnitManagers["externalKey:primary-org"])
|
||||||
|
require.False(t, *user.OrgUnitManagers["externalKey:primary-org"])
|
||||||
|
require.NotNil(t, user.OrgUnitManagers["externalKey:secondary-org"])
|
||||||
|
require.True(t, *user.OrgUnitManagers["externalKey:secondary-org"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseWorksmobileDirectoryGroupExtractsMailLocalPart(t *testing.T) {
|
func TestParseWorksmobileDirectoryGroupExtractsMailLocalPart(t *testing.T) {
|
||||||
@@ -908,11 +1076,14 @@ func TestParseWorksmobileDirectoryGroupExtractsMailLocalPart(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type fakeWorksmobileOutboxRepo struct {
|
type fakeWorksmobileOutboxRepo struct {
|
||||||
ready []domain.WorksmobileOutbox
|
recent []domain.WorksmobileOutbox
|
||||||
created []domain.WorksmobileOutbox
|
ready []domain.WorksmobileOutbox
|
||||||
processingIDs []string
|
created []domain.WorksmobileOutbox
|
||||||
processedIDs []string
|
credentialBatchJobs []domain.WorksmobileOutbox
|
||||||
failedIDs []string
|
payloadUpdates []domain.JSONMap
|
||||||
|
processingIDs []string
|
||||||
|
processedIDs []string
|
||||||
|
failedIDs []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeWorksmobileOutboxRepo) Create(ctx context.Context, item *domain.WorksmobileOutbox) error {
|
func (f *fakeWorksmobileOutboxRepo) Create(ctx context.Context, item *domain.WorksmobileOutbox) error {
|
||||||
@@ -921,7 +1092,31 @@ func (f *fakeWorksmobileOutboxRepo) Create(ctx context.Context, item *domain.Wor
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeWorksmobileOutboxRepo) ListRecent(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) {
|
func (f *fakeWorksmobileOutboxRepo) ListRecent(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) {
|
||||||
return nil, nil
|
return f.recent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeWorksmobileOutboxRepo) ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error) {
|
||||||
|
rows := make([]domain.WorksmobileOutbox, 0)
|
||||||
|
for _, row := range f.credentialBatchJobs {
|
||||||
|
if stringValue(row.Payload["tenantRootId"]) != tenantRootID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if credentialBatchID != "" && stringValue(row.Payload["credentialBatchId"]) != credentialBatchID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rows = append(rows, row)
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeWorksmobileOutboxRepo) UpdatePayload(ctx context.Context, id string, payload domain.JSONMap) error {
|
||||||
|
f.payloadUpdates = append(f.payloadUpdates, payload)
|
||||||
|
for i := range f.credentialBatchJobs {
|
||||||
|
if f.credentialBatchJobs[i].ID == id {
|
||||||
|
f.credentialBatchJobs[i].Payload = payload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeWorksmobileOutboxRepo) ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) {
|
func (f *fakeWorksmobileOutboxRepo) ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) {
|
||||||
@@ -958,6 +1153,8 @@ type fakeWorksmobileDirectoryClient struct {
|
|||||||
deletedUsers []string
|
deletedUsers []string
|
||||||
activeUsers []string
|
activeUsers []string
|
||||||
suspendedUsers []string
|
suspendedUsers []string
|
||||||
|
aliasEmails []string
|
||||||
|
passwordResets []string
|
||||||
users []WorksmobileRemoteUser
|
users []WorksmobileRemoteUser
|
||||||
orgUnitMatchKeys []string
|
orgUnitMatchKeys []string
|
||||||
groups []WorksmobileRemoteGroup
|
groups []WorksmobileRemoteGroup
|
||||||
@@ -1062,6 +1259,16 @@ func (f *fakeWorksmobileDirectoryClient) UpsertUser(ctx context.Context, payload
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeWorksmobileDirectoryClient) AddUserAliasEmail(ctx context.Context, userID string, email string) error {
|
||||||
|
f.aliasEmails = append(f.aliasEmails, userID+":"+email)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeWorksmobileDirectoryClient) ResetUserPassword(ctx context.Context, userID string, password string) error {
|
||||||
|
f.passwordResets = append(f.passwordResets, userID+":"+password)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeWorksmobileDirectoryClient) DeleteUser(ctx context.Context, userID string) error {
|
func (f *fakeWorksmobileDirectoryClient) DeleteUser(ctx context.Context, userID string) error {
|
||||||
f.deletedUsers = append(f.deletedUsers, userID)
|
f.deletedUsers = append(f.deletedUsers, userID)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ func TestWorksmobileLiveSamanUsersDirectoryProvisioning(t *testing.T) {
|
|||||||
require.NoError(t, outboxRepo.MarkProcessed(ctx, job.ID))
|
require.NoError(t, outboxRepo.MarkProcessed(ctx, job.ID))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
item, err := syncService.EnqueueUserSync(ctx, root.ID, user.ID)
|
item, err := syncService.EnqueueUserSync(ctx, root.ID, user.ID, "")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotEmpty(t, item)
|
require.NotEmpty(t, item)
|
||||||
require.NoError(t, outboxRepo.MarkRetry(ctx, job.ID))
|
require.NoError(t, outboxRepo.MarkRetry(ctx, job.ID))
|
||||||
@@ -70,7 +70,7 @@ func TestWorksmobileLiveSamanUsersDirectoryProvisioning(t *testing.T) {
|
|||||||
require.Equal(t, domain.WorksmobileOutboxStatusProcessed, processed.Status)
|
require.Equal(t, domain.WorksmobileOutboxStatusProcessed, processed.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
credentials, err := syncService.ListInitialPasswordCredentials(ctx, root.ID)
|
credentials, err := syncService.ListInitialPasswordCredentials(ctx, root.ID, "")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
for _, credential := range credentials {
|
for _, credential := range credentials {
|
||||||
|
|||||||
@@ -51,15 +51,21 @@ type WorksmobilePasswordConfig struct {
|
|||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WorksmobilePasswordResetPayload struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
PasswordConfig WorksmobilePasswordConfig `json:"passwordConfig"`
|
||||||
|
}
|
||||||
|
|
||||||
type WorksmobileUserOrganization struct {
|
type WorksmobileUserOrganization struct {
|
||||||
DomainID int64 `json:"domainId,omitempty"`
|
DomainID int64 `json:"domainId,omitempty"`
|
||||||
Primary bool `json:"primary,omitempty"`
|
Email string `json:"email,omitempty"`
|
||||||
|
Primary bool `json:"primary"`
|
||||||
OrgUnits []WorksmobileUserOrgUnit `json:"orgUnits"`
|
OrgUnits []WorksmobileUserOrgUnit `json:"orgUnits"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorksmobileUserOrgUnit struct {
|
type WorksmobileUserOrgUnit struct {
|
||||||
OrgUnitID string `json:"orgUnitId"`
|
OrgUnitID string `json:"orgUnitId"`
|
||||||
Primary bool `json:"primary,omitempty"`
|
Primary bool `json:"primary"`
|
||||||
PositionID string `json:"positionId,omitempty"`
|
PositionID string `json:"positionId,omitempty"`
|
||||||
IsManager *bool `json:"isManager,omitempty"`
|
IsManager *bool `json:"isManager,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -156,12 +162,11 @@ func BuildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain
|
|||||||
tenantByID = map[string]domain.Tenant{}
|
tenantByID = map[string]domain.Tenant{}
|
||||||
}
|
}
|
||||||
tenantByID[tenant.ID] = tenant
|
tenantByID[tenant.ID] = tenant
|
||||||
domainTenant := worksmobileDomainClassificationTenant(tenant, tenantByID)
|
domainID, err := ResolveWorksmobileAccountDomainIDFromEmail(user.Email, tenant, rootConfig)
|
||||||
domainID, err := ResolveWorksmobileDomainIDFromTenant(domainTenant, rootConfig)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return WorksmobileUserPayload{}, err
|
return WorksmobileUserPayload{}, err
|
||||||
}
|
}
|
||||||
employeeNumber := metadataString(user.Metadata, "employee_id", "employeeNumber", "employee_number")
|
employeeNumber := metadataEmployeeNumber(user.Metadata)
|
||||||
organizations, task, err := buildWorksmobileUserOrganizations(user, tenant, tenantByID, rootConfig)
|
organizations, task, err := buildWorksmobileUserOrganizations(user, tenant, tenantByID, rootConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return WorksmobileUserPayload{}, err
|
return WorksmobileUserPayload{}, err
|
||||||
@@ -202,28 +207,19 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
|
|||||||
if len(appointments) == 0 {
|
if len(appointments) == 0 {
|
||||||
appointments = []worksmobileAppointment{{TenantID: tenant.ID, IsPrimary: true}}
|
appointments = []worksmobileAppointment{{TenantID: tenant.ID, IsPrimary: true}}
|
||||||
}
|
}
|
||||||
primaryTenantID := metadataString(user.Metadata, "primaryTenantId", "primary_tenant_id")
|
accountDomainTenant := worksmobileAccountDomainTenantFromEmail(user.Email, tenant, tenantByID)
|
||||||
if primaryTenantID == "" && user.TenantID != nil {
|
accountDomainEnvKey := worksmobileTenantDomainIDEnvKey(accountDomainTenant)
|
||||||
primaryTenantID = *user.TenantID
|
if !worksmobileAppointmentsContainDomain(appointments, tenantByID, accountDomainEnvKey) && accountDomainTenant.ID != "" {
|
||||||
}
|
appointments = append([]worksmobileAppointment{{
|
||||||
hasPrimary := false
|
TenantID: accountDomainTenant.ID,
|
||||||
for i := range appointments {
|
IsPrimary: true,
|
||||||
if appointments[i].TenantID == primaryTenantID || appointments[i].IsPrimary {
|
JobTitle: strings.TrimSpace(user.JobTitle),
|
||||||
appointments[i].IsPrimary = true
|
PositionID: metadataString(user.Metadata, "worksmobilePositionId", "positionId", "position_id"),
|
||||||
hasPrimary = true
|
}}, appointments...)
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !hasPrimary {
|
|
||||||
for i := range appointments {
|
|
||||||
if appointments[i].TenantID == tenant.ID {
|
|
||||||
appointments[i].IsPrimary = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
organizations := make([]WorksmobileUserOrganization, 0, len(appointments))
|
organizations := make([]WorksmobileUserOrganization, 0)
|
||||||
|
organizationIndexByDomainID := map[int64]int{}
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
task := ""
|
task := ""
|
||||||
for _, appointment := range appointments {
|
for _, appointment := range appointments {
|
||||||
@@ -242,21 +238,34 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
isAccountDomain := worksmobileTenantDomainIDEnvKey(domainTenant) == accountDomainEnvKey
|
||||||
|
isPrimaryOrganization := isAccountDomain && !worksmobileOrganizationsHavePrimary(organizations)
|
||||||
|
organizationIndex, organizationExists := organizationIndexByDomainID[domainID]
|
||||||
orgUnit := WorksmobileUserOrgUnit{
|
orgUnit := WorksmobileUserOrgUnit{
|
||||||
OrgUnitID: "externalKey:" + appointmentTenant.ID,
|
OrgUnitID: "externalKey:" + appointmentTenant.ID,
|
||||||
Primary: appointment.IsPrimary,
|
Primary: !organizationExists,
|
||||||
PositionID: appointment.PositionID,
|
PositionID: appointment.PositionID,
|
||||||
}
|
}
|
||||||
if appointment.HasManager {
|
if appointment.HasManager {
|
||||||
isManager := appointment.IsManager
|
isManager := appointment.IsManager
|
||||||
orgUnit.IsManager = &isManager
|
orgUnit.IsManager = &isManager
|
||||||
}
|
}
|
||||||
organizations = append(organizations, WorksmobileUserOrganization{
|
if organizationExists {
|
||||||
DomainID: domainID,
|
if isPrimaryOrganization {
|
||||||
Primary: appointment.IsPrimary,
|
organizations[organizationIndex].Primary = true
|
||||||
OrgUnits: []WorksmobileUserOrgUnit{orgUnit},
|
organizations[organizationIndex].Email = worksmobileOrganizationEmail(user, domainTenant)
|
||||||
})
|
}
|
||||||
if appointment.IsPrimary && strings.TrimSpace(appointment.JobTitle) != "" {
|
organizations[organizationIndex].OrgUnits = append(organizations[organizationIndex].OrgUnits, orgUnit)
|
||||||
|
} else {
|
||||||
|
organizationIndexByDomainID[domainID] = len(organizations)
|
||||||
|
organizations = append(organizations, WorksmobileUserOrganization{
|
||||||
|
DomainID: domainID,
|
||||||
|
Email: worksmobileOrganizationEmail(user, domainTenant),
|
||||||
|
Primary: isPrimaryOrganization,
|
||||||
|
OrgUnits: []WorksmobileUserOrgUnit{orgUnit},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if isPrimaryOrganization && strings.TrimSpace(appointment.JobTitle) != "" {
|
||||||
task = strings.TrimSpace(appointment.JobTitle)
|
task = strings.TrimSpace(appointment.JobTitle)
|
||||||
}
|
}
|
||||||
seen[appointment.TenantID] = true
|
seen[appointment.TenantID] = true
|
||||||
@@ -264,10 +273,39 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
|
|||||||
if len(organizations) == 0 {
|
if len(organizations) == 0 {
|
||||||
return nil, "", errors.New("no valid worksmobile organization")
|
return nil, "", errors.New("no valid worksmobile organization")
|
||||||
}
|
}
|
||||||
|
if !worksmobileOrganizationsHavePrimary(organizations) {
|
||||||
|
organizations[0].Primary = true
|
||||||
|
if len(organizations[0].OrgUnits) > 0 {
|
||||||
|
organizations[0].OrgUnits[0].Primary = true
|
||||||
|
}
|
||||||
|
}
|
||||||
sortWorksmobileOrganizations(organizations)
|
sortWorksmobileOrganizations(organizations)
|
||||||
return organizations, task, nil
|
return organizations, task, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func worksmobileAppointmentsContainDomain(appointments []worksmobileAppointment, tenantByID map[string]domain.Tenant, envKey string) bool {
|
||||||
|
for _, appointment := range appointments {
|
||||||
|
tenant, ok := tenantByID[appointment.TenantID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
domainTenant := worksmobileDomainClassificationTenant(tenant, tenantByID)
|
||||||
|
if worksmobileTenantDomainIDEnvKey(domainTenant) == envKey {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func worksmobileOrganizationsHavePrimary(organizations []WorksmobileUserOrganization) bool {
|
||||||
|
for _, organization := range organizations {
|
||||||
|
if organization.Primary {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func worksmobileAppointmentsFromMetadata(metadata domain.JSONMap) []worksmobileAppointment {
|
func worksmobileAppointmentsFromMetadata(metadata domain.JSONMap) []worksmobileAppointment {
|
||||||
rawAppointments, ok := metadata["additionalAppointments"].([]any)
|
rawAppointments, ok := metadata["additionalAppointments"].([]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -326,7 +364,7 @@ func BuildWorksmobileAliasEmails(user domain.User, tenant domain.Tenant) []strin
|
|||||||
} {
|
} {
|
||||||
candidates = append(candidates, metadataStringList(user.Metadata, key)...)
|
candidates = append(candidates, metadataStringList(user.Metadata, key)...)
|
||||||
}
|
}
|
||||||
employeeNumber := metadataString(user.Metadata, "employee_id", "employeeNumber", "employee_number")
|
employeeNumber := metadataEmployeeNumber(user.Metadata)
|
||||||
if isHanmacWorksmobileTenant(tenant) && employeeNumber != "" {
|
if isHanmacWorksmobileTenant(tenant) && employeeNumber != "" {
|
||||||
candidates = append(candidates, employeeNumber+"@hanmaceng.co.kr")
|
candidates = append(candidates, employeeNumber+"@hanmaceng.co.kr")
|
||||||
}
|
}
|
||||||
@@ -351,26 +389,21 @@ func normalizeWorksmobileAliasEmails(primaryEmail string, candidates []string) [
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func ValidateWorksmobileAliasLocalParts(primaryEmail string, aliasEmails []string, existingLocalParts map[string]string) error {
|
func ValidateWorksmobileAliasEmails(primaryEmail string, aliasEmails []string, existingEmails map[string]string) error {
|
||||||
seen := map[string]string{}
|
seen := map[string]string{strings.ToLower(strings.TrimSpace(primaryEmail)): primaryEmail}
|
||||||
primaryLocalPart, err := domain.ExtractNormalizedEmailLocalPart(primaryEmail)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
seen[primaryLocalPart] = primaryEmail
|
|
||||||
|
|
||||||
for _, aliasEmail := range aliasEmails {
|
for _, aliasEmail := range aliasEmails {
|
||||||
localPart, err := domain.ExtractNormalizedEmailLocalPart(aliasEmail)
|
normalized := strings.ToLower(strings.TrimSpace(aliasEmail))
|
||||||
if err != nil {
|
if _, err := mail.ParseAddress(normalized); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if previous, ok := seen[localPart]; ok {
|
if previous, ok := seen[normalized]; ok {
|
||||||
return fmt.Errorf("worksmobile alias local-part duplicates %s: %s and %s", localPart, previous, aliasEmail)
|
return fmt.Errorf("worksmobile alias email duplicates: %s and %s", previous, aliasEmail)
|
||||||
}
|
}
|
||||||
if owner, ok := existingLocalParts[localPart]; ok {
|
if owner, ok := existingEmails[normalized]; ok {
|
||||||
return fmt.Errorf("worksmobile alias local-part %s는 이미 사용 중입니다: %s", localPart, owner)
|
return fmt.Errorf("worksmobile alias email %s는 이미 사용 중입니다: %s", normalized, owner)
|
||||||
}
|
}
|
||||||
seen[localPart] = aliasEmail
|
seen[normalized] = aliasEmail
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -446,6 +479,91 @@ func ResolveWorksmobileDomainIDFromTenant(tenant domain.Tenant, _ domain.JSONMap
|
|||||||
return 0, fmt.Errorf("worksmobile domain id env is missing for tenant: %s", envKey)
|
return 0, fmt.Errorf("worksmobile domain id env is missing for tenant: %s", envKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ResolveWorksmobileAccountDomainIDFromEmail(email string, fallbackTenant domain.Tenant, rootConfig domain.JSONMap) (int64, error) {
|
||||||
|
switch worksmobileEmailDomainName(email) {
|
||||||
|
case "samaneng.com":
|
||||||
|
if domainID, ok := worksmobileDomainIDFromEnv("SAMAN_DOMAIN_ID"); ok {
|
||||||
|
return domainID, nil
|
||||||
|
}
|
||||||
|
case "hanmaceng.co.kr":
|
||||||
|
if domainID, ok := worksmobileDomainIDFromEnv("HANMAC_DOMAIN_ID"); ok {
|
||||||
|
return domainID, nil
|
||||||
|
}
|
||||||
|
case "baroncs.co.kr":
|
||||||
|
if domainID, ok := worksmobileDomainIDFromEnv("GPDTDC_DOMAIN_ID"); ok {
|
||||||
|
return domainID, nil
|
||||||
|
}
|
||||||
|
case "brsw.kr":
|
||||||
|
if domainID, ok := worksmobileDomainIDFromEnv("BARONGROUP_DOMAIN_ID"); ok {
|
||||||
|
return domainID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ResolveWorksmobileDomainIDFromTenant(fallbackTenant, rootConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
func worksmobileAccountDomainTenantFromEmail(email string, fallbackTenant domain.Tenant, tenantByID map[string]domain.Tenant) domain.Tenant {
|
||||||
|
envKey := worksmobileDomainIDEnvKeyFromEmail(email)
|
||||||
|
for _, tenant := range tenantByID {
|
||||||
|
if isWorksmobileDomainRootTenant(tenant) && worksmobileTenantDomainIDEnvKey(tenant) == envKey {
|
||||||
|
return tenant
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, tenant := range tenantByID {
|
||||||
|
if worksmobileTenantDomainIDEnvKey(tenant) == envKey {
|
||||||
|
return worksmobileDomainClassificationTenant(tenant, tenantByID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return worksmobileDomainClassificationTenant(fallbackTenant, tenantByID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func worksmobileDomainIDEnvKeyFromEmail(email string) string {
|
||||||
|
switch worksmobileEmailDomainName(email) {
|
||||||
|
case "samaneng.com":
|
||||||
|
return "SAMAN_DOMAIN_ID"
|
||||||
|
case "hanmaceng.co.kr":
|
||||||
|
return "HANMAC_DOMAIN_ID"
|
||||||
|
case "baroncs.co.kr":
|
||||||
|
return "GPDTDC_DOMAIN_ID"
|
||||||
|
case "brsw.kr":
|
||||||
|
return "BARONGROUP_DOMAIN_ID"
|
||||||
|
default:
|
||||||
|
return worksmobileTenantDomainIDEnvKey(domain.Tenant{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func worksmobileEmailDomainName(email string) string {
|
||||||
|
address, err := mail.ParseAddress(strings.TrimSpace(email))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := strings.Split(address.Address, "@")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.ToLower(strings.TrimSpace(parts[1]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func worksmobileOrganizationEmail(user domain.User, domainTenant domain.Tenant) string {
|
||||||
|
domainName := worksmobileTenantMailDomain(domainTenant)
|
||||||
|
if domainName == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
primaryEmail := strings.ToLower(strings.TrimSpace(user.Email))
|
||||||
|
if worksmobileEmailDomainName(primaryEmail) == domainName {
|
||||||
|
return primaryEmail
|
||||||
|
}
|
||||||
|
for _, alias := range BuildWorksmobileAliasEmails(user, domainTenant) {
|
||||||
|
if worksmobileEmailDomainName(alias) == domainName {
|
||||||
|
return alias
|
||||||
|
}
|
||||||
|
}
|
||||||
|
localPart, err := domain.ExtractNormalizedEmailLocalPart(primaryEmail)
|
||||||
|
if err != nil || localPart == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return localPart + "@" + domainName
|
||||||
|
}
|
||||||
|
|
||||||
func worksmobileTenantDomainIDEnvKey(tenant domain.Tenant) string {
|
func worksmobileTenantDomainIDEnvKey(tenant domain.Tenant) string {
|
||||||
if tenantHasDomain(tenant, "samaneng.com") || tenantMatchesAny(tenant, "saman", "삼안") {
|
if tenantHasDomain(tenant, "samaneng.com") || tenantMatchesAny(tenant, "saman", "삼안") {
|
||||||
return "SAMAN_DOMAIN_ID"
|
return "SAMAN_DOMAIN_ID"
|
||||||
@@ -597,6 +715,70 @@ func metadataString(metadata domain.JSONMap, keys ...string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func metadataEmployeeNumber(metadata domain.JSONMap) string {
|
||||||
|
for _, key := range []string{"employee_id", "employeeNumber", "employee_number"} {
|
||||||
|
value, ok := metadata[key]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if normalized := normalizeMetadataEmployeeNumber(value); normalized != "" {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMetadataEmployeeNumber(value any) string {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case string:
|
||||||
|
return strings.TrimSpace(v)
|
||||||
|
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
|
||||||
|
return strings.TrimSpace(fmt.Sprint(v))
|
||||||
|
case map[string]any:
|
||||||
|
return normalizeMetadataCharacterMap(v)
|
||||||
|
case domain.JSONMap:
|
||||||
|
return normalizeMetadataCharacterMap(map[string]any(v))
|
||||||
|
case map[string]string:
|
||||||
|
converted := make(map[string]any, len(v))
|
||||||
|
for key, value := range v {
|
||||||
|
converted[key] = value
|
||||||
|
}
|
||||||
|
return normalizeMetadataCharacterMap(converted)
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMetadataCharacterMap(value map[string]any) string {
|
||||||
|
type characterEntry struct {
|
||||||
|
index int
|
||||||
|
value string
|
||||||
|
}
|
||||||
|
entries := make([]characterEntry, 0, len(value))
|
||||||
|
for key, raw := range value {
|
||||||
|
index, err := strconv.Atoi(key)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
part, ok := raw.(string)
|
||||||
|
if !ok || part == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
entries = append(entries, characterEntry{index: index, value: part})
|
||||||
|
}
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
sort.Slice(entries, func(i, j int) bool {
|
||||||
|
return entries[i].index < entries[j].index
|
||||||
|
})
|
||||||
|
var builder strings.Builder
|
||||||
|
for _, entry := range entries {
|
||||||
|
builder.WriteString(entry.value)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(builder.String())
|
||||||
|
}
|
||||||
|
|
||||||
func metadataBool(metadata domain.JSONMap, keys ...string) bool {
|
func metadataBool(metadata domain.JSONMap, keys ...string) bool {
|
||||||
value, _ := metadataOptionalBool(metadata, keys...)
|
value, _ := metadataOptionalBool(metadata, keys...)
|
||||||
return value
|
return value
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
|
"encoding/json"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -134,6 +135,36 @@ func TestBuildWorksmobileUserPayloadMapsBaronUserAndPrimaryTenant(t *testing.T)
|
|||||||
require.Equal(t, "externalKey:"+tenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
|
require.Equal(t, "externalKey:"+tenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildWorksmobileUserPayloadNormalizesLegacyCharacterMapEmployeeID(t *testing.T) {
|
||||||
|
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||||
|
tenantID := "33333333-3333-3333-3333-333333333333"
|
||||||
|
user := domain.User{
|
||||||
|
ID: "44444444-4444-4444-4444-444444444444",
|
||||||
|
Email: "john1@samaneng.com",
|
||||||
|
Name: "John Doe",
|
||||||
|
TenantID: &tenantID,
|
||||||
|
Metadata: domain.JSONMap{
|
||||||
|
"employee_id": map[string]any{
|
||||||
|
"0": "j",
|
||||||
|
"1": "o",
|
||||||
|
"2": "h",
|
||||||
|
"3": "n",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
tenant := domain.Tenant{
|
||||||
|
ID: tenantID,
|
||||||
|
Slug: "saman",
|
||||||
|
Name: "Saman",
|
||||||
|
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := BuildWorksmobileUserPayload(user, tenant, nil)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "john", payload.EmployeeNumber)
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildWorksmobileUserPayloadMapsAdditionalAppointmentsToOrgUnits(t *testing.T) {
|
func TestBuildWorksmobileUserPayloadMapsAdditionalAppointmentsToOrgUnits(t *testing.T) {
|
||||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||||
t.Setenv("HANMAC_DOMAIN_ID", "1002")
|
t.Setenv("HANMAC_DOMAIN_ID", "1002")
|
||||||
@@ -198,7 +229,7 @@ func TestBuildWorksmobileUserPayloadMapsAdditionalAppointmentsToOrgUnits(t *test
|
|||||||
require.Equal(t, int64(1002), payload.Organizations[1].DomainID)
|
require.Equal(t, int64(1002), payload.Organizations[1].DomainID)
|
||||||
require.False(t, payload.Organizations[1].Primary)
|
require.False(t, payload.Organizations[1].Primary)
|
||||||
require.Equal(t, "externalKey:"+secondaryTenantID, payload.Organizations[1].OrgUnits[0].OrgUnitID)
|
require.Equal(t, "externalKey:"+secondaryTenantID, payload.Organizations[1].OrgUnits[0].OrgUnitID)
|
||||||
require.False(t, payload.Organizations[1].OrgUnits[0].Primary)
|
require.True(t, payload.Organizations[1].OrgUnits[0].Primary)
|
||||||
require.NotNil(t, payload.Organizations[1].OrgUnits[0].IsManager)
|
require.NotNil(t, payload.Organizations[1].OrgUnits[0].IsManager)
|
||||||
require.True(t, *payload.Organizations[1].OrgUnits[0].IsManager)
|
require.True(t, *payload.Organizations[1].OrgUnits[0].IsManager)
|
||||||
}
|
}
|
||||||
@@ -259,7 +290,7 @@ func TestBuildWorksmobileUserPayloadKeepsFirstAffiliationPrimaryWhenBaronReprese
|
|||||||
)
|
)
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, int64(1003), payload.DomainID)
|
require.Equal(t, int64(1001), payload.DomainID)
|
||||||
require.Equal(t, "First affiliation task", payload.Task)
|
require.Equal(t, "First affiliation task", payload.Task)
|
||||||
require.Len(t, payload.Organizations, 2)
|
require.Len(t, payload.Organizations, 2)
|
||||||
require.Equal(t, int64(1001), payload.Organizations[0].DomainID)
|
require.Equal(t, int64(1001), payload.Organizations[0].DomainID)
|
||||||
@@ -269,7 +300,99 @@ func TestBuildWorksmobileUserPayloadKeepsFirstAffiliationPrimaryWhenBaronReprese
|
|||||||
require.Equal(t, int64(1003), payload.Organizations[1].DomainID)
|
require.Equal(t, int64(1003), payload.Organizations[1].DomainID)
|
||||||
require.False(t, payload.Organizations[1].Primary)
|
require.False(t, payload.Organizations[1].Primary)
|
||||||
require.Equal(t, "externalKey:"+secondTenantID, payload.Organizations[1].OrgUnits[0].OrgUnitID)
|
require.Equal(t, "externalKey:"+secondTenantID, payload.Organizations[1].OrgUnits[0].OrgUnitID)
|
||||||
require.False(t, payload.Organizations[1].OrgUnits[0].Primary)
|
require.True(t, payload.Organizations[1].OrgUnits[0].Primary)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildWorksmobileUserPayloadUsesEmailDomainForAccountDomainWhenPrimaryOrgIsGPDTDC(t *testing.T) {
|
||||||
|
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||||
|
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
||||||
|
samanID := "11111111-1111-1111-1111-111111111111"
|
||||||
|
gpdtdcID := "5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee"
|
||||||
|
leafTenantID := "52f06c97-9d6f-4819-971b-43303062e193"
|
||||||
|
user := domain.User{
|
||||||
|
ID: "44444444-4444-4444-4444-444444444444",
|
||||||
|
Email: "dhlee@samaneng.com",
|
||||||
|
Name: "GPDTDC Saman User",
|
||||||
|
TenantID: &leafTenantID,
|
||||||
|
Metadata: domain.JSONMap{
|
||||||
|
"additionalAppointments": []any{
|
||||||
|
map[string]any{
|
||||||
|
"tenantId": leafTenantID,
|
||||||
|
"isPrimary": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
samanTenant := domain.Tenant{
|
||||||
|
ID: samanID,
|
||||||
|
Slug: "saman",
|
||||||
|
Name: "삼안",
|
||||||
|
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||||
|
}
|
||||||
|
gpdtdcTenant := domain.Tenant{
|
||||||
|
ID: gpdtdcID,
|
||||||
|
Slug: "gpdtdc",
|
||||||
|
Name: "총괄기획&기술개발센터",
|
||||||
|
}
|
||||||
|
leafTenant := domain.Tenant{
|
||||||
|
ID: leafTenantID,
|
||||||
|
Slug: "infra-bim2",
|
||||||
|
Name: "인프라 BIM2",
|
||||||
|
ParentID: &gpdtdcID,
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
|
||||||
|
user,
|
||||||
|
leafTenant,
|
||||||
|
map[string]domain.Tenant{
|
||||||
|
samanID: samanTenant,
|
||||||
|
gpdtdcID: gpdtdcTenant,
|
||||||
|
leafTenantID: leafTenant,
|
||||||
|
},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, int64(1001), payload.DomainID)
|
||||||
|
require.Len(t, payload.Organizations, 2)
|
||||||
|
require.Equal(t, int64(1001), payload.Organizations[0].DomainID)
|
||||||
|
require.True(t, payload.Organizations[0].Primary)
|
||||||
|
require.Equal(t, "dhlee@samaneng.com", payload.Organizations[0].Email)
|
||||||
|
require.Equal(t, "externalKey:"+samanID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
|
||||||
|
require.True(t, payload.Organizations[0].OrgUnits[0].Primary)
|
||||||
|
require.Equal(t, int64(1003), payload.Organizations[1].DomainID)
|
||||||
|
require.False(t, payload.Organizations[1].Primary)
|
||||||
|
require.Equal(t, "dhlee@baroncs.co.kr", payload.Organizations[1].Email)
|
||||||
|
require.Equal(t, "externalKey:"+leafTenantID, payload.Organizations[1].OrgUnits[0].OrgUnitID)
|
||||||
|
require.True(t, payload.Organizations[1].OrgUnits[0].Primary)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorksmobileUserPayloadJSONIncludesFalsePrimaryFields(t *testing.T) {
|
||||||
|
payload := WorksmobileUserPayload{
|
||||||
|
Email: "user@samaneng.com",
|
||||||
|
Organizations: []WorksmobileUserOrganization{
|
||||||
|
{
|
||||||
|
DomainID: 1001,
|
||||||
|
Primary: true,
|
||||||
|
OrgUnits: []WorksmobileUserOrgUnit{
|
||||||
|
{OrgUnitID: "externalKey:primary", Primary: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
DomainID: 1003,
|
||||||
|
Primary: false,
|
||||||
|
OrgUnits: []WorksmobileUserOrgUnit{
|
||||||
|
{OrgUnitID: "externalKey:secondary", Primary: false},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, string(data), `"primary":false`)
|
||||||
|
require.Contains(t, string(data), `"orgUnitId":"externalKey:secondary","primary":false`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveWorksmobileDomainIDFromTenantIgnoresRootDomainMappings(t *testing.T) {
|
func TestResolveWorksmobileDomainIDFromTenantIgnoresRootDomainMappings(t *testing.T) {
|
||||||
@@ -441,19 +564,51 @@ func TestBuildWorksmobileUserPayloadAddsSubEmailMetadataAlias(t *testing.T) {
|
|||||||
require.Equal(t, []string{"alias1@hanmaceng.co.kr", "alias2@hanmaceng.co.kr"}, payload.AliasEmails)
|
require.Equal(t, []string{"alias1@hanmaceng.co.kr", "alias2@hanmaceng.co.kr"}, payload.AliasEmails)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateWorksmobileAliasLocalPartsRejectsPrimaryAndAliasCollisions(t *testing.T) {
|
func TestBuildWorksmobileUserPayloadKeepsSubEmailAliasWithPrimaryLocalPart(t *testing.T) {
|
||||||
err := ValidateWorksmobileAliasLocalParts(
|
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||||
|
tenantID := "33333333-3333-3333-3333-333333333333"
|
||||||
|
user := domain.User{
|
||||||
|
ID: "44444444-4444-4444-4444-444444444444",
|
||||||
|
Email: "ypshim@samaneng.com",
|
||||||
|
Name: "Saman User",
|
||||||
|
TenantID: &tenantID,
|
||||||
|
Metadata: domain.JSONMap{
|
||||||
|
"sub_email": "ypshim@hanmaceng.co.kr",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
tenant := domain.Tenant{
|
||||||
|
ID: tenantID,
|
||||||
|
Slug: "saman",
|
||||||
|
Name: "삼안",
|
||||||
|
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := BuildWorksmobileUserPayload(user, tenant, nil)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, []string{"ypshim@hanmaceng.co.kr"}, payload.AliasEmails)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateWorksmobileAliasEmailsAllowsSameLocalPartOnDifferentDomains(t *testing.T) {
|
||||||
|
err := ValidateWorksmobileAliasEmails(
|
||||||
"main@samaneng.com",
|
"main@samaneng.com",
|
||||||
[]string{"main@hanmaceng.co.kr"},
|
[]string{"main@hanmaceng.co.kr"},
|
||||||
map[string]string{},
|
map[string]string{},
|
||||||
)
|
)
|
||||||
require.Error(t, err)
|
require.NoError(t, err)
|
||||||
require.Contains(t, err.Error(), "local-part")
|
|
||||||
|
|
||||||
err = ValidateWorksmobileAliasLocalParts(
|
err = ValidateWorksmobileAliasEmails(
|
||||||
|
"main@samaneng.com",
|
||||||
|
[]string{"main@samaneng.com"},
|
||||||
|
map[string]string{},
|
||||||
|
)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "duplicates")
|
||||||
|
|
||||||
|
err = ValidateWorksmobileAliasEmails(
|
||||||
"main@samaneng.com",
|
"main@samaneng.com",
|
||||||
[]string{"alias@hanmaceng.co.kr"},
|
[]string{"alias@hanmaceng.co.kr"},
|
||||||
map[string]string{"alias": "existing-user"},
|
map[string]string{"alias@hanmaceng.co.kr": "existing-user"},
|
||||||
)
|
)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
require.Contains(t, err.Error(), "이미 사용 중")
|
require.Contains(t, err.Error(), "이미 사용 중")
|
||||||
|
|||||||
@@ -100,9 +100,16 @@ func (w *WorksmobileRelayWorker) dispatch(ctx context.Context, job domain.Worksm
|
|||||||
if err := decodeWorksmobileRequest(job.Payload, &payload); err != nil {
|
if err := decodeWorksmobileRequest(job.Payload, &payload); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
aliasEmails := append([]string(nil), payload.AliasEmails...)
|
||||||
|
payload.AliasEmails = nil
|
||||||
if err := w.client.UpsertUser(ctx, payload); err != nil {
|
if err := w.client.UpsertUser(ctx, payload); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
for _, aliasEmail := range aliasEmails {
|
||||||
|
if err := w.client.AddUserAliasEmail(ctx, payload.Email, aliasEmail); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
if stringValue(job.Payload["baronStatus"]) == domain.UserStatusActive {
|
if stringValue(job.Payload["baronStatus"]) == domain.UserStatusActive {
|
||||||
return w.client.SetUserActive(ctx, worksmobileOutboxUserIdentifier(job), true)
|
return w.client.SetUserActive(ctx, worksmobileOutboxUserIdentifier(job), true)
|
||||||
}
|
}
|
||||||
@@ -111,6 +118,16 @@ func (w *WorksmobileRelayWorker) dispatch(ctx context.Context, job domain.Worksm
|
|||||||
return w.client.DeleteUser(ctx, worksmobileOutboxUserIdentifier(job))
|
return w.client.DeleteUser(ctx, worksmobileOutboxUserIdentifier(job))
|
||||||
case domain.WorksmobileActionSuspend:
|
case domain.WorksmobileActionSuspend:
|
||||||
return w.client.SetUserActive(ctx, worksmobileOutboxUserIdentifier(job), false)
|
return w.client.SetUserActive(ctx, worksmobileOutboxUserIdentifier(job), false)
|
||||||
|
case domain.WorksmobileActionPasswordReset:
|
||||||
|
var payload WorksmobilePasswordResetPayload
|
||||||
|
if err := decodeWorksmobileRequest(job.Payload, &payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
identifier := strings.TrimSpace(payload.Email)
|
||||||
|
if identifier == "" {
|
||||||
|
identifier = worksmobileOutboxUserIdentifier(job)
|
||||||
|
}
|
||||||
|
return w.client.ResetUserPassword(ctx, identifier, payload.PasswordConfig.Password)
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import (
|
|||||||
"baron-sso-backend/internal/repository"
|
"baron-sso-backend/internal/repository"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net/mail"
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const HanmacFamilyTenantSlug = "hanmac-family"
|
const HanmacFamilyTenantSlug = "hanmac-family"
|
||||||
@@ -25,9 +27,12 @@ type WorksmobileAdminService interface {
|
|||||||
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)
|
||||||
EnqueueUserSync(ctx context.Context, tenantID, userID string) (*domain.WorksmobileOutbox, error)
|
EnqueueUserSync(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error)
|
||||||
|
EnqueueUserPasswordReset(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error)
|
||||||
RetryJob(ctx context.Context, tenantID, jobID string) (*domain.WorksmobileOutbox, error)
|
RetryJob(ctx context.Context, tenantID, jobID string) (*domain.WorksmobileOutbox, error)
|
||||||
ListInitialPasswordCredentials(ctx context.Context, tenantID string) ([]WorksmobileInitialPasswordCredential, error)
|
ListInitialPasswordCredentials(ctx context.Context, tenantID, credentialBatchID string) ([]WorksmobileInitialPasswordCredential, error)
|
||||||
|
ListCredentialBatches(ctx context.Context, tenantID string) ([]WorksmobileCredentialBatch, error)
|
||||||
|
DeleteCredentialBatchPasswords(ctx context.Context, tenantID, credentialBatchID string) (WorksmobileCredentialBatch, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorksmobileConfigSummary struct {
|
type WorksmobileConfigSummary struct {
|
||||||
@@ -49,10 +54,36 @@ type WorksmobileBackfillDryRun struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type WorksmobileInitialPasswordCredential struct {
|
type WorksmobileInitialPasswordCredential struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
InitialPassword string `json:"initialPassword"`
|
Name string `json:"name,omitempty"`
|
||||||
Status string `json:"status"`
|
PrimaryLeafOrgName string `json:"primaryLeafOrgName,omitempty"`
|
||||||
LastError string `json:"lastError,omitempty"`
|
InitialPassword string `json:"initialPassword"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
LastError string `json:"lastError,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorksmobileCredentialBatch struct {
|
||||||
|
BatchID string `json:"batchId"`
|
||||||
|
Operation string `json:"operation,omitempty"`
|
||||||
|
UserCount int `json:"userCount"`
|
||||||
|
PendingCount int `json:"pendingCount"`
|
||||||
|
ProcessingCount int `json:"processingCount"`
|
||||||
|
ProcessedCount int `json:"processedCount"`
|
||||||
|
FailedCount int `json:"failedCount"`
|
||||||
|
HasPasswords bool `json:"hasPasswords"`
|
||||||
|
DeletedAt string `json:"deletedAt,omitempty"`
|
||||||
|
Failures []WorksmobileCredentialBatchFailure `json:"failures,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorksmobileCredentialBatchFailure struct {
|
||||||
|
UserID string `json:"userId,omitempty"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
RetryCount int `json:"retryCount"`
|
||||||
|
LastError string `json:"lastError,omitempty"`
|
||||||
|
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorksmobileComparison struct {
|
type WorksmobileComparison struct {
|
||||||
@@ -93,6 +124,10 @@ type WorksmobileComparisonItem struct {
|
|||||||
WorksmobileParentName string `json:"worksmobileParentName,omitempty"`
|
WorksmobileParentName string `json:"worksmobileParentName,omitempty"`
|
||||||
WorksmobileParentEmail string `json:"worksmobileParentEmail,omitempty"`
|
WorksmobileParentEmail string `json:"worksmobileParentEmail,omitempty"`
|
||||||
WorksmobileParentExternalKey string `json:"worksmobileParentExternalKey,omitempty"`
|
WorksmobileParentExternalKey string `json:"worksmobileParentExternalKey,omitempty"`
|
||||||
|
WorksmobileJobStatus string `json:"worksmobileJobStatus,omitempty"`
|
||||||
|
WorksmobileJobRetryCount int `json:"worksmobileJobRetryCount,omitempty"`
|
||||||
|
WorksmobileLastError string `json:"worksmobileLastError,omitempty"`
|
||||||
|
WorksmobileLastAttemptAt string `json:"worksmobileLastAttemptAt,omitempty"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,8 +220,10 @@ func (s *worksmobileSyncService) GetComparison(ctx context.Context, tenantID str
|
|||||||
return WorksmobileComparison{}, err
|
return WorksmobileComparison{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recentJobs, _ := s.outboxRepo.ListRecent(ctx, 1000)
|
||||||
|
|
||||||
return WorksmobileComparison{
|
return WorksmobileComparison{
|
||||||
Users: compareWorksmobileUsers(users, remoteUsers, includeMatched, tenantByID),
|
Users: compareWorksmobileUsers(users, remoteUsers, includeMatched, tenantByID, worksmobileUserJobSummaries(recentJobs)),
|
||||||
Groups: compareWorksmobileGroups(append([]domain.Tenant{*root}, tenants...), remoteGroups, includeMatched),
|
Groups: compareWorksmobileGroups(append([]domain.Tenant{*root}, tenants...), remoteGroups, includeMatched),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -340,7 +377,7 @@ func (s *worksmobileSyncService) EnqueueOrgUnitDelete(ctx context.Context, tenan
|
|||||||
return item, nil
|
return item, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID, userID string) (*domain.WorksmobileOutbox, error) {
|
func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) {
|
||||||
root, err := s.hanmacRoot(ctx, tenantID)
|
root, err := s.hanmacRoot(ctx, tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -394,18 +431,104 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
|
|||||||
DedupeKey: "user:" + strings.ToLower(action) + ":" + user.ID,
|
DedupeKey: "user:" + strings.ToLower(action) + ":" + user.ID,
|
||||||
Payload: worksmobileUserOutboxPayload(root.ID, payload, user.Status),
|
Payload: worksmobileUserOutboxPayload(root.ID, payload, user.Status),
|
||||||
}
|
}
|
||||||
|
item.Payload["displayName"] = strings.TrimSpace(user.Name)
|
||||||
|
item.Payload["primaryLeafOrgName"] = worksmobileUserPrimaryOrgName(*user, tenantByID)
|
||||||
|
if batchID := strings.TrimSpace(credentialBatchID); batchID != "" {
|
||||||
|
item.Payload["credentialBatchId"] = batchID
|
||||||
|
item.Payload["credentialOperation"] = "worksmobile_user_sync"
|
||||||
|
item.Payload["credentialBatchCreatedAt"] = time.Now().UTC().Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
if err := s.outboxRepo.Create(ctx, item); err != nil {
|
if err := s.outboxRepo.Create(ctx, item); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return item, nil
|
return item, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *worksmobileSyncService) ListInitialPasswordCredentials(ctx context.Context, tenantID string) ([]WorksmobileInitialPasswordCredential, error) {
|
func (s *worksmobileSyncService) EnqueueUserPasswordReset(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) {
|
||||||
root, err := s.hanmacRoot(ctx, tenantID)
|
root, err := s.hanmacRoot(ctx, tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
jobs, err := s.outboxRepo.ListRecent(ctx, 1000)
|
user, err := s.userRepo.FindByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if user.TenantID == nil {
|
||||||
|
return nil, errors.New("target user has no tenant")
|
||||||
|
}
|
||||||
|
tenant, err := s.tenantService.GetTenant(ctx, *user.TenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tenantRoot, ok, err := s.rootForTenant(ctx, *tenant)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok || tenantRoot.ID != root.ID {
|
||||||
|
return nil, errors.New("target user is outside hanmac-family subtree")
|
||||||
|
}
|
||||||
|
if !domain.IsWorksProvisionedUserStatus(user.Status) {
|
||||||
|
return nil, errors.New("target user status is excluded from Worksmobile password reset")
|
||||||
|
}
|
||||||
|
scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
||||||
|
payload, err := BuildWorksmobileUserPayloadForDomainTenants(*user, *tenant, tenantByID, root.Config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
password := GenerateWorksmobileInitialPassword()
|
||||||
|
request := WorksmobilePasswordResetPayload{
|
||||||
|
Email: strings.TrimSpace(payload.Email),
|
||||||
|
PasswordConfig: WorksmobilePasswordConfig{
|
||||||
|
PasswordCreationType: "ADMIN",
|
||||||
|
Password: password,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
batchID := strings.TrimSpace(credentialBatchID)
|
||||||
|
batchCreatedAt := time.Now().UTC().Format(time.RFC3339Nano)
|
||||||
|
dedupeSuffix := batchID
|
||||||
|
if dedupeSuffix == "" {
|
||||||
|
dedupeSuffix = batchCreatedAt
|
||||||
|
}
|
||||||
|
item := &domain.WorksmobileOutbox{
|
||||||
|
ResourceType: domain.WorksmobileResourceUser,
|
||||||
|
ResourceID: user.ID,
|
||||||
|
Action: domain.WorksmobileActionPasswordReset,
|
||||||
|
DedupeKey: "user:password-reset:" + user.ID + ":" + dedupeSuffix,
|
||||||
|
Payload: domain.JSONMap{
|
||||||
|
"tenantRootId": root.ID,
|
||||||
|
"loginEmail": request.Email,
|
||||||
|
"userExternalKey": user.ID,
|
||||||
|
"initialPassword": password,
|
||||||
|
"displayName": strings.TrimSpace(user.Name),
|
||||||
|
"primaryLeafOrgName": worksmobileUserPrimaryOrgName(*user, tenantByID),
|
||||||
|
"credentialBatchId": batchID,
|
||||||
|
"credentialOperation": "worksmobile_password_reset",
|
||||||
|
"credentialBatchCreatedAt": batchCreatedAt,
|
||||||
|
"request": request,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := s.outboxRepo.Create(ctx, item); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *worksmobileSyncService) ListInitialPasswordCredentials(ctx context.Context, tenantID, credentialBatchID string) ([]WorksmobileInitialPasswordCredential, error) {
|
||||||
|
root, err := s.hanmacRoot(ctx, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
credentialBatchID = strings.TrimSpace(credentialBatchID)
|
||||||
|
var jobs []domain.WorksmobileOutbox
|
||||||
|
if credentialBatchID != "" {
|
||||||
|
jobs, err = s.outboxRepo.ListCredentialBatchJobs(ctx, root.ID, credentialBatchID)
|
||||||
|
} else {
|
||||||
|
jobs, err = s.outboxRepo.ListRecent(ctx, 1000)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -418,6 +541,9 @@ func (s *worksmobileSyncService) ListInitialPasswordCredentials(ctx context.Cont
|
|||||||
if stringValue(job.Payload["tenantRootId"]) != root.ID {
|
if stringValue(job.Payload["tenantRootId"]) != root.ID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if credentialBatchID != "" && stringValue(job.Payload["credentialBatchId"]) != credentialBatchID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
email := stringValue(job.Payload["loginEmail"])
|
email := stringValue(job.Payload["loginEmail"])
|
||||||
password := stringValue(job.Payload["initialPassword"])
|
password := stringValue(job.Payload["initialPassword"])
|
||||||
if email == "" || password == "" || seen[email] {
|
if email == "" || password == "" || seen[email] {
|
||||||
@@ -425,15 +551,60 @@ func (s *worksmobileSyncService) ListInitialPasswordCredentials(ctx context.Cont
|
|||||||
}
|
}
|
||||||
seen[email] = true
|
seen[email] = true
|
||||||
credentials = append(credentials, WorksmobileInitialPasswordCredential{
|
credentials = append(credentials, WorksmobileInitialPasswordCredential{
|
||||||
Email: email,
|
Email: email,
|
||||||
InitialPassword: password,
|
Name: stringValue(job.Payload["displayName"]),
|
||||||
Status: job.Status,
|
PrimaryLeafOrgName: stringValue(job.Payload["primaryLeafOrgName"]),
|
||||||
LastError: job.LastError,
|
InitialPassword: password,
|
||||||
|
Status: job.Status,
|
||||||
|
LastError: job.LastError,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return credentials, nil
|
return credentials, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *worksmobileSyncService) ListCredentialBatches(ctx context.Context, tenantID string) ([]WorksmobileCredentialBatch, error) {
|
||||||
|
root, err := s.hanmacRoot(ctx, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
jobs, err := s.outboxRepo.ListCredentialBatchJobs(ctx, root.ID, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return aggregateWorksmobileCredentialBatches(jobs), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *worksmobileSyncService) DeleteCredentialBatchPasswords(ctx context.Context, tenantID, credentialBatchID string) (WorksmobileCredentialBatch, error) {
|
||||||
|
root, err := s.hanmacRoot(ctx, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return WorksmobileCredentialBatch{}, err
|
||||||
|
}
|
||||||
|
credentialBatchID = strings.TrimSpace(credentialBatchID)
|
||||||
|
if credentialBatchID == "" {
|
||||||
|
return WorksmobileCredentialBatch{}, errors.New("credential batch id is required")
|
||||||
|
}
|
||||||
|
jobs, err := s.outboxRepo.ListCredentialBatchJobs(ctx, root.ID, credentialBatchID)
|
||||||
|
if err != nil {
|
||||||
|
return WorksmobileCredentialBatch{}, err
|
||||||
|
}
|
||||||
|
if len(jobs) == 0 {
|
||||||
|
return WorksmobileCredentialBatch{}, errors.New("credential batch not found")
|
||||||
|
}
|
||||||
|
deletedAt := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
for i := range jobs {
|
||||||
|
nextPayload := scrubWorksmobileCredentialPayload(jobs[i].Payload, deletedAt)
|
||||||
|
if err := s.outboxRepo.UpdatePayload(ctx, jobs[i].ID, nextPayload); err != nil {
|
||||||
|
return WorksmobileCredentialBatch{}, err
|
||||||
|
}
|
||||||
|
jobs[i].Payload = nextPayload
|
||||||
|
}
|
||||||
|
batches := aggregateWorksmobileCredentialBatches(jobs)
|
||||||
|
if len(batches) == 0 {
|
||||||
|
return WorksmobileCredentialBatch{}, errors.New("credential batch not found")
|
||||||
|
}
|
||||||
|
return batches[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *worksmobileSyncService) RetryJob(ctx context.Context, tenantID, jobID string) (*domain.WorksmobileOutbox, error) {
|
func (s *worksmobileSyncService) RetryJob(ctx context.Context, tenantID, jobID string) (*domain.WorksmobileOutbox, error) {
|
||||||
if _, err := s.hanmacRoot(ctx, tenantID); err != nil {
|
if _, err := s.hanmacRoot(ctx, tenantID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -663,7 +834,7 @@ func (s *worksmobileSyncService) validateUserAliasLocalParts(ctx context.Context
|
|||||||
if existingUser.ID == user.ID {
|
if existingUser.ID == user.ID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
addWorksmobileLocalPart(existing, existingUser.Email, existingUser.ID)
|
addWorksmobileEmail(existing, existingUser.Email, existingUser.ID)
|
||||||
if existingUser.TenantID == nil {
|
if existingUser.TenantID == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -672,16 +843,16 @@ func (s *worksmobileSyncService) validateUserAliasLocalParts(ctx context.Context
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, alias := range BuildWorksmobileAliasEmails(existingUser, tenant) {
|
for _, alias := range BuildWorksmobileAliasEmails(existingUser, tenant) {
|
||||||
addWorksmobileLocalPart(existing, alias, existingUser.ID)
|
addWorksmobileEmail(existing, alias, existingUser.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ValidateWorksmobileAliasLocalParts(payload.Email, payload.AliasEmails, existing)
|
return ValidateWorksmobileAliasEmails(payload.Email, payload.AliasEmails, existing)
|
||||||
}
|
}
|
||||||
|
|
||||||
func addWorksmobileLocalPart(target map[string]string, email string, owner string) {
|
func addWorksmobileEmail(target map[string]string, email string, owner string) {
|
||||||
localPart, err := domain.ExtractNormalizedEmailLocalPart(email)
|
normalized := strings.ToLower(strings.TrimSpace(email))
|
||||||
if err == nil && localPart != "" {
|
if _, err := mail.ParseAddress(normalized); err == nil && normalized != "" {
|
||||||
target[localPart] = owner
|
target[normalized] = owner
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -833,6 +1004,196 @@ func worksmobileUserOutboxPayload(rootID string, payload WorksmobileUserPayload,
|
|||||||
return outboxPayload
|
return outboxPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func aggregateWorksmobileCredentialBatches(jobs []domain.WorksmobileOutbox) []WorksmobileCredentialBatch {
|
||||||
|
byBatchID := map[string]*WorksmobileCredentialBatch{}
|
||||||
|
for _, job := range jobs {
|
||||||
|
batchID := stringValue(job.Payload["credentialBatchId"])
|
||||||
|
if batchID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
batch, ok := byBatchID[batchID]
|
||||||
|
if !ok {
|
||||||
|
createdAt := worksmobileCredentialBatchCreatedAt(job)
|
||||||
|
batch = &WorksmobileCredentialBatch{
|
||||||
|
BatchID: batchID,
|
||||||
|
Operation: stringValue(job.Payload["credentialOperation"]),
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
UpdatedAt: job.UpdatedAt,
|
||||||
|
}
|
||||||
|
byBatchID[batchID] = batch
|
||||||
|
}
|
||||||
|
batch.UserCount++
|
||||||
|
if batch.Operation == "" {
|
||||||
|
batch.Operation = stringValue(job.Payload["credentialOperation"])
|
||||||
|
}
|
||||||
|
jobBatchCreatedAt := worksmobileCredentialBatchCreatedAt(job)
|
||||||
|
if jobBatchCreatedAt.Before(batch.CreatedAt) || batch.CreatedAt.IsZero() {
|
||||||
|
batch.CreatedAt = jobBatchCreatedAt
|
||||||
|
}
|
||||||
|
if job.UpdatedAt.After(batch.UpdatedAt) {
|
||||||
|
batch.UpdatedAt = job.UpdatedAt
|
||||||
|
}
|
||||||
|
switch job.Status {
|
||||||
|
case domain.WorksmobileOutboxStatusPending:
|
||||||
|
batch.PendingCount++
|
||||||
|
case domain.WorksmobileOutboxStatusProcessing:
|
||||||
|
batch.ProcessingCount++
|
||||||
|
case domain.WorksmobileOutboxStatusProcessed:
|
||||||
|
batch.ProcessedCount++
|
||||||
|
case domain.WorksmobileOutboxStatusFailed:
|
||||||
|
batch.FailedCount++
|
||||||
|
batch.Failures = append(batch.Failures, WorksmobileCredentialBatchFailure{
|
||||||
|
UserID: job.ResourceID,
|
||||||
|
Email: worksmobileCredentialJobEmail(job),
|
||||||
|
Status: job.Status,
|
||||||
|
RetryCount: job.RetryCount,
|
||||||
|
LastError: strings.TrimSpace(job.LastError),
|
||||||
|
UpdatedAt: job.UpdatedAt.Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if worksmobilePayloadHasPassword(job.Payload) {
|
||||||
|
batch.HasPasswords = true
|
||||||
|
}
|
||||||
|
if deletedAt := stringValue(job.Payload["credentialDeletedAt"]); deletedAt != "" {
|
||||||
|
batch.DeletedAt = deletedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
batches := make([]WorksmobileCredentialBatch, 0, len(byBatchID))
|
||||||
|
for _, batch := range byBatchID {
|
||||||
|
batches = append(batches, *batch)
|
||||||
|
}
|
||||||
|
sort.Slice(batches, func(i, j int) bool {
|
||||||
|
return batches[i].CreatedAt.After(batches[j].CreatedAt)
|
||||||
|
})
|
||||||
|
return batches
|
||||||
|
}
|
||||||
|
|
||||||
|
func worksmobileCredentialBatchCreatedAt(job domain.WorksmobileOutbox) time.Time {
|
||||||
|
if value := stringValue(job.Payload["credentialBatchCreatedAt"]); value != "" {
|
||||||
|
if parsed, err := time.Parse(time.RFC3339Nano, value); err == nil {
|
||||||
|
return parsed.UTC()
|
||||||
|
}
|
||||||
|
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
|
||||||
|
return parsed.UTC()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !job.UpdatedAt.IsZero() && !job.CreatedAt.IsZero() && job.UpdatedAt.After(job.CreatedAt) {
|
||||||
|
return job.UpdatedAt.UTC()
|
||||||
|
}
|
||||||
|
return job.CreatedAt.UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
func worksmobileCredentialJobEmail(job domain.WorksmobileOutbox) string {
|
||||||
|
if email := stringValue(job.Payload["loginEmail"]); email != "" {
|
||||||
|
return email
|
||||||
|
}
|
||||||
|
switch request := job.Payload["request"].(type) {
|
||||||
|
case WorksmobileUserPayload:
|
||||||
|
return strings.TrimSpace(request.Email)
|
||||||
|
case WorksmobilePasswordResetPayload:
|
||||||
|
return strings.TrimSpace(request.Email)
|
||||||
|
case map[string]any:
|
||||||
|
return stringValue(request["email"])
|
||||||
|
case domain.JSONMap:
|
||||||
|
return stringValue(request["email"])
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrubWorksmobileCredentialPayload(payload domain.JSONMap, deletedAt string) domain.JSONMap {
|
||||||
|
nextPayload := make(domain.JSONMap, len(payload)+1)
|
||||||
|
for key, value := range payload {
|
||||||
|
nextPayload[key] = value
|
||||||
|
}
|
||||||
|
delete(nextPayload, "initialPassword")
|
||||||
|
nextPayload["credentialDeletedAt"] = deletedAt
|
||||||
|
nextPayload["request"] = scrubWorksmobileRequestPassword(nextPayload["request"])
|
||||||
|
return nextPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrubWorksmobileRequestPassword(request any) any {
|
||||||
|
switch v := request.(type) {
|
||||||
|
case WorksmobileUserPayload:
|
||||||
|
v.PasswordConfig.Password = ""
|
||||||
|
return v
|
||||||
|
case WorksmobilePasswordResetPayload:
|
||||||
|
v.PasswordConfig.Password = ""
|
||||||
|
return v
|
||||||
|
case map[string]any:
|
||||||
|
next := make(map[string]any, len(v))
|
||||||
|
for key, value := range v {
|
||||||
|
next[key] = value
|
||||||
|
}
|
||||||
|
next["passwordConfig"] = scrubWorksmobilePasswordConfig(next["passwordConfig"])
|
||||||
|
return next
|
||||||
|
case domain.JSONMap:
|
||||||
|
next := make(domain.JSONMap, len(v))
|
||||||
|
for key, value := range v {
|
||||||
|
next[key] = value
|
||||||
|
}
|
||||||
|
next["passwordConfig"] = scrubWorksmobilePasswordConfig(next["passwordConfig"])
|
||||||
|
return next
|
||||||
|
default:
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrubWorksmobilePasswordConfig(config any) any {
|
||||||
|
switch v := config.(type) {
|
||||||
|
case WorksmobilePasswordConfig:
|
||||||
|
v.Password = ""
|
||||||
|
return v
|
||||||
|
case map[string]any:
|
||||||
|
next := make(map[string]any, len(v))
|
||||||
|
for key, value := range v {
|
||||||
|
next[key] = value
|
||||||
|
}
|
||||||
|
next["password"] = ""
|
||||||
|
return next
|
||||||
|
case domain.JSONMap:
|
||||||
|
next := make(domain.JSONMap, len(v))
|
||||||
|
for key, value := range v {
|
||||||
|
next[key] = value
|
||||||
|
}
|
||||||
|
next["password"] = ""
|
||||||
|
return next
|
||||||
|
default:
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func worksmobilePayloadHasPassword(payload domain.JSONMap) bool {
|
||||||
|
if stringValue(payload["initialPassword"]) != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch request := payload["request"].(type) {
|
||||||
|
case WorksmobileUserPayload:
|
||||||
|
return strings.TrimSpace(request.PasswordConfig.Password) != ""
|
||||||
|
case WorksmobilePasswordResetPayload:
|
||||||
|
return strings.TrimSpace(request.PasswordConfig.Password) != ""
|
||||||
|
case map[string]any:
|
||||||
|
return worksmobilePasswordConfigHasPassword(request["passwordConfig"])
|
||||||
|
case domain.JSONMap:
|
||||||
|
return worksmobilePasswordConfigHasPassword(request["passwordConfig"])
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func worksmobilePasswordConfigHasPassword(config any) bool {
|
||||||
|
switch v := config.(type) {
|
||||||
|
case WorksmobilePasswordConfig:
|
||||||
|
return strings.TrimSpace(v.Password) != ""
|
||||||
|
case map[string]any:
|
||||||
|
return stringValue(v["password"]) != ""
|
||||||
|
case domain.JSONMap:
|
||||||
|
return stringValue(v["password"]) != ""
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func stringValue(value any) string {
|
func stringValue(value any) string {
|
||||||
switch v := value.(type) {
|
switch v := value.(type) {
|
||||||
case string:
|
case string:
|
||||||
@@ -842,7 +1203,40 @@ func stringValue(value any) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []WorksmobileRemoteUser, includeMatched bool, localTenants map[string]domain.Tenant) []WorksmobileComparisonItem {
|
type worksmobileUserJobSummary struct {
|
||||||
|
Status string
|
||||||
|
RetryCount int
|
||||||
|
LastError string
|
||||||
|
LastAttemptAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
func worksmobileUserJobSummaries(jobs []domain.WorksmobileOutbox) map[string]worksmobileUserJobSummary {
|
||||||
|
result := map[string]worksmobileUserJobSummary{}
|
||||||
|
for _, job := range jobs {
|
||||||
|
if job.ResourceType != domain.WorksmobileResourceUser {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if job.ResourceID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := result[job.ResourceID]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result[job.ResourceID] = worksmobileUserJobSummary{
|
||||||
|
Status: job.Status,
|
||||||
|
RetryCount: job.RetryCount,
|
||||||
|
LastError: strings.TrimSpace(job.LastError),
|
||||||
|
LastAttemptAt: job.UpdatedAt.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []WorksmobileRemoteUser, includeMatched bool, localTenants map[string]domain.Tenant, jobSummaries ...map[string]worksmobileUserJobSummary) []WorksmobileComparisonItem {
|
||||||
|
jobSummaryByUserID := map[string]worksmobileUserJobSummary{}
|
||||||
|
if len(jobSummaries) > 0 && jobSummaries[0] != nil {
|
||||||
|
jobSummaryByUserID = jobSummaries[0]
|
||||||
|
}
|
||||||
remoteByExternalID := map[string]WorksmobileRemoteUser{}
|
remoteByExternalID := map[string]WorksmobileRemoteUser{}
|
||||||
remoteByEmail := map[string]WorksmobileRemoteUser{}
|
remoteByEmail := map[string]WorksmobileRemoteUser{}
|
||||||
for _, remote := range remoteUsers {
|
for _, remote := range remoteUsers {
|
||||||
@@ -872,7 +1266,8 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
|
|||||||
if !matched {
|
if !matched {
|
||||||
remote, matched = remoteByEmail[strings.ToLower(strings.TrimSpace(user.Email))]
|
remote, matched = remoteByEmail[strings.ToLower(strings.TrimSpace(user.Email))]
|
||||||
}
|
}
|
||||||
if matched && !includeMatched {
|
needsUpdate := matched && worksmobileUserNeedsUpdate(user, remote)
|
||||||
|
if matched && !includeMatched && !needsUpdate {
|
||||||
matchedRemoteIDs[remote.ID] = true
|
matchedRemoteIDs[remote.ID] = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -886,8 +1281,19 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
|
|||||||
BaronPrimaryOrgName: worksmobileUserPrimaryOrgName(user, localTenants),
|
BaronPrimaryOrgName: worksmobileUserPrimaryOrgName(user, localTenants),
|
||||||
Status: "missing_in_worksmobile",
|
Status: "missing_in_worksmobile",
|
||||||
}
|
}
|
||||||
|
if summary, ok := jobSummaryByUserID[user.ID]; ok {
|
||||||
|
item.WorksmobileJobStatus = summary.Status
|
||||||
|
item.WorksmobileJobRetryCount = summary.RetryCount
|
||||||
|
item.WorksmobileLastAttemptAt = summary.LastAttemptAt
|
||||||
|
if summary.Status == domain.WorksmobileOutboxStatusFailed {
|
||||||
|
item.WorksmobileLastError = summary.LastError
|
||||||
|
}
|
||||||
|
}
|
||||||
if matched {
|
if matched {
|
||||||
item.Status = "matched"
|
item.Status = "matched"
|
||||||
|
if needsUpdate {
|
||||||
|
item.Status = "needs_update"
|
||||||
|
}
|
||||||
item.WorksmobileID = remote.ID
|
item.WorksmobileID = remote.ID
|
||||||
item.ExternalKey = remote.ExternalID
|
item.ExternalKey = remote.ExternalID
|
||||||
item.WorksmobileName = remote.DisplayName
|
item.WorksmobileName = remote.DisplayName
|
||||||
@@ -958,6 +1364,62 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func worksmobileUserNeedsUpdate(user domain.User, remote WorksmobileRemoteUser) bool {
|
||||||
|
if strings.TrimSpace(remote.ExternalID) != strings.TrimSpace(user.ID) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(remote.DisplayName) != strings.TrimSpace(user.Name) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.ToLower(strings.TrimSpace(remote.Email)) != strings.ToLower(strings.TrimSpace(user.Email)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if worksmobileUserManagerNeedsUpdate(user, remote) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func worksmobileUserManagerNeedsUpdate(user domain.User, remote WorksmobileRemoteUser) bool {
|
||||||
|
localManagers := worksmobileUserExplicitOrgUnitManagers(user)
|
||||||
|
if len(localManagers) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
remoteManagers := remote.OrgUnitManagers
|
||||||
|
if len(remoteManagers) == 0 && remote.PrimaryOrgUnitID != "" {
|
||||||
|
remoteManagers = map[string]*bool{remote.PrimaryOrgUnitID: remote.PrimaryOrgUnitIsManager}
|
||||||
|
}
|
||||||
|
for remoteOrgUnitID, remoteManager := range remoteManagers {
|
||||||
|
if remoteManager == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
localManager, ok := localManagers[worksmobileOrgUnitLocalExternalKey(remoteOrgUnitID)]
|
||||||
|
if ok && localManager != *remoteManager {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func worksmobileUserExplicitOrgUnitManagers(user domain.User) map[string]bool {
|
||||||
|
managers := map[string]bool{}
|
||||||
|
for _, appointment := range worksmobileAppointmentsFromMetadata(user.Metadata) {
|
||||||
|
if appointment.TenantID == "" || !appointment.HasManager {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
managers[appointment.TenantID] = appointment.IsManager
|
||||||
|
}
|
||||||
|
return managers
|
||||||
|
}
|
||||||
|
|
||||||
|
func worksmobileOrgUnitLocalExternalKey(orgUnitID string) string {
|
||||||
|
normalized := strings.TrimSpace(orgUnitID)
|
||||||
|
if after, ok := strings.CutPrefix(normalized, "externalKey:"); ok {
|
||||||
|
return strings.TrimSpace(after)
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
func worksmobileUserPrimaryOrgID(user domain.User) string {
|
func worksmobileUserPrimaryOrgID(user domain.User) string {
|
||||||
if user.TenantID == nil {
|
if user.TenantID == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import (
|
|||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWorksmobileSyncServiceRejectsAliasLocalPartAlreadyUsedByOtherUser(t *testing.T) {
|
func TestWorksmobileSyncServiceRejectsAliasEmailAlreadyUsedByOtherUser(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"
|
||||||
@@ -36,7 +37,7 @@ func TestWorksmobileSyncServiceRejectsAliasLocalPartAlreadyUsedByOtherUser(t *te
|
|||||||
}
|
}
|
||||||
existing := domain.User{
|
existing := domain.User{
|
||||||
ID: "existing-user",
|
ID: "existing-user",
|
||||||
Email: "used@samaneng.com",
|
Email: "used@hanmaceng.co.kr",
|
||||||
Name: "Existing",
|
Name: "Existing",
|
||||||
TenantID: &tenantID,
|
TenantID: &tenantID,
|
||||||
}
|
}
|
||||||
@@ -48,7 +49,7 @@ func TestWorksmobileSyncServiceRejectsAliasLocalPartAlreadyUsedByOtherUser(t *te
|
|||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID)
|
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "")
|
||||||
|
|
||||||
require.Nil(t, item)
|
require.Nil(t, item)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
@@ -88,7 +89,7 @@ func TestWorksmobileSyncServiceEnqueuesSuspendedUserStatusWithOrganizations(t *t
|
|||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID)
|
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "")
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, item)
|
require.NotNil(t, item)
|
||||||
@@ -101,6 +102,253 @@ func TestWorksmobileSyncServiceEnqueuesSuspendedUserStatusWithOrganizations(t *t
|
|||||||
require.Equal(t, "target@samaneng.com", outboxRepo.created[0].Payload["loginEmail"])
|
require.Equal(t, "target@samaneng.com", outboxRepo.created[0].Payload["loginEmail"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWorksmobileSyncServiceEnqueuesUserCredentialBatchID(t *testing.T) {
|
||||||
|
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||||
|
rootID := "root-tenant"
|
||||||
|
tenantID := "saman-tenant"
|
||||||
|
root := domain.Tenant{
|
||||||
|
ID: rootID,
|
||||||
|
Slug: HanmacFamilyTenantSlug,
|
||||||
|
Name: "Hanmac Family",
|
||||||
|
}
|
||||||
|
tenant := domain.Tenant{
|
||||||
|
ID: tenantID,
|
||||||
|
Slug: "saman",
|
||||||
|
Name: "Saman",
|
||||||
|
Type: domain.TenantTypeCompany,
|
||||||
|
ParentID: &rootID,
|
||||||
|
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||||
|
}
|
||||||
|
target := domain.User{
|
||||||
|
ID: "target-user",
|
||||||
|
Email: "target@samaneng.com",
|
||||||
|
Name: "Target",
|
||||||
|
Status: domain.UserStatusActive,
|
||||||
|
TenantID: &tenantID,
|
||||||
|
}
|
||||||
|
outboxRepo := &fakeWorksmobileOutboxRepo{}
|
||||||
|
service := NewWorksmobileSyncService(
|
||||||
|
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, tenantID: tenant}, list: []domain.Tenant{root, tenant}},
|
||||||
|
&fakeWorksmobileUserRepo{byID: map[string]domain.User{target.ID: target}, byTenant: []domain.User{target}},
|
||||||
|
outboxRepo,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "batch-1")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, item)
|
||||||
|
require.Len(t, outboxRepo.created, 1)
|
||||||
|
require.Equal(t, "batch-1", outboxRepo.created[0].Payload["credentialBatchId"])
|
||||||
|
require.NotEmpty(t, outboxRepo.created[0].Payload["credentialBatchCreatedAt"])
|
||||||
|
require.Equal(t, "Target", outboxRepo.created[0].Payload["displayName"])
|
||||||
|
require.Equal(t, "Saman", outboxRepo.created[0].Payload["primaryLeafOrgName"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorksmobileSyncServiceEnqueuesUserPasswordResetCredentialBatch(t *testing.T) {
|
||||||
|
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||||
|
rootID := "root-tenant"
|
||||||
|
tenantID := "saman-leaf"
|
||||||
|
root := domain.Tenant{
|
||||||
|
ID: rootID,
|
||||||
|
Slug: HanmacFamilyTenantSlug,
|
||||||
|
Name: "Hanmac Family",
|
||||||
|
}
|
||||||
|
tenant := domain.Tenant{
|
||||||
|
ID: tenantID,
|
||||||
|
Slug: "people-growth",
|
||||||
|
Name: "인재성장",
|
||||||
|
Type: domain.TenantTypeOrganization,
|
||||||
|
ParentID: &rootID,
|
||||||
|
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||||
|
}
|
||||||
|
target := domain.User{
|
||||||
|
ID: "target-user",
|
||||||
|
Email: "target@samaneng.com",
|
||||||
|
Name: "Target",
|
||||||
|
Status: domain.UserStatusActive,
|
||||||
|
TenantID: &tenantID,
|
||||||
|
}
|
||||||
|
outboxRepo := &fakeWorksmobileOutboxRepo{}
|
||||||
|
service := NewWorksmobileSyncService(
|
||||||
|
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, tenantID: tenant}, list: []domain.Tenant{root, tenant}},
|
||||||
|
&fakeWorksmobileUserRepo{byID: map[string]domain.User{target.ID: target}, byTenant: []domain.User{target}},
|
||||||
|
outboxRepo,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
item, err := service.EnqueueUserPasswordReset(context.Background(), rootID, target.ID, "reset-batch-1")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, item)
|
||||||
|
require.Len(t, outboxRepo.created, 1)
|
||||||
|
require.Equal(t, domain.WorksmobileActionPasswordReset, outboxRepo.created[0].Action)
|
||||||
|
require.Equal(t, "reset-batch-1", outboxRepo.created[0].Payload["credentialBatchId"])
|
||||||
|
require.Equal(t, "worksmobile_password_reset", outboxRepo.created[0].Payload["credentialOperation"])
|
||||||
|
require.NotEmpty(t, outboxRepo.created[0].Payload["credentialBatchCreatedAt"])
|
||||||
|
require.Equal(t, "target@samaneng.com", outboxRepo.created[0].Payload["loginEmail"])
|
||||||
|
require.Equal(t, "Target", outboxRepo.created[0].Payload["displayName"])
|
||||||
|
require.Equal(t, "인재성장", outboxRepo.created[0].Payload["primaryLeafOrgName"])
|
||||||
|
require.NotEmpty(t, outboxRepo.created[0].Payload["initialPassword"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorksmobileSyncServiceFiltersInitialPasswordsByCredentialBatchID(t *testing.T) {
|
||||||
|
rootID := "root-tenant"
|
||||||
|
root := domain.Tenant{
|
||||||
|
ID: rootID,
|
||||||
|
Slug: HanmacFamilyTenantSlug,
|
||||||
|
Name: "Hanmac Family",
|
||||||
|
}
|
||||||
|
outboxRepo := &fakeWorksmobileOutboxRepo{
|
||||||
|
credentialBatchJobs: []domain.WorksmobileOutbox{
|
||||||
|
{
|
||||||
|
ResourceType: domain.WorksmobileResourceUser,
|
||||||
|
Status: domain.WorksmobileOutboxStatusProcessed,
|
||||||
|
Payload: domain.JSONMap{
|
||||||
|
"tenantRootId": rootID,
|
||||||
|
"loginEmail": "batch-user@samaneng.com",
|
||||||
|
"displayName": "Batch User",
|
||||||
|
"primaryLeafOrgName": "인재성장",
|
||||||
|
"initialPassword": "BatchPass1!",
|
||||||
|
"credentialBatchId": "batch-1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ResourceType: domain.WorksmobileResourceUser,
|
||||||
|
Status: domain.WorksmobileOutboxStatusProcessed,
|
||||||
|
Payload: domain.JSONMap{
|
||||||
|
"tenantRootId": rootID,
|
||||||
|
"loginEmail": "other-user@samaneng.com",
|
||||||
|
"initialPassword": "OtherPass1!",
|
||||||
|
"credentialBatchId": "batch-2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ResourceType: domain.WorksmobileResourceUser,
|
||||||
|
Status: domain.WorksmobileOutboxStatusProcessed,
|
||||||
|
Payload: domain.JSONMap{
|
||||||
|
"tenantRootId": rootID,
|
||||||
|
"loginEmail": "legacy-user@samaneng.com",
|
||||||
|
"initialPassword": "LegacyPass1!",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
service := NewWorksmobileSyncService(
|
||||||
|
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root}, list: []domain.Tenant{root}},
|
||||||
|
&fakeWorksmobileUserRepo{},
|
||||||
|
outboxRepo,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
credentials, err := service.ListInitialPasswordCredentials(context.Background(), rootID, "batch-1")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, []WorksmobileInitialPasswordCredential{
|
||||||
|
{
|
||||||
|
Email: "batch-user@samaneng.com",
|
||||||
|
Name: "Batch User",
|
||||||
|
PrimaryLeafOrgName: "인재성장",
|
||||||
|
InitialPassword: "BatchPass1!",
|
||||||
|
Status: domain.WorksmobileOutboxStatusProcessed,
|
||||||
|
},
|
||||||
|
}, credentials)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorksmobileSyncServiceDeletesCredentialBatchPasswordsButKeepsHistory(t *testing.T) {
|
||||||
|
rootID := "root-tenant"
|
||||||
|
root := domain.Tenant{
|
||||||
|
ID: rootID,
|
||||||
|
Slug: HanmacFamilyTenantSlug,
|
||||||
|
Name: "Hanmac Family",
|
||||||
|
}
|
||||||
|
outboxRepo := &fakeWorksmobileOutboxRepo{
|
||||||
|
credentialBatchJobs: []domain.WorksmobileOutbox{
|
||||||
|
{
|
||||||
|
ID: "job-1",
|
||||||
|
ResourceType: domain.WorksmobileResourceUser,
|
||||||
|
Status: domain.WorksmobileOutboxStatusProcessed,
|
||||||
|
Payload: domain.JSONMap{
|
||||||
|
"tenantRootId": rootID,
|
||||||
|
"loginEmail": "batch-user@samaneng.com",
|
||||||
|
"initialPassword": "BatchPass1!",
|
||||||
|
"credentialBatchId": "batch-1",
|
||||||
|
"credentialOperation": "worksmobile_user_sync",
|
||||||
|
"request": map[string]any{"passwordConfig": map[string]any{"password": "BatchPass1!"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "job-2",
|
||||||
|
ResourceID: "failed-user",
|
||||||
|
ResourceType: domain.WorksmobileResourceUser,
|
||||||
|
Status: domain.WorksmobileOutboxStatusFailed,
|
||||||
|
RetryCount: 2,
|
||||||
|
LastError: "worksmobile api failed",
|
||||||
|
Payload: domain.JSONMap{
|
||||||
|
"tenantRootId": rootID,
|
||||||
|
"loginEmail": "failed-user@samaneng.com",
|
||||||
|
"initialPassword": "FailedPass1!",
|
||||||
|
"credentialBatchId": "batch-1",
|
||||||
|
"credentialOperation": "worksmobile_user_sync",
|
||||||
|
"request": map[string]any{"passwordConfig": map[string]any{"password": "FailedPass1!"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
service := NewWorksmobileSyncService(
|
||||||
|
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root}, list: []domain.Tenant{root}},
|
||||||
|
&fakeWorksmobileUserRepo{},
|
||||||
|
outboxRepo,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
before, err := service.ListCredentialBatches(context.Background(), rootID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, before, 1)
|
||||||
|
require.True(t, before[0].HasPasswords)
|
||||||
|
require.Equal(t, 1, before[0].FailedCount)
|
||||||
|
require.Len(t, before[0].Failures, 1)
|
||||||
|
require.Equal(t, "failed-user", before[0].Failures[0].UserID)
|
||||||
|
require.Equal(t, "failed-user@samaneng.com", before[0].Failures[0].Email)
|
||||||
|
require.Equal(t, "worksmobile api failed", before[0].Failures[0].LastError)
|
||||||
|
|
||||||
|
after, err := service.DeleteCredentialBatchPasswords(context.Background(), rootID, "batch-1")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "batch-1", after.BatchID)
|
||||||
|
require.False(t, after.HasPasswords)
|
||||||
|
require.Equal(t, 2, after.UserCount)
|
||||||
|
require.NotEmpty(t, after.DeletedAt)
|
||||||
|
require.Len(t, outboxRepo.payloadUpdates, 2)
|
||||||
|
require.Empty(t, stringValue(outboxRepo.payloadUpdates[0]["initialPassword"]))
|
||||||
|
require.Empty(t, stringValue(outboxRepo.payloadUpdates[1]["initialPassword"]))
|
||||||
|
request := outboxRepo.payloadUpdates[0]["request"].(map[string]any)
|
||||||
|
passwordConfig := request["passwordConfig"].(map[string]any)
|
||||||
|
require.Empty(t, stringValue(passwordConfig["password"]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAggregateWorksmobileCredentialBatchesUsesCredentialBatchCreatedAt(t *testing.T) {
|
||||||
|
oldCreatedAt := time.Date(2026, 5, 29, 1, 4, 15, 0, time.UTC)
|
||||||
|
batchCreatedAt := time.Date(2026, 6, 1, 7, 20, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
batches := aggregateWorksmobileCredentialBatches([]domain.WorksmobileOutbox{
|
||||||
|
{
|
||||||
|
ID: "job-1",
|
||||||
|
CreatedAt: oldCreatedAt,
|
||||||
|
UpdatedAt: batchCreatedAt.Add(time.Minute),
|
||||||
|
Status: domain.WorksmobileOutboxStatusPending,
|
||||||
|
Payload: domain.JSONMap{
|
||||||
|
"credentialBatchId": "batch-1",
|
||||||
|
"credentialOperation": "worksmobile_user_sync",
|
||||||
|
"credentialBatchCreatedAt": batchCreatedAt.Format(time.RFC3339),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Len(t, batches, 1)
|
||||||
|
require.Equal(t, batchCreatedAt, batches[0].CreatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
func TestWorksmobileSyncServiceDeprovisionsArchivedUser(t *testing.T) {
|
func TestWorksmobileSyncServiceDeprovisionsArchivedUser(t *testing.T) {
|
||||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||||
rootID := "root-tenant"
|
rootID := "root-tenant"
|
||||||
@@ -133,7 +381,7 @@ func TestWorksmobileSyncServiceDeprovisionsArchivedUser(t *testing.T) {
|
|||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID)
|
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "")
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, item)
|
require.NotNil(t, item)
|
||||||
@@ -1139,6 +1387,95 @@ func TestWorksmobileSyncServiceBackfillDryRunSkipsArchivedUsers(t *testing.T) {
|
|||||||
require.Equal(t, 1, outboxRepo.created[0].Payload["userCount"])
|
require.Equal(t, 1, outboxRepo.created[0].Payload["userCount"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCompareWorksmobileUsersMarksManagerChangeNeedsUpdate(t *testing.T) {
|
||||||
|
tenantID := "tenant-leaf"
|
||||||
|
user := domain.User{
|
||||||
|
ID: "user-manager",
|
||||||
|
Email: "manager@samaneng.com",
|
||||||
|
Name: "Manager User",
|
||||||
|
TenantID: &tenantID,
|
||||||
|
Status: domain.UserStatusActive,
|
||||||
|
Metadata: domain.JSONMap{
|
||||||
|
"additionalAppointments": []any{
|
||||||
|
map[string]any{
|
||||||
|
"tenantId": tenantID,
|
||||||
|
"isPrimary": true,
|
||||||
|
"isManager": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
remoteManager := false
|
||||||
|
items := compareWorksmobileUsers(
|
||||||
|
[]domain.User{user},
|
||||||
|
[]WorksmobileRemoteUser{{
|
||||||
|
ID: "works-user-manager",
|
||||||
|
ExternalID: user.ID,
|
||||||
|
Email: user.Email,
|
||||||
|
DisplayName: user.Name,
|
||||||
|
PrimaryOrgUnitID: "externalKey:" + tenantID,
|
||||||
|
PrimaryOrgUnitIsManager: &remoteManager,
|
||||||
|
}},
|
||||||
|
true,
|
||||||
|
map[string]domain.Tenant{
|
||||||
|
tenantID: {ID: tenantID, Name: "Leaf", Type: domain.TenantTypeOrganization},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
require.Len(t, items, 1)
|
||||||
|
require.Equal(t, "needs_update", items[0].Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareWorksmobileUsersMarksSecondaryManagerChangeNeedsUpdate(t *testing.T) {
|
||||||
|
primaryTenantID := "tenant-company"
|
||||||
|
secondaryTenantID := "tenant-gpdtdc-leaf"
|
||||||
|
user := domain.User{
|
||||||
|
ID: "user-secondary-manager",
|
||||||
|
Email: "secondary-manager@samaneng.com",
|
||||||
|
Name: "Secondary Manager User",
|
||||||
|
TenantID: &secondaryTenantID,
|
||||||
|
Status: domain.UserStatusActive,
|
||||||
|
Metadata: domain.JSONMap{
|
||||||
|
"additionalAppointments": []any{
|
||||||
|
map[string]any{
|
||||||
|
"tenantId": primaryTenantID,
|
||||||
|
"isPrimary": true,
|
||||||
|
},
|
||||||
|
map[string]any{
|
||||||
|
"tenantId": secondaryTenantID,
|
||||||
|
"isPrimary": false,
|
||||||
|
"isManager": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
remotePrimaryManager := false
|
||||||
|
remoteSecondaryManager := false
|
||||||
|
items := compareWorksmobileUsers(
|
||||||
|
[]domain.User{user},
|
||||||
|
[]WorksmobileRemoteUser{{
|
||||||
|
ID: "works-user-secondary-manager",
|
||||||
|
ExternalID: user.ID,
|
||||||
|
Email: user.Email,
|
||||||
|
DisplayName: user.Name,
|
||||||
|
PrimaryOrgUnitID: "externalKey:" + primaryTenantID,
|
||||||
|
PrimaryOrgUnitIsManager: &remotePrimaryManager,
|
||||||
|
OrgUnitManagers: map[string]*bool{
|
||||||
|
"externalKey:" + primaryTenantID: &remotePrimaryManager,
|
||||||
|
"externalKey:" + secondaryTenantID: &remoteSecondaryManager,
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
true,
|
||||||
|
map[string]domain.Tenant{
|
||||||
|
primaryTenantID: {ID: primaryTenantID, Name: "Company", Type: domain.TenantTypeCompany},
|
||||||
|
secondaryTenantID: {ID: secondaryTenantID, Name: "GPDTDC Leaf", Type: domain.TenantTypeOrganization},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
require.Len(t, items, 1)
|
||||||
|
require.Equal(t, "needs_update", items[0].Status)
|
||||||
|
}
|
||||||
|
|
||||||
type fakeWorksmobileTenantService struct {
|
type fakeWorksmobileTenantService struct {
|
||||||
tenants map[string]domain.Tenant
|
tenants map[string]domain.Tenant
|
||||||
list []domain.Tenant
|
list []domain.Tenant
|
||||||
|
|||||||
78
common/pnpm-lock.yaml
generated
78
common/pnpm-lock.yaml
generated
@@ -63,8 +63,8 @@ importers:
|
|||||||
specifier: ^3.3.0
|
specifier: ^3.3.0
|
||||||
version: 3.3.1(oidc-client-ts@3.5.0)(react@19.2.6)
|
version: 3.3.1(oidc-client-ts@3.5.0)(react@19.2.6)
|
||||||
react-router-dom:
|
react-router-dom:
|
||||||
specifier: ^6.28.2
|
specifier: ^7.15.1
|
||||||
version: 6.30.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
version: 7.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.4.0
|
specifier: ^3.4.0
|
||||||
version: 3.6.0
|
version: 3.6.0
|
||||||
@@ -469,7 +469,7 @@ importers:
|
|||||||
specifier: ^8.0.14
|
specifier: ^8.0.14
|
||||||
version: 8.0.14(@types/node@25.7.0)(jiti@1.21.7)
|
version: 8.0.14(@types/node@25.7.0)(jiti@1.21.7)
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^4.1.6
|
specifier: 4.1.6
|
||||||
version: 4.1.6(@types/node@25.7.0)(@vitest/coverage-v8@4.1.6)(jsdom@28.1.0)(vite@8.0.14(@types/node@25.7.0)(jiti@1.21.7))
|
version: 4.1.6(@types/node@25.7.0)(@vitest/coverage-v8@4.1.6)(jsdom@28.1.0)(vite@8.0.14(@types/node@25.7.0)(jiti@1.21.7))
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
@@ -549,28 +549,24 @@ packages:
|
|||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@biomejs/cli-linux-arm64@2.4.16':
|
'@biomejs/cli-linux-arm64@2.4.16':
|
||||||
resolution: {integrity: sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ==}
|
resolution: {integrity: sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@biomejs/cli-linux-x64-musl@2.4.16':
|
'@biomejs/cli-linux-x64-musl@2.4.16':
|
||||||
resolution: {integrity: sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg==}
|
resolution: {integrity: sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@biomejs/cli-linux-x64@2.4.16':
|
'@biomejs/cli-linux-x64@2.4.16':
|
||||||
resolution: {integrity: sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ==}
|
resolution: {integrity: sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@biomejs/cli-win32-arm64@2.4.16':
|
'@biomejs/cli-win32-arm64@2.4.16':
|
||||||
resolution: {integrity: sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A==}
|
resolution: {integrity: sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A==}
|
||||||
@@ -1095,10 +1091,6 @@ packages:
|
|||||||
'@radix-ui/rect@1.1.1':
|
'@radix-ui/rect@1.1.1':
|
||||||
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
||||||
|
|
||||||
'@remix-run/router@1.23.2':
|
|
||||||
resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==}
|
|
||||||
engines: {node: '>=14.0.0'}
|
|
||||||
|
|
||||||
'@rolldown/binding-android-arm64@1.0.0':
|
'@rolldown/binding-android-arm64@1.0.0':
|
||||||
resolution: {integrity: sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==}
|
resolution: {integrity: sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -1164,84 +1156,72 @@ packages:
|
|||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rolldown/binding-linux-arm64-gnu@1.0.2':
|
'@rolldown/binding-linux-arm64-gnu@1.0.2':
|
||||||
resolution: {integrity: sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==}
|
resolution: {integrity: sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rolldown/binding-linux-arm64-musl@1.0.0':
|
'@rolldown/binding-linux-arm64-musl@1.0.0':
|
||||||
resolution: {integrity: sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==}
|
resolution: {integrity: sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rolldown/binding-linux-arm64-musl@1.0.2':
|
'@rolldown/binding-linux-arm64-musl@1.0.2':
|
||||||
resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==}
|
resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rolldown/binding-linux-ppc64-gnu@1.0.0':
|
'@rolldown/binding-linux-ppc64-gnu@1.0.0':
|
||||||
resolution: {integrity: sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==}
|
resolution: {integrity: sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rolldown/binding-linux-ppc64-gnu@1.0.2':
|
'@rolldown/binding-linux-ppc64-gnu@1.0.2':
|
||||||
resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==}
|
resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rolldown/binding-linux-s390x-gnu@1.0.0':
|
'@rolldown/binding-linux-s390x-gnu@1.0.0':
|
||||||
resolution: {integrity: sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==}
|
resolution: {integrity: sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rolldown/binding-linux-s390x-gnu@1.0.2':
|
'@rolldown/binding-linux-s390x-gnu@1.0.2':
|
||||||
resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==}
|
resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rolldown/binding-linux-x64-gnu@1.0.0':
|
'@rolldown/binding-linux-x64-gnu@1.0.0':
|
||||||
resolution: {integrity: sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==}
|
resolution: {integrity: sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rolldown/binding-linux-x64-gnu@1.0.2':
|
'@rolldown/binding-linux-x64-gnu@1.0.2':
|
||||||
resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==}
|
resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rolldown/binding-linux-x64-musl@1.0.0':
|
'@rolldown/binding-linux-x64-musl@1.0.0':
|
||||||
resolution: {integrity: sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==}
|
resolution: {integrity: sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rolldown/binding-linux-x64-musl@1.0.2':
|
'@rolldown/binding-linux-x64-musl@1.0.2':
|
||||||
resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==}
|
resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rolldown/binding-openharmony-arm64@1.0.0':
|
'@rolldown/binding-openharmony-arm64@1.0.0':
|
||||||
resolution: {integrity: sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==}
|
resolution: {integrity: sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==}
|
||||||
@@ -1940,28 +1920,24 @@ packages:
|
|||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
lightningcss-linux-arm64-musl@1.32.0:
|
lightningcss-linux-arm64-musl@1.32.0:
|
||||||
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
|
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
lightningcss-linux-x64-gnu@1.32.0:
|
lightningcss-linux-x64-gnu@1.32.0:
|
||||||
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
lightningcss-linux-x64-musl@1.32.0:
|
lightningcss-linux-x64-musl@1.32.0:
|
||||||
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
|
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
lightningcss-win32-arm64-msvc@1.32.0:
|
lightningcss-win32-arm64-msvc@1.32.0:
|
||||||
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
|
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
|
||||||
@@ -2219,13 +2195,6 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
react-router-dom@6.30.3:
|
|
||||||
resolution: {integrity: sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==}
|
|
||||||
engines: {node: '>=14.0.0'}
|
|
||||||
peerDependencies:
|
|
||||||
react: '>=16.8'
|
|
||||||
react-dom: '>=16.8'
|
|
||||||
|
|
||||||
react-router-dom@7.15.0:
|
react-router-dom@7.15.0:
|
||||||
resolution: {integrity: sha512-VcrVg64Fo8nwBvDscajG8gRTLIuTC6N50nb22l2HOOV4PTOHgoGp8mUjy9wLiHYoYTSYI36tUnXZgasSRFZorQ==}
|
resolution: {integrity: sha512-VcrVg64Fo8nwBvDscajG8gRTLIuTC6N50nb22l2HOOV4PTOHgoGp8mUjy9wLiHYoYTSYI36tUnXZgasSRFZorQ==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
@@ -2233,11 +2202,12 @@ packages:
|
|||||||
react: '>=18'
|
react: '>=18'
|
||||||
react-dom: '>=18'
|
react-dom: '>=18'
|
||||||
|
|
||||||
react-router@6.30.3:
|
react-router-dom@7.16.0:
|
||||||
resolution: {integrity: sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==}
|
resolution: {integrity: sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: '>=16.8'
|
react: '>=18'
|
||||||
|
react-dom: '>=18'
|
||||||
|
|
||||||
react-router@7.15.0:
|
react-router@7.15.0:
|
||||||
resolution: {integrity: sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ==}
|
resolution: {integrity: sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ==}
|
||||||
@@ -2249,6 +2219,16 @@ packages:
|
|||||||
react-dom:
|
react-dom:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
react-router@7.16.0:
|
||||||
|
resolution: {integrity: sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=18'
|
||||||
|
react-dom: '>=18'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
react-dom:
|
||||||
|
optional: true
|
||||||
|
|
||||||
react-style-singleton@2.2.3:
|
react-style-singleton@2.2.3:
|
||||||
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
|
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -3210,8 +3190,6 @@ snapshots:
|
|||||||
|
|
||||||
'@radix-ui/rect@1.1.1': {}
|
'@radix-ui/rect@1.1.1': {}
|
||||||
|
|
||||||
'@remix-run/router@1.23.2': {}
|
|
||||||
|
|
||||||
'@rolldown/binding-android-arm64@1.0.0':
|
'@rolldown/binding-android-arm64@1.0.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -4199,23 +4177,17 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.14
|
'@types/react': 19.2.14
|
||||||
|
|
||||||
react-router-dom@6.30.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
|
||||||
dependencies:
|
|
||||||
'@remix-run/router': 1.23.2
|
|
||||||
react: 19.2.6
|
|
||||||
react-dom: 19.2.6(react@19.2.6)
|
|
||||||
react-router: 6.30.3(react@19.2.6)
|
|
||||||
|
|
||||||
react-router-dom@7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
react-router-dom@7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.6
|
react: 19.2.6
|
||||||
react-dom: 19.2.6(react@19.2.6)
|
react-dom: 19.2.6(react@19.2.6)
|
||||||
react-router: 7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
react-router: 7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
|
|
||||||
react-router@6.30.3(react@19.2.6):
|
react-router-dom@7.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@remix-run/router': 1.23.2
|
|
||||||
react: 19.2.6
|
react: 19.2.6
|
||||||
|
react-dom: 19.2.6(react@19.2.6)
|
||||||
|
react-router: 7.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
|
|
||||||
react-router@7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
react-router@7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -4225,6 +4197,14 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
react-dom: 19.2.6(react@19.2.6)
|
react-dom: 19.2.6(react@19.2.6)
|
||||||
|
|
||||||
|
react-router@7.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||||
|
dependencies:
|
||||||
|
cookie: 1.1.1
|
||||||
|
react: 19.2.6
|
||||||
|
set-cookie-parser: 2.7.2
|
||||||
|
optionalDependencies:
|
||||||
|
react-dom: 19.2.6(react@19.2.6)
|
||||||
|
|
||||||
react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.6):
|
react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.6):
|
||||||
dependencies:
|
dependencies:
|
||||||
get-nonce: 1.0.1
|
get-nonce: 1.0.1
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ ensure_frontend_dependencies() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
if [ "$WORKSPACE_DIR" = "/workspace/common" ]; then
|
if [ "$WORKSPACE_DIR" = "/workspace/common" ]; then
|
||||||
(cd /workspace/common && CI=true pnpm install --filter "${APP_WORKSPACE_FILTER}..." --no-frozen-lockfile --ignore-scripts)
|
(cd /workspace/common && CI=true pnpm install --filter "${APP_WORKSPACE_FILTER}..." --frozen-lockfile --ignore-scripts)
|
||||||
else
|
else
|
||||||
npm ci
|
npm ci
|
||||||
fi
|
fi
|
||||||
|
|||||||
349
docs/backup-restore-design.md
Normal file
349
docs/backup-restore-design.md
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
# Baron SSO 전체 시스템 백업 및 복구 설계
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
Baron SSO의 전체 시스템 백업/복구는 CSV export/import의 확장판이 아니라, 서비스 저장소 전체를 일관된 시점으로 보존하고 복원하는 재해 복구 기능이다.
|
||||||
|
|
||||||
|
핵심 목표는 다음과 같다.
|
||||||
|
|
||||||
|
- 사용자, 조직, 권한, RP, WORKS 연동 참조에 쓰이는 UUID를 그대로 보존한다.
|
||||||
|
- Kratos identity subject와 Baron local user ID가 어긋나지 않게 복구한다.
|
||||||
|
- Hydra/Keto/Oathkeeper 기반 인증/인가 상태를 서비스 가능한 수준으로 복원한다.
|
||||||
|
- 복구 후 WORKS externalKey 기반 비교/동기화가 기존 연동과 이어지도록 한다.
|
||||||
|
- 백업 산출물의 무결성, 보안, 복구 가능성을 검증 가능한 절차로 만든다.
|
||||||
|
|
||||||
|
## 배경과 결론
|
||||||
|
|
||||||
|
사용자 CSV는 `user_id`를 포함해 내보낼 수 있지만, 실제 사용자 계정의 주체 ID는 Kratos identity ID다. Kratos Admin API의 identity 생성 요청은 `id` 필드를 받지 않으므로, CSV import만으로 기존 사용자 UUID를 보장할 수 없다.
|
||||||
|
|
||||||
|
따라서 사용자 UUID 보존이 필요한 복구는 반드시 Kratos DB까지 포함한 full backup/restore로 처리해야 한다. CSV import는 운영 편의 기능으로 유지하되, 재해 복구나 WORKS 연동 보존 목적의 원장 복구 수단으로 간주하지 않는다.
|
||||||
|
|
||||||
|
## 백업 대상
|
||||||
|
|
||||||
|
### 필수 저장소
|
||||||
|
|
||||||
|
| 대상 | 저장소 | 포함 이유 | 복구 필수 여부 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Baron 애플리케이션 DB | `baron_postgres` | users, tenants, user_login_ids, user_groups, api_keys, outbox, WORKS mapping, RP metadata 등 | 필수 |
|
||||||
|
| Kratos DB | `ory_postgres`의 `ory_kratos` | identity UUID, credentials, verifiable/recovery addresses, sessions | 필수 |
|
||||||
|
| Hydra DB | `ory_postgres`의 `ory_hydra` | OAuth2 clients, consent, token/session 관련 상태 | 필수 |
|
||||||
|
| Keto DB | `ory_postgres`의 `ory_keto` | ReBAC relation tuple 원장 | 필수 |
|
||||||
|
| Baron ClickHouse | `baron_clickhouse` | 감사 로그, 운영 추적 데이터 | 운영 정책상 필수 |
|
||||||
|
| Ory ClickHouse | `ory_clickhouse` | Ory/Oathkeeper/Vector 계열 로그 | 운영 정책상 필수 |
|
||||||
|
| 설정/비밀값 | `.env`, generated Ory config, WORKS private key, gateway/Oathkeeper config | 동일 환경 재기동과 외부 연동에 필요 | 필수 |
|
||||||
|
|
||||||
|
### 선택 저장소
|
||||||
|
|
||||||
|
| 대상 | 처리 원칙 |
|
||||||
|
| --- | --- |
|
||||||
|
| Redis | 로그인 pending state, cache, short code 등 휘발성 데이터다. full restore에서는 원칙적으로 제외하고 재시작 시 비운다. 무중단 이전 시나리오에서만 snapshot을 검토한다. |
|
||||||
|
| 프론트 빌드 산출물 | 소스/이미지 태그로 재생성한다. 별도 보관은 배포 재현성을 위한 선택 항목이다. |
|
||||||
|
| 로컬 개발 산출물 | reports, coverage, test-results 등은 백업 대상에서 제외한다. |
|
||||||
|
|
||||||
|
## 백업 산출물 형식
|
||||||
|
|
||||||
|
백업 단위는 압축된 디렉터리 또는 object storage prefix로 관리한다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
baron-sso-backup-YYYYMMDD-HHMMSSZ/
|
||||||
|
manifest.json
|
||||||
|
checksums.sha256
|
||||||
|
postgres/
|
||||||
|
baron.dump
|
||||||
|
ory_kratos.dump
|
||||||
|
ory_hydra.dump
|
||||||
|
ory_keto.dump
|
||||||
|
globals.sql
|
||||||
|
clickhouse/
|
||||||
|
baron_clickhouse/
|
||||||
|
ory_clickhouse/
|
||||||
|
config/
|
||||||
|
env.redacted
|
||||||
|
env.encrypted
|
||||||
|
ory/
|
||||||
|
gateway/
|
||||||
|
reports/
|
||||||
|
row-counts.json
|
||||||
|
restore-readiness.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`manifest.json`에는 최소한 다음 정보를 기록한다.
|
||||||
|
|
||||||
|
- backup format version
|
||||||
|
- 생성 시각, git commit, 이미지 태그
|
||||||
|
- 서비스별 DB schema/migration version
|
||||||
|
- 각 dump 파일의 checksum, 크기, 생성 명령 버전
|
||||||
|
- 백업 모드: `offline`, `maintenance`, `online-best-effort`
|
||||||
|
- 암호화 방식과 key id
|
||||||
|
- 복구 대상 환경 제한: `same-env-only`, `staging-rehearsal`, `cross-env`
|
||||||
|
|
||||||
|
## 백업 모드
|
||||||
|
|
||||||
|
### 1. Offline backup
|
||||||
|
|
||||||
|
가장 안전한 모드다. 모든 writer를 중지한 뒤 dump한다.
|
||||||
|
|
||||||
|
순서:
|
||||||
|
|
||||||
|
1. gateway 또는 Oathkeeper에서 maintenance mode 활성화
|
||||||
|
2. backend, relay worker, vector 등 write producer 중지
|
||||||
|
3. Kratos/Hydra/Keto public/admin 요청 차단 또는 컨테이너 중지
|
||||||
|
4. Baron Postgres dump
|
||||||
|
5. Ory Postgres의 Kratos/Hydra/Keto DB dump
|
||||||
|
6. ClickHouse backup
|
||||||
|
7. 설정/비밀값 백업
|
||||||
|
8. checksum과 row count 생성
|
||||||
|
9. 서비스 재개
|
||||||
|
|
||||||
|
이 모드는 사용자 UUID, WORKS mapping, Keto relation, OAuth consent 상태의 일관성이 가장 좋다.
|
||||||
|
|
||||||
|
### 2. Maintenance backup
|
||||||
|
|
||||||
|
짧은 점검 모드에서 writer만 막고 read는 제한적으로 허용한다. 운영 기본 모드로 권장한다.
|
||||||
|
|
||||||
|
필수 조건:
|
||||||
|
|
||||||
|
- 사용자 생성/삭제/수정 차단
|
||||||
|
- 테넌트/조직 변경 차단
|
||||||
|
- WORKS outbox relay 중지
|
||||||
|
- Keto outbox relay 중지
|
||||||
|
- OAuth client 변경 차단
|
||||||
|
|
||||||
|
### 3. Online best-effort backup
|
||||||
|
|
||||||
|
무중단 스냅샷이다. 저장소가 여러 개라 cross-store 일관성을 보장할 수 없다. 감사 로그나 분석용 백업에는 가능하지만, 재해 복구용 원본으로는 사용하지 않는다.
|
||||||
|
|
||||||
|
## Postgres 백업 전략
|
||||||
|
|
||||||
|
Postgres는 논리 dump를 기본으로 한다.
|
||||||
|
|
||||||
|
- `pg_dump -Fc` 형식 사용
|
||||||
|
- DB별 dump 파일 분리
|
||||||
|
- `pg_dumpall --globals-only`로 role/extension/권한 정보 별도 보관
|
||||||
|
- 백업 전후 row count와 핵심 UUID sample 기록
|
||||||
|
|
||||||
|
대상 DB:
|
||||||
|
|
||||||
|
- Baron DB: `DB_NAME`
|
||||||
|
- Kratos DB: `ory_kratos`
|
||||||
|
- Hydra DB: `ory_hydra`
|
||||||
|
- Keto DB: `ory_keto`
|
||||||
|
|
||||||
|
복구는 빈 DB에 `pg_restore --clean --if-exists`로 수행한다. 기존 운영 DB에 덮어쓰는 in-place restore는 금지한다.
|
||||||
|
|
||||||
|
## ClickHouse 백업 전략
|
||||||
|
|
||||||
|
ClickHouse는 감사 로그 성격이 강하므로 정책을 분리한다.
|
||||||
|
|
||||||
|
- 재해 복구: ClickHouse native backup 또는 volume snapshot 사용
|
||||||
|
- 장기 보관: 파티션 단위 export 또는 object storage backup
|
||||||
|
- 복구 검증: 날짜 파티션별 row count와 min/max timestamp 비교
|
||||||
|
|
||||||
|
ClickHouse 백업 실패가 인증 기능 복구를 막지는 않지만, 감사 로그 보존 정책상 별도 실패로 취급한다.
|
||||||
|
|
||||||
|
## Redis 처리 원칙
|
||||||
|
|
||||||
|
Redis는 기본적으로 백업하지 않는다.
|
||||||
|
|
||||||
|
복구 후 영향:
|
||||||
|
|
||||||
|
- 로그인 pending flow 만료
|
||||||
|
- short code/link login flow 재시작 필요
|
||||||
|
- headless JWKS cache 재생성
|
||||||
|
- 세션 cache miss 발생 가능
|
||||||
|
|
||||||
|
Kratos/Hydra 자체 session/token 원장은 Postgres 쪽에 있으므로 Redis가 비어 있어도 서비스는 재수렴해야 한다.
|
||||||
|
|
||||||
|
## 설정과 비밀값
|
||||||
|
|
||||||
|
DB dump만으로는 복구가 불완전하다. 다음 항목은 암호화해서 함께 보관한다.
|
||||||
|
|
||||||
|
- `.env` 또는 배포 환경 변수
|
||||||
|
- Ory generated config
|
||||||
|
- Hydra system secret, cookie secret
|
||||||
|
- Kratos courier/config secret
|
||||||
|
- Keto config
|
||||||
|
- Oathkeeper rules/config
|
||||||
|
- WORKS Admin OAuth client private key
|
||||||
|
- API gateway 설정
|
||||||
|
- object storage backup key id
|
||||||
|
|
||||||
|
`env.redacted`는 검토용이고, 실제 복구에는 `env.encrypted`만 사용한다.
|
||||||
|
|
||||||
|
## 복구 절차
|
||||||
|
|
||||||
|
### Full restore 기본 절차
|
||||||
|
|
||||||
|
1. 대상 환경을 새로 준비한다.
|
||||||
|
2. 모든 애플리케이션 서비스를 중지한다.
|
||||||
|
3. 기존 DB가 있으면 별도 보관 후 빈 DB를 만든다.
|
||||||
|
4. Postgres globals를 복구한다.
|
||||||
|
5. Baron DB를 복구한다.
|
||||||
|
6. Kratos/Hydra/Keto DB를 복구한다.
|
||||||
|
7. ClickHouse를 복구한다.
|
||||||
|
8. 설정/비밀값을 복호화해 배치한다.
|
||||||
|
9. migration은 자동 실행하지 않고 현재 dump의 schema version을 확인한다.
|
||||||
|
10. Ory Stack을 기동한다.
|
||||||
|
11. backend를 기동한다.
|
||||||
|
12. relay worker는 아직 켜지 않는다.
|
||||||
|
13. post-restore verification을 수행한다.
|
||||||
|
14. WORKS comparison dry-run을 수행한다.
|
||||||
|
15. 문제가 없을 때 relay worker와 외부 동기화를 재개한다.
|
||||||
|
|
||||||
|
### Post-restore verification
|
||||||
|
|
||||||
|
필수 검증:
|
||||||
|
|
||||||
|
- Kratos identity 수와 Baron users 수 비교
|
||||||
|
- Baron `users.id`가 Kratos `identities.id`에 존재하는지 확인
|
||||||
|
- tenant parent tree 참조 무결성 확인
|
||||||
|
- `user_login_ids.user_id`, `user_login_ids.tenant_id` 참조 무결성 확인
|
||||||
|
- Keto relation subject/object가 복구된 사용자/테넌트를 참조하는지 확인
|
||||||
|
- Hydra client와 Baron RP metadata 참조 확인
|
||||||
|
- WORKS mapping의 BaronResourceID가 복구된 사용자/테넌트를 참조하는지 확인
|
||||||
|
- super admin 로그인 확인
|
||||||
|
- 일반 사용자 로그인 확인
|
||||||
|
- 대표 RP OIDC login/consent 확인
|
||||||
|
- WORKS comparison dry-run에서 externalKey 기준 대량 삭제/생성 후보가 없는지 확인
|
||||||
|
|
||||||
|
## WORKS 연동 복구 정책
|
||||||
|
|
||||||
|
WORKS 자체 데이터는 Baron 백업으로 복구하지 않는다. Baron이 보존해야 하는 것은 WORKS와 연결되는 참조 키다.
|
||||||
|
|
||||||
|
필수 보존:
|
||||||
|
|
||||||
|
- 사용자 UUID
|
||||||
|
- 테넌트 UUID
|
||||||
|
- WORKS resource mapping
|
||||||
|
- WORKS outbox 처리 상태
|
||||||
|
- WORKS domain mapping/config
|
||||||
|
|
||||||
|
복구 직후 정책:
|
||||||
|
|
||||||
|
- relay worker 자동 실행 금지
|
||||||
|
- comparison dry-run 먼저 실행
|
||||||
|
- externalKey 기준으로 Baron/WORKS가 매칭되는지 확인
|
||||||
|
- 대량 delete/upsert가 감지되면 동기화 중단
|
||||||
|
- 확인 후 필요한 사용자/조직만 재동기화
|
||||||
|
|
||||||
|
outbox는 복구 모드에 따라 처리한다.
|
||||||
|
|
||||||
|
| 복구 모드 | outbox 정책 |
|
||||||
|
| --- | --- |
|
||||||
|
| 같은 운영 환경 재해 복구 | outbox 상태 보존, 단 relay 재개 전 dry-run 필수 |
|
||||||
|
| staging rehearsal | outbox relay 비활성화, 외부 WORKS 호출 금지 |
|
||||||
|
| cross-env migration | outbox는 보존하되 실행하지 않고 별도 remap 정책 필요 |
|
||||||
|
|
||||||
|
## CSV export/import의 위치
|
||||||
|
|
||||||
|
CSV는 다음 목적으로만 사용한다.
|
||||||
|
|
||||||
|
- 운영자가 사용자/조직을 일괄 등록하거나 보정
|
||||||
|
- 일부 필드를 검토하기 위한 추출
|
||||||
|
- dry-run 입력 보조 자료
|
||||||
|
|
||||||
|
CSV는 다음 목적에 사용하지 않는다.
|
||||||
|
|
||||||
|
- Kratos identity UUID 보존 복구
|
||||||
|
- 비밀번호/credential 복구
|
||||||
|
- OAuth consent/token/session 복구
|
||||||
|
- Keto relation 원장 복구
|
||||||
|
- WORKS mapping 원장 복구
|
||||||
|
|
||||||
|
## 구현 계획
|
||||||
|
|
||||||
|
### Phase 1: 백업/복구 스크립트와 문서화
|
||||||
|
|
||||||
|
Estimate Time: 3~5d
|
||||||
|
|
||||||
|
- `scripts/backup/full-backup.sh`
|
||||||
|
- `scripts/backup/full-restore.sh`
|
||||||
|
- `scripts/backup/verify-backup.sh`
|
||||||
|
- `scripts/backup/verify-restore.sh`
|
||||||
|
- `manifest.json` 생성기
|
||||||
|
- checksum 생성
|
||||||
|
- row count report 생성
|
||||||
|
|
||||||
|
### Phase 2: staging restore rehearsal
|
||||||
|
|
||||||
|
Estimate Time: 3~5d
|
||||||
|
|
||||||
|
- 백업 파일로 격리된 staging stack 복구
|
||||||
|
- Ory/Baron/Postgres/ClickHouse 복구 검증
|
||||||
|
- 로그인/OIDC/관리자 화면 smoke test
|
||||||
|
- WORKS 외부 호출 차단 상태에서 comparison dry-run
|
||||||
|
|
||||||
|
### Phase 3: 운영 자동화
|
||||||
|
|
||||||
|
Estimate Time: 5~8d
|
||||||
|
|
||||||
|
- 정기 백업 스케줄링
|
||||||
|
- 암호화 및 object storage 업로드
|
||||||
|
- retention 정책
|
||||||
|
- 실패 알림
|
||||||
|
- restore rehearsal 주기화
|
||||||
|
|
||||||
|
### Phase 4: 관리 UI
|
||||||
|
|
||||||
|
Estimate Time: 5~8d
|
||||||
|
|
||||||
|
- backup 목록 조회
|
||||||
|
- backup 생성 요청
|
||||||
|
- restore readiness report 조회
|
||||||
|
- staging rehearsal 결과 조회
|
||||||
|
- 운영 restore는 UI에서 직접 실행하지 않고 승인 절차와 runbook으로 제한
|
||||||
|
|
||||||
|
## 테스트 전략
|
||||||
|
|
||||||
|
### 단위 테스트
|
||||||
|
|
||||||
|
- manifest 생성 검증
|
||||||
|
- checksum 검증
|
||||||
|
- row count report 비교
|
||||||
|
- restore readiness parser 검증
|
||||||
|
|
||||||
|
### 통합 테스트
|
||||||
|
|
||||||
|
- fixture DB 생성
|
||||||
|
- full backup 실행
|
||||||
|
- 빈 DB로 restore
|
||||||
|
- 핵심 테이블 row count 비교
|
||||||
|
- 사용자/테넌트 UUID 동일성 비교
|
||||||
|
- Kratos identity와 Baron user ID 일치 검증
|
||||||
|
|
||||||
|
### E2E 테스트
|
||||||
|
|
||||||
|
- super admin 로그인
|
||||||
|
- 일반 사용자 로그인
|
||||||
|
- AdminFront 사용자 목록 조회
|
||||||
|
- UserFront 로그인
|
||||||
|
- 대표 RP OIDC 로그인
|
||||||
|
- WORKS comparison dry-run
|
||||||
|
|
||||||
|
### 실패 테스트
|
||||||
|
|
||||||
|
- 누락된 dump 파일
|
||||||
|
- checksum 불일치
|
||||||
|
- schema version 불일치
|
||||||
|
- 일부 DB만 복구된 상태
|
||||||
|
- Kratos identity는 있는데 Baron user가 없는 상태
|
||||||
|
- Baron user는 있는데 Kratos identity가 없는 상태
|
||||||
|
- WORKS mapping이 복구되지 않은 상태
|
||||||
|
|
||||||
|
## 운영 정책
|
||||||
|
|
||||||
|
- 백업은 암호화하지 않은 상태로 저장하지 않는다.
|
||||||
|
- 운영 restore는 빈 환경 또는 새 볼륨에만 수행한다.
|
||||||
|
- restore 전 현재 운영 DB는 별도 snapshot으로 보존한다.
|
||||||
|
- restore 후 WORKS relay는 수동 승인 전까지 비활성화한다.
|
||||||
|
- 월 1회 이상 staging restore rehearsal을 수행한다.
|
||||||
|
- schema migration 직전 수동 backup을 강제한다.
|
||||||
|
|
||||||
|
## 남은 결정 사항
|
||||||
|
|
||||||
|
- RPO/RTO 목표값
|
||||||
|
- 백업 저장 위치와 암호화 key 관리 방식
|
||||||
|
- ClickHouse 장기 보관 기간
|
||||||
|
- WORKS outbox replay 정책의 운영 기본값
|
||||||
|
- 운영 restore 승인자와 절차
|
||||||
|
- restore rehearsal 자동 실행 주기
|
||||||
@@ -106,7 +106,7 @@ ensure_frontend_dependencies() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
if [ "$WORKSPACE_DIR" = "/workspace/common" ]; then
|
if [ "$WORKSPACE_DIR" = "/workspace/common" ]; then
|
||||||
(cd /workspace/common && CI=true pnpm install --filter "${APP_WORKSPACE_FILTER}..." --no-frozen-lockfile --ignore-scripts)
|
(cd /workspace/common && CI=true pnpm install --filter "${APP_WORKSPACE_FILTER}..." --frozen-lockfile --ignore-scripts)
|
||||||
else
|
else
|
||||||
npm ci
|
npm ci
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ tskim@samaneng.com,김태식A,010-9965-9940,user,rnd-saman,,,,,222182,design-pla
|
|||||||
jhkang@samaneng.com,강정훈,010-9891-8798,user,rnd-saman,,,,,222212,strana,,연구원,,,B22048,b22048@hanmaceng.co.kr
|
jhkang@samaneng.com,강정훈,010-9891-8798,user,rnd-saman,,,,,222212,strana,,연구원,,,B22048,b22048@hanmaceng.co.kr
|
||||||
jhkim14@samaneng.com,김재현,010-2534-7837,user,rnd-saman,,,,,222231,watch-bim,,수석연구원,,,B22051,b22051@hanmaceng.co.kr
|
jhkim14@samaneng.com,김재현,010-2534-7837,user,rnd-saman,,,,,222231,watch-bim,,수석연구원,,,B22051,b22051@hanmaceng.co.kr
|
||||||
yjchoi1@samaneng.com,최윤진,010-2349-6687,user,rnd-saman,,,,,222240,way-draw,,연구원,,,B22052,b22052@hanmaceng.co.kr
|
yjchoi1@samaneng.com,최윤진,010-2349-6687,user,rnd-saman,,,,,222240,way-draw,,연구원,,,B22052,b22052@hanmaceng.co.kr
|
||||||
wkkim@samaneng.com,김원기,010-4727-8530,user,rnd-saman,,,,,222242,infra-bim1,,책임연구원,,,B22057,kwongi79@hanmaceng.co.kr
|
wkkim@samaneng.com,김원기,010-4727-8530,user,rnd-saman,,,,,222242,infra-bim1,,책임연구원,,,B22057,
|
||||||
jhlee@samaneng.com,이준호,010-2514-6898,user,rnd-saman,,,,,223046,structural-software,,연구원,,,B23003,b23003@hanmaceng.co.kr
|
jhlee@samaneng.com,이준호,010-2514-6898,user,rnd-saman,,,,,223046,structural-software,,연구원,,,B23003,b23003@hanmaceng.co.kr
|
||||||
jhchoi3@samaneng.com,최진헌,010-8638-8079,user,rnd-saman,,,,,222272,strana,,선임연구원,,,B22063,b22063@hanmaceng.co.kr
|
jhchoi3@samaneng.com,최진헌,010-8638-8079,user,rnd-saman,,,,,222272,strana,,선임연구원,,,B22063,b22063@hanmaceng.co.kr
|
||||||
hulee1@samaneng.com,이한울,010-9271-8997,user,rnd-saman,,,,,222294,web-design,,연구원,,,B22069,b22069@hanmaceng.co.kr
|
hulee1@samaneng.com,이한울,010-9271-8997,user,rnd-saman,,,,,222294,web-design,,연구원,,,B22069,b22069@hanmaceng.co.kr
|
||||||
@@ -94,7 +94,7 @@ hmin@samaneng.com,민홍,010-8654-5461,user,rnd-saman,,,,,223313,gsim,,선임연
|
|||||||
hwan@samaneng.com,안효원,010-3358-4260,user,rnd-saman,,,,,223228,infra-bim1,,선임연구원,,,B23040,b23040@hanmaceng.co.kr
|
hwan@samaneng.com,안효원,010-3358-4260,user,rnd-saman,,,,,223228,infra-bim1,,선임연구원,,,B23040,b23040@hanmaceng.co.kr
|
||||||
sihan@samaneng.com,한성일,010-4322-1100,user,rnd-saman,,,,,223226,abut-control,,책임연구원,,,B23042,b23042@hanmaceng.co.kr
|
sihan@samaneng.com,한성일,010-4322-1100,user,rnd-saman,,,,,223226,abut-control,,책임연구원,,,B23042,b23042@hanmaceng.co.kr
|
||||||
jhkim25@samaneng.com,김재환,010-8962-3743,user,rnd-saman,,,,,223229,structural-design,,책임연구원,,,B23041,b23041@hanmaceng.co.kr
|
jhkim25@samaneng.com,김재환,010-8962-3743,user,rnd-saman,,,,,223229,structural-design,,책임연구원,,,B23041,b23041@hanmaceng.co.kr
|
||||||
gy9411@naver.com,이가연,010-2430-5102,user,rnd-saman,,,,,223269,slope-structures,,연구원,,,B23047,b23047@hanmaceng.co.kr
|
gylee1@samaneng.com,이가연,010-2430-5102,user,rnd-saman,,,,,223269,slope-structures,,연구원,,,B23047,b23047@hanmaceng.co.kr
|
||||||
yskim3@samaneng.com,김예서,010-9167-6132,user,rnd-saman,,,,,223280,land-map-cell,,연구원,,,B23051,b23051@hanmaceng.co.kr
|
yskim3@samaneng.com,김예서,010-9167-6132,user,rnd-saman,,,,,223280,land-map-cell,,연구원,,,B23051,b23051@hanmaceng.co.kr
|
||||||
jhpyo@samaneng.com,표재학,010-2522-4984,user,rnd-saman,,,,,223281,primal-plan,,연구원,,,B23052,b23052@hanmaceng.co.kr
|
jhpyo@samaneng.com,표재학,010-2522-4984,user,rnd-saman,,,,,223281,primal-plan,,연구원,,,B23052,b23052@hanmaceng.co.kr
|
||||||
sjkim6@samaneng.com,김신지,010-7667-8256,user,rnd-saman,,,,,223361,tech-planning,,연구원,,,B23064,b23064@hanmaceng.co.kr
|
sjkim6@samaneng.com,김신지,010-7667-8256,user,rnd-saman,,,,,223361,tech-planning,,연구원,,,B23064,b23064@hanmaceng.co.kr
|
||||||
@@ -140,7 +140,7 @@ hrlee1@samaneng.com,이해랑,010-8628-0094,user,rnd-saman,,,,,225175,modeler,,
|
|||||||
jhsim@samaneng.com,심재훈,010-6633-3366,user,rnd-saman,,,,,225183,tunnel,,수석연구원,,,B25025,b25025@hanmaceng.co.kr
|
jhsim@samaneng.com,심재훈,010-6633-3366,user,rnd-saman,,,,,225183,tunnel,,수석연구원,,,B25025,b25025@hanmaceng.co.kr
|
||||||
shkim4@samaneng.com,김수현,010-5645-5153,user,rnd-saman,,,,,225215,design-planning,,선임연구원,,,B25027,b25027@hanmaceng.co.kr
|
shkim4@samaneng.com,김수현,010-5645-5153,user,rnd-saman,,,,,225215,design-planning,,선임연구원,,,B25027,b25027@hanmaceng.co.kr
|
||||||
smbaek@samaneng.com,백승민,010-7156-8542,user,rnd-saman,,,,,225319,hmeg,,책임연구원,,,B25035,b25035@hanmaceng.co.kr
|
smbaek@samaneng.com,백승민,010-7156-8542,user,rnd-saman,,,,,225319,hmeg,,책임연구원,,,B25035,b25035@hanmaceng.co.kr
|
||||||
swpark3@saman.com,박상원,010-4794-0148,user,rnd-saman,,,,,225336,cm-planning,,연구원,,,B25036,b25036@hanmaceng.co.kr
|
swpark3@samaneng.com,박상원,010-4794-0148,user,rnd-saman,,,,,225336,cm-planning,,연구원,,,B25036,b25036@hanmaceng.co.kr
|
||||||
smyoun@samaneng.com,윤석무,010-9780-8901,user,rnd-saman,,,,,226049,solution-dev,,연구원,,,B26002,b26002@hanmaceng.co.kr
|
smyoun@samaneng.com,윤석무,010-9780-8901,user,rnd-saman,,,,,226049,solution-dev,,연구원,,,B26002,b26002@hanmaceng.co.kr
|
||||||
jhpark4@samaneng.com,박종혁,010-4211-2090,user,rnd-saman,,,,,226072,infra-bim2,,연구원,,,B26003,b26003@hanmaceng.co.kr
|
jhpark4@samaneng.com,박종혁,010-4211-2090,user,rnd-saman,,,,,226072,infra-bim2,,연구원,,,B26003,b26003@hanmaceng.co.kr
|
||||||
dhhong@samaneng.com,홍덕현,010-5360-7314,user,rnd-saman,,,,,226073,structural-design,,연구원,,,B26004,b26004@hanmaceng.co.kr
|
dhhong@samaneng.com,홍덕현,010-5360-7314,user,rnd-saman,,,,,226073,structural-design,,연구원,,,B26004,b26004@hanmaceng.co.kr
|
||||||
|
|||||||
|
@@ -27,11 +27,71 @@ warm_get() {
|
|||||||
"http://127.0.0.1:${USERFRONT_INTERNAL_PORT}${path}" >/dev/null 2>&1
|
"http://127.0.0.1:${USERFRONT_INTERNAL_PORT}${path}" >/dev/null 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
wait_for_userfront_build() {
|
||||||
|
flutter_pid="$1"
|
||||||
|
attempt=1
|
||||||
|
|
||||||
|
while [ "$attempt" -le "$USERFRONT_BOOT_WARMUP_ATTEMPTS" ]; do
|
||||||
|
if [ -f "build/web/index.html" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if ! kill -0 "$flutter_pid" 2>/dev/null; then
|
||||||
|
echo "[userfront-boot] warmup skipped because flutter exited before build/web/index.html was ready" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
sleep "$USERFRONT_BOOT_WARMUP_INTERVAL_SECONDS"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[userfront-boot] warmup skipped after ${USERFRONT_BOOT_WARMUP_ATTEMPTS} build readiness attempts" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
reset_userfront_service_worker() {
|
||||||
|
cat > build/web/flutter_service_worker.js <<'EOF'
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
if (self.caches) {
|
||||||
|
const keys = await self.caches.keys();
|
||||||
|
await Promise.all(
|
||||||
|
keys
|
||||||
|
.filter(
|
||||||
|
(key) =>
|
||||||
|
key.indexOf("baron-userfront-") === 0 ||
|
||||||
|
key.indexOf("flutter-app-cache") === 0,
|
||||||
|
)
|
||||||
|
.map((key) => self.caches.delete(key)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await self.registration.unregister();
|
||||||
|
|
||||||
|
const clients = await self.clients.matchAll({
|
||||||
|
type: "window",
|
||||||
|
includeUncontrolled: true,
|
||||||
|
});
|
||||||
|
await Promise.all(clients.map((client) => client.navigate(client.url)));
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
warm_userfront_once() {
|
warm_userfront_once() {
|
||||||
flutter_pid="$1"
|
flutter_pid="$1"
|
||||||
attempt=1
|
attempt=1
|
||||||
started_at="$(date +%s)"
|
started_at="$(date +%s)"
|
||||||
|
|
||||||
|
if ! wait_for_userfront_build "$flutter_pid"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
reset_userfront_service_worker
|
||||||
|
|
||||||
while [ "$attempt" -le "$USERFRONT_BOOT_WARMUP_ATTEMPTS" ]; do
|
while [ "$attempt" -le "$USERFRONT_BOOT_WARMUP_ATTEMPTS" ]; do
|
||||||
if wget -qO- "http://127.0.0.1:${USERFRONT_INTERNAL_PORT}/flutter_bootstrap.js" >/dev/null 2>&1; then
|
if wget -qO- "http://127.0.0.1:${USERFRONT_INTERNAL_PORT}/flutter_bootstrap.js" >/dev/null 2>&1; then
|
||||||
break
|
break
|
||||||
|
|||||||
Reference in New Issue
Block a user