forked from baron/baron-sso
조직 연동 오류 해결
This commit is contained in:
23
.playwright-mcp/page-2026-05-20T02-00-01-354Z.yml
Normal file
23
.playwright-mcp/page-2026-05-20T02-00-01-354Z.yml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
- generic [ref=e4]:
|
||||||
|
- generic [ref=e5]:
|
||||||
|
- img [ref=e7]
|
||||||
|
- generic [ref=e9]:
|
||||||
|
- heading "Baron SSO" [level=1] [ref=e10]
|
||||||
|
- paragraph [ref=e11]: Admin Control Plane
|
||||||
|
- generic [ref=e12]:
|
||||||
|
- generic [ref=e13]:
|
||||||
|
- heading "관리자 로그인" [level=3] [ref=e14]:
|
||||||
|
- img [ref=e15]
|
||||||
|
- text: 관리자 로그인
|
||||||
|
- paragraph [ref=e18]: Baron 통합 인증(SSO)을 통해 관리자 페이지에 접속합니다.
|
||||||
|
- generic [ref=e19]:
|
||||||
|
- button "SSO 계정으로 로그인" [ref=e20] [cursor=pointer]:
|
||||||
|
- img [ref=e21]
|
||||||
|
- text: SSO 계정으로 로그인
|
||||||
|
- img [ref=e23]
|
||||||
|
- paragraph [ref=e27]:
|
||||||
|
- text: 관리자 전역 세션은 보안을 위해 15분간 유지됩니다.
|
||||||
|
- text: 민감한 작업 시 재인증을 요구할 수 있습니다.
|
||||||
|
- paragraph [ref=e32]:
|
||||||
|
- text: 인증 정보가 없거나 로그인이 되지 않는 경우
|
||||||
|
- text: 시스템 관리자에게 문의하세요.
|
||||||
@@ -185,6 +185,7 @@ AdminFront의 테넌트와 사용자 export/import는 운영자가 CSV를 직접
|
|||||||
|
|
||||||
- 기존 `inactive` 입력은 `preboarding`으로, `leave_of_absence` 입력은 `temporary_leave`로 호환 처리합니다.
|
- 기존 `inactive` 입력은 `preboarding`으로, `leave_of_absence` 입력은 `temporary_leave`로 호환 처리합니다.
|
||||||
- 이슈 #862의 초기 명칭 `baron_only`는 구현 명칭으로 사용하지 않고 `baron_guest`로 정리합니다.
|
- 이슈 #862의 초기 명칭 `baron_only`는 구현 명칭으로 사용하지 않고 `baron_guest`로 정리합니다.
|
||||||
|
- backend bootstrap은 남아 있는 legacy `users.status` 값을 `inactive -> preboarding`, `leave_of_absence -> temporary_leave`, `baron_only -> baron_guest`로 자동 정규화합니다.
|
||||||
- `archived` 사용자는 과거 이력 보존용 계정이며 AdminFront 같은 관리자 화면에서만 감사/운영/중복 확인 목적으로 조회할 수 있습니다.
|
- `archived` 사용자는 과거 이력 보존용 계정이며 AdminFront 같은 관리자 화면에서만 감사/운영/중복 확인 목적으로 조회할 수 있습니다.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import {
|
|||||||
canCreateWorksmobileRow,
|
canCreateWorksmobileRow,
|
||||||
canOpenWorksmobilePasswordManage,
|
canOpenWorksmobilePasswordManage,
|
||||||
canSelectWorksmobileRow,
|
canSelectWorksmobileRow,
|
||||||
|
comparisonFilterOptions,
|
||||||
filterVisibleWorksmobileComparisonRows,
|
filterVisibleWorksmobileComparisonRows,
|
||||||
filterWorksmobileComparisonRows,
|
filterWorksmobileComparisonRows,
|
||||||
filterWorksmobileComparisonRowsBySearch,
|
filterWorksmobileComparisonRowsBySearch,
|
||||||
formatWorksmobileOrgDetails,
|
formatWorksmobileOrgDetails,
|
||||||
formatWorksmobilePersonName,
|
formatWorksmobilePersonName,
|
||||||
|
formatWorksmobileUpdateDetails,
|
||||||
|
getDefaultGroupComparisonFilters,
|
||||||
getDefaultWorksmobileComparisonColumns,
|
getDefaultWorksmobileComparisonColumns,
|
||||||
getWorksmobileComparisonStatusLabel,
|
getWorksmobileComparisonStatusLabel,
|
||||||
getWorksmobileRowSelectionKey,
|
getWorksmobileRowSelectionKey,
|
||||||
@@ -24,6 +27,7 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
|||||||
it("summarizes comparison rows by status", () => {
|
it("summarizes comparison rows by status", () => {
|
||||||
const summary = summarizeWorksmobileComparison([
|
const summary = summarizeWorksmobileComparison([
|
||||||
{ resourceType: "USER", status: "matched" },
|
{ resourceType: "USER", status: "matched" },
|
||||||
|
{ resourceType: "GROUP", status: "needs_update" },
|
||||||
{ resourceType: "USER", status: "missing_in_worksmobile" },
|
{ resourceType: "USER", status: "missing_in_worksmobile" },
|
||||||
{ resourceType: "USER", status: "missing_in_baron" },
|
{ resourceType: "USER", status: "missing_in_baron" },
|
||||||
{ resourceType: "USER", status: "missing_external_key" },
|
{ resourceType: "USER", status: "missing_external_key" },
|
||||||
@@ -31,8 +35,9 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
expect(summary).toEqual({
|
expect(summary).toEqual({
|
||||||
total: 5,
|
total: 6,
|
||||||
matched: 1,
|
matched: 1,
|
||||||
|
needsUpdate: 1,
|
||||||
missingInWorksmobile: 1,
|
missingInWorksmobile: 1,
|
||||||
missingInBaron: 2,
|
missingInBaron: 2,
|
||||||
missingExternalKey: 1,
|
missingExternalKey: 1,
|
||||||
@@ -50,6 +55,9 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
|||||||
expect(getWorksmobileComparisonStatusLabel("missing_external_key")).toBe(
|
expect(getWorksmobileComparisonStatusLabel("missing_external_key")).toBe(
|
||||||
"ex_key 없음",
|
"ex_key 없음",
|
||||||
);
|
);
|
||||||
|
expect(getWorksmobileComparisonStatusLabel("needs_update")).toBe(
|
||||||
|
"업데이트 필요",
|
||||||
|
);
|
||||||
expect(getWorksmobileComparisonStatusLabel("unknown_status")).toBe(
|
expect(getWorksmobileComparisonStatusLabel("unknown_status")).toBe(
|
||||||
"unknown_status",
|
"unknown_status",
|
||||||
);
|
);
|
||||||
@@ -426,11 +434,52 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
|||||||
it("orders user comparison filter options from Baron-only first", () => {
|
it("orders user comparison filter options from Baron-only first", () => {
|
||||||
expect(userFilterOptions.map((option) => option.value)).toEqual([
|
expect(userFilterOptions.map((option) => option.value)).toEqual([
|
||||||
"baron_only",
|
"baron_only",
|
||||||
|
"needs_update",
|
||||||
"works_only",
|
"works_only",
|
||||||
"matched",
|
"matched",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps all organization/group comparison filter labels available", () => {
|
||||||
|
expect(comparisonFilterOptions).toEqual([
|
||||||
|
{ value: "baron_only", label: "바론에만 있음" },
|
||||||
|
{ value: "needs_update", label: "업데이트 필요" },
|
||||||
|
{ value: "works_only", label: "웍스에만 있음" },
|
||||||
|
{ value: "matched", label: "양쪽 다 있음" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows update-needed group rows by default", () => {
|
||||||
|
const rows = [
|
||||||
|
{ resourceType: "GROUP", status: "needs_update", baronId: "org-1" },
|
||||||
|
{ resourceType: "GROUP", status: "matched", baronId: "org-2" },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
filterWorksmobileComparisonRows(rows, getDefaultGroupComparisonFilters()),
|
||||||
|
).toEqual([rows[0]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats update details for changed organization rows", () => {
|
||||||
|
expect(
|
||||||
|
formatWorksmobileUpdateDetails({
|
||||||
|
resourceType: "GROUP",
|
||||||
|
status: "needs_update",
|
||||||
|
baronId: "818c856b-9545-442f-b827-d1c569f200b0",
|
||||||
|
baronName: "삼안기술개발센터(조직도용)",
|
||||||
|
worksmobileName: "기술개발센터(조직도용)",
|
||||||
|
baronParentId: "9caf62e1-297d-4e8f-870b-61780998bbeb",
|
||||||
|
baronParentWorksmobileId: "works-saman",
|
||||||
|
baronParentWorksmobileName: "삼안",
|
||||||
|
worksmobileParentId: "works-other",
|
||||||
|
worksmobileParentName: "다른 상위",
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
"이름: 기술개발센터(조직도용) -> 삼안기술개발센터(조직도용)",
|
||||||
|
"상위: 다른 상위 -> 삼안",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("formats WORKS account name with level on one line", () => {
|
it("formats WORKS account name with level on one line", () => {
|
||||||
expect(
|
expect(
|
||||||
formatWorksmobilePersonName({
|
formatWorksmobilePersonName({
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ import {
|
|||||||
filterWorksmobileComparisonRowsBySearch,
|
filterWorksmobileComparisonRowsBySearch,
|
||||||
formatWorksmobileOrgDetails,
|
formatWorksmobileOrgDetails,
|
||||||
formatWorksmobilePersonName,
|
formatWorksmobilePersonName,
|
||||||
|
formatWorksmobileUpdateDetails,
|
||||||
|
getDefaultGroupComparisonFilters,
|
||||||
getDefaultWorksmobileComparisonColumns,
|
getDefaultWorksmobileComparisonColumns,
|
||||||
getWorksmobileComparisonStatusLabel,
|
getWorksmobileComparisonStatusLabel,
|
||||||
getWorksmobileRowSelectionKey,
|
getWorksmobileRowSelectionKey,
|
||||||
@@ -81,7 +83,7 @@ export function TenantWorksmobilePage() {
|
|||||||
>(["baron_only", "works_only"]);
|
>(["baron_only", "works_only"]);
|
||||||
const [groupFilters, setGroupFilters] = React.useState<
|
const [groupFilters, setGroupFilters] = React.useState<
|
||||||
WorksmobileComparisonFilter[]
|
WorksmobileComparisonFilter[]
|
||||||
>(["baron_only", "works_only"]);
|
>(getDefaultGroupComparisonFilters);
|
||||||
const [includeUserMissingExternalKey, setIncludeUserMissingExternalKey] =
|
const [includeUserMissingExternalKey, setIncludeUserMissingExternalKey] =
|
||||||
React.useState(false);
|
React.useState(false);
|
||||||
const [includeGroupMissingExternalKey, setIncludeGroupMissingExternalKey] =
|
const [includeGroupMissingExternalKey, setIncludeGroupMissingExternalKey] =
|
||||||
@@ -594,6 +596,9 @@ function getWorksmobileComparisonStatusVariant(status: string) {
|
|||||||
if (status === "matched") {
|
if (status === "matched") {
|
||||||
return "success";
|
return "success";
|
||||||
}
|
}
|
||||||
|
if (status === "needs_update") {
|
||||||
|
return "warning";
|
||||||
|
}
|
||||||
if (status === "missing_external_key") {
|
if (status === "missing_external_key") {
|
||||||
return "warning";
|
return "warning";
|
||||||
}
|
}
|
||||||
@@ -622,6 +627,10 @@ function ComparisonSummary({
|
|||||||
<span className="text-muted-foreground">Baron 없음</span>
|
<span className="text-muted-foreground">Baron 없음</span>
|
||||||
<span className="font-mono">{summary.missingInBaron}</span>
|
<span className="font-mono">{summary.missingInBaron}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-muted-foreground">업데이트 필요</span>
|
||||||
|
<span className="font-mono">{summary.needsUpdate}</span>
|
||||||
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-muted-foreground">ex_key 없음</span>
|
<span className="text-muted-foreground">ex_key 없음</span>
|
||||||
<span className="font-mono">{summary.missingExternalKey}</span>
|
<span className="font-mono">{summary.missingExternalKey}</span>
|
||||||
@@ -860,7 +869,7 @@ function ComparisonTable({
|
|||||||
return (
|
return (
|
||||||
<div className="min-w-0 space-y-2">
|
<div className="min-w-0 space-y-2">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex min-w-0 flex-wrap items-center gap-3">
|
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-3">
|
||||||
<h4 className="text-lg font-semibold leading-none">{title}</h4>
|
<h4 className="text-lg font-semibold leading-none">{title}</h4>
|
||||||
<Input
|
<Input
|
||||||
type="search"
|
type="search"
|
||||||
@@ -890,7 +899,7 @@ function ComparisonTable({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
|
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||||
<Dialog
|
<Dialog
|
||||||
open={columnSettingsOpen}
|
open={columnSettingsOpen}
|
||||||
onOpenChange={setColumnSettingsOpen}
|
onOpenChange={setColumnSettingsOpen}
|
||||||
@@ -1058,6 +1067,14 @@ function ComparisonTable({
|
|||||||
>
|
>
|
||||||
{getWorksmobileComparisonStatusLabel(row.status)}
|
{getWorksmobileComparisonStatusLabel(row.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{formatWorksmobileUpdateDetails(row).map((detail) => (
|
||||||
|
<div
|
||||||
|
key={detail}
|
||||||
|
className="mt-1 max-w-56 whitespace-normal text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{detail}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
{showBaronIdColumn && isColumnVisible("baronId") && (
|
{showBaronIdColumn && isColumnVisible("baronId") && (
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import type { WorksmobileComparisonItem } from "../../../lib/adminApi";
|
|||||||
export type WorksmobileComparisonFilter =
|
export type WorksmobileComparisonFilter =
|
||||||
| "works_only"
|
| "works_only"
|
||||||
| "baron_only"
|
| "baron_only"
|
||||||
|
| "needs_update"
|
||||||
| "matched";
|
| "matched";
|
||||||
|
|
||||||
export type WorksmobileComparisonSummary = {
|
export type WorksmobileComparisonSummary = {
|
||||||
total: number;
|
total: number;
|
||||||
matched: number;
|
matched: number;
|
||||||
|
needsUpdate: number;
|
||||||
missingInWorksmobile: number;
|
missingInWorksmobile: number;
|
||||||
missingInBaron: number;
|
missingInBaron: number;
|
||||||
missingExternalKey: number;
|
missingExternalKey: number;
|
||||||
@@ -52,6 +54,8 @@ export function summarizeWorksmobileComparison(
|
|||||||
(summary, row) => {
|
(summary, row) => {
|
||||||
if (row.status === "matched") {
|
if (row.status === "matched") {
|
||||||
summary.matched += 1;
|
summary.matched += 1;
|
||||||
|
} else if (row.status === "needs_update") {
|
||||||
|
summary.needsUpdate += 1;
|
||||||
} else if (row.status === "missing_in_worksmobile") {
|
} else if (row.status === "missing_in_worksmobile") {
|
||||||
summary.missingInWorksmobile += 1;
|
summary.missingInWorksmobile += 1;
|
||||||
} else if (row.status === "missing_in_baron") {
|
} else if (row.status === "missing_in_baron") {
|
||||||
@@ -64,6 +68,7 @@ export function summarizeWorksmobileComparison(
|
|||||||
{
|
{
|
||||||
total: rows.length,
|
total: rows.length,
|
||||||
matched: 0,
|
matched: 0,
|
||||||
|
needsUpdate: 0,
|
||||||
missingInWorksmobile: 0,
|
missingInWorksmobile: 0,
|
||||||
missingInBaron: 0,
|
missingInBaron: 0,
|
||||||
missingExternalKey: 0,
|
missingExternalKey: 0,
|
||||||
@@ -77,6 +82,8 @@ export function getWorksmobileComparisonStatusLabel(status: string) {
|
|||||||
return "일치";
|
return "일치";
|
||||||
case "missing_in_worksmobile":
|
case "missing_in_worksmobile":
|
||||||
return "WORKS 없음";
|
return "WORKS 없음";
|
||||||
|
case "needs_update":
|
||||||
|
return "업데이트 필요";
|
||||||
case "missing_in_baron":
|
case "missing_in_baron":
|
||||||
return "Baron 없음";
|
return "Baron 없음";
|
||||||
case "missing_external_key":
|
case "missing_external_key":
|
||||||
@@ -292,6 +299,42 @@ export function formatWorksmobileOrgDetails(row: WorksmobileComparisonItem) {
|
|||||||
return details;
|
return details;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
|
||||||
|
if (row.status !== "needs_update") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const details: string[] = [];
|
||||||
|
const baronName = row.baronName?.trim();
|
||||||
|
const worksmobileName = row.worksmobileName?.trim();
|
||||||
|
if (baronName && worksmobileName && baronName !== worksmobileName) {
|
||||||
|
details.push(`이름: ${worksmobileName} -> ${baronName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedParent =
|
||||||
|
row.baronParentWorksmobileName ??
|
||||||
|
row.baronParentName ??
|
||||||
|
row.baronParentWorksmobileId ??
|
||||||
|
row.baronParentId ??
|
||||||
|
"";
|
||||||
|
const actualParent =
|
||||||
|
row.worksmobileParentName ??
|
||||||
|
row.worksmobileParentExternalKey ??
|
||||||
|
row.worksmobileParentId ??
|
||||||
|
"";
|
||||||
|
const expectedParentKey =
|
||||||
|
row.baronParentWorksmobileId ?? row.baronParentId ?? "";
|
||||||
|
const actualParentKey =
|
||||||
|
row.worksmobileParentId ?? row.worksmobileParentExternalKey ?? "";
|
||||||
|
if (expectedParentKey !== actualParentKey) {
|
||||||
|
details.push(
|
||||||
|
`상위: ${actualParent || "없음"} -> ${expectedParent || "없음"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildWorksmobilePasswordManageUrl({
|
export function buildWorksmobilePasswordManageUrl({
|
||||||
tenantId,
|
tenantId,
|
||||||
domainId,
|
domainId,
|
||||||
@@ -345,15 +388,21 @@ export const comparisonFilterOptions: Array<{
|
|||||||
label: string;
|
label: string;
|
||||||
}> = [
|
}> = [
|
||||||
{ value: "baron_only", label: "바론에만 있음" },
|
{ value: "baron_only", label: "바론에만 있음" },
|
||||||
|
{ value: "needs_update", label: "업데이트 필요" },
|
||||||
{ value: "works_only", label: "웍스에만 있음" },
|
{ value: "works_only", label: "웍스에만 있음" },
|
||||||
{ value: "matched", label: "양쪽 다 있음" },
|
{ value: "matched", label: "양쪽 다 있음" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const userFilterOptions = comparisonFilterOptions;
|
export const userFilterOptions = comparisonFilterOptions;
|
||||||
|
|
||||||
|
export function getDefaultGroupComparisonFilters(): WorksmobileComparisonFilter[] {
|
||||||
|
return ["baron_only", "needs_update", "works_only"];
|
||||||
|
}
|
||||||
|
|
||||||
const worksmobileFilterStatuses: Record<WorksmobileComparisonFilter, string[]> =
|
const worksmobileFilterStatuses: Record<WorksmobileComparisonFilter, string[]> =
|
||||||
{
|
{
|
||||||
baron_only: ["missing_in_worksmobile"],
|
baron_only: ["missing_in_worksmobile"],
|
||||||
|
needs_update: ["needs_update"],
|
||||||
works_only: ["missing_in_baron"],
|
works_only: ["missing_in_baron"],
|
||||||
matched: ["matched"],
|
matched: ["matched"],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ func TestEnsureSuperAdminPromotesExistingLocalUser(t *testing.T) {
|
|||||||
Email: "existing@example.com",
|
Email: "existing@example.com",
|
||||||
Name: "Existing",
|
Name: "Existing",
|
||||||
Role: domain.RoleUser,
|
Role: domain.RoleUser,
|
||||||
Status: domain.UserStatusInactive,
|
Status: domain.UserStatusPreboarding,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ func Run(db *gorm.DB) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Normalize staging seed/read-model data
|
// 3. Normalize staging seed/read-model data
|
||||||
|
if err := CanonicalizeLegacyUserStatuses(db); err != nil {
|
||||||
|
return fmt.Errorf("legacy user status canonicalization failed: %w", err)
|
||||||
|
}
|
||||||
if err := SanitizeLegacyUserMetadata(db); err != nil {
|
if err := SanitizeLegacyUserMetadata(db); err != nil {
|
||||||
return fmt.Errorf("legacy user metadata sanitize failed: %w", err)
|
return fmt.Errorf("legacy user metadata sanitize failed: %w", err)
|
||||||
}
|
}
|
||||||
@@ -64,6 +67,25 @@ func migrateSchemas(db *gorm.DB) error {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CanonicalizeLegacyUserStatuses(db *gorm.DB) error {
|
||||||
|
if db == nil || !db.Migrator().HasTable(&domain.User{}) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
updates := map[string]string{
|
||||||
|
"inactive": domain.UserStatusPreboarding,
|
||||||
|
"leave_of_absence": domain.UserStatusTemporaryLeave,
|
||||||
|
"baron_only": domain.UserStatusBaronGuest,
|
||||||
|
}
|
||||||
|
for legacy, canonical := range updates {
|
||||||
|
if err := db.Model(&domain.User{}).
|
||||||
|
Where("status = ?", legacy).
|
||||||
|
Update("status", canonical).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to canonicalize users.status %s to %s: %w", legacy, canonical, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func dropLegacyUserCompanyColumns(db *gorm.DB) error {
|
func dropLegacyUserCompanyColumns(db *gorm.DB) error {
|
||||||
if !db.Migrator().HasTable(&domain.User{}) {
|
if !db.Migrator().HasTable(&domain.User{}) {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -68,6 +68,52 @@ func TestSanitizeLegacyUserMetadataRemovesClassificationFlags(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCanonicalizeLegacyUserStatuses(t *testing.T) {
|
||||||
|
db := openBootstrapPostgresTestDB(t)
|
||||||
|
if err := db.AutoMigrate(&domain.User{}); err != nil {
|
||||||
|
t.Fatalf("failed to migrate users table: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
users := []domain.User{
|
||||||
|
{ID: "11000000-0000-0000-0000-000000000001", Email: "inactive@example.com", Name: "Inactive", Role: domain.RoleUser, Status: "inactive"},
|
||||||
|
{ID: "11000000-0000-0000-0000-000000000002", Email: "leave@example.com", Name: "Leave", Role: domain.RoleUser, Status: "leave_of_absence"},
|
||||||
|
{ID: "11000000-0000-0000-0000-000000000003", Email: "baron-only@example.com", Name: "Baron Only", Role: domain.RoleUser, Status: "baron_only"},
|
||||||
|
{ID: "11000000-0000-0000-0000-000000000004", Email: "active@example.com", Name: "Active", Role: domain.RoleUser, Status: domain.UserStatusActive},
|
||||||
|
}
|
||||||
|
if err := db.Create(&users).Error; err != nil {
|
||||||
|
t.Fatalf("failed to create users: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := CanonicalizeLegacyUserStatuses(db); err != nil {
|
||||||
|
t.Fatalf("CanonicalizeLegacyUserStatuses returned error: %v", err)
|
||||||
|
}
|
||||||
|
if err := CanonicalizeLegacyUserStatuses(db); err != nil {
|
||||||
|
t.Fatalf("CanonicalizeLegacyUserStatuses must be idempotent: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := map[string]string{}
|
||||||
|
var loaded []domain.User
|
||||||
|
if err := db.Find(&loaded).Error; err != nil {
|
||||||
|
t.Fatalf("failed to load users: %v", err)
|
||||||
|
}
|
||||||
|
for _, user := range loaded {
|
||||||
|
got[user.Email] = user.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
if got["inactive@example.com"] != domain.UserStatusPreboarding {
|
||||||
|
t.Fatalf("inactive status = %q, want %q", got["inactive@example.com"], domain.UserStatusPreboarding)
|
||||||
|
}
|
||||||
|
if got["leave@example.com"] != domain.UserStatusTemporaryLeave {
|
||||||
|
t.Fatalf("leave status = %q, want %q", got["leave@example.com"], domain.UserStatusTemporaryLeave)
|
||||||
|
}
|
||||||
|
if got["baron-only@example.com"] != domain.UserStatusBaronGuest {
|
||||||
|
t.Fatalf("baron_only status = %q, want %q", got["baron-only@example.com"], domain.UserStatusBaronGuest)
|
||||||
|
}
|
||||||
|
if got["active@example.com"] != domain.UserStatusActive {
|
||||||
|
t.Fatalf("active status = %q, want %q", got["active@example.com"], domain.UserStatusActive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunSanitizesLegacyUserMetadata(t *testing.T) {
|
func TestRunSanitizesLegacyUserMetadata(t *testing.T) {
|
||||||
db := openBootstrapPostgresTestDB(t)
|
db := openBootstrapPostgresTestDB(t)
|
||||||
if err := db.AutoMigrate(&domain.User{}); err != nil {
|
if err := db.AutoMigrate(&domain.User{}); err != nil {
|
||||||
|
|||||||
@@ -21,9 +21,7 @@ const (
|
|||||||
// User statuses
|
// User statuses
|
||||||
const (
|
const (
|
||||||
UserStatusActive = "active"
|
UserStatusActive = "active"
|
||||||
UserStatusInactive = "inactive"
|
|
||||||
UserStatusSuspended = "suspended"
|
UserStatusSuspended = "suspended"
|
||||||
UserStatusLeaveOfAbsence = "leave_of_absence"
|
|
||||||
UserStatusTemporaryLeave = "temporary_leave"
|
UserStatusTemporaryLeave = "temporary_leave"
|
||||||
UserStatusPreboarding = "preboarding"
|
UserStatusPreboarding = "preboarding"
|
||||||
UserStatusBaronGuest = "baron_guest"
|
UserStatusBaronGuest = "baron_guest"
|
||||||
@@ -37,9 +35,9 @@ func NormalizeUserStatus(status string) string {
|
|||||||
return UserStatusActive
|
return UserStatusActive
|
||||||
case "blocked", UserStatusSuspended:
|
case "blocked", UserStatusSuspended:
|
||||||
return UserStatusSuspended
|
return UserStatusSuspended
|
||||||
case UserStatusInactive, UserStatusPreboarding:
|
case "inactive", UserStatusPreboarding:
|
||||||
return UserStatusPreboarding
|
return UserStatusPreboarding
|
||||||
case UserStatusLeaveOfAbsence, UserStatusTemporaryLeave:
|
case "leave_of_absence", UserStatusTemporaryLeave:
|
||||||
return UserStatusTemporaryLeave
|
return UserStatusTemporaryLeave
|
||||||
case "baron_only", UserStatusBaronGuest:
|
case "baron_only", UserStatusBaronGuest:
|
||||||
return UserStatusBaronGuest
|
return UserStatusBaronGuest
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ func TestUserStatusPolicy(t *testing.T) {
|
|||||||
{status: UserStatusBaronGuest, normalized: UserStatusBaronGuest, baronAllowed: true, worksDeprovisioned: true},
|
{status: UserStatusBaronGuest, normalized: UserStatusBaronGuest, baronAllowed: true, worksDeprovisioned: true},
|
||||||
{status: UserStatusExtendedLeave, normalized: UserStatusExtendedLeave, worksDeprovisioned: true},
|
{status: UserStatusExtendedLeave, normalized: UserStatusExtendedLeave, worksDeprovisioned: true},
|
||||||
{status: UserStatusArchived, normalized: UserStatusArchived, worksDeprovisioned: true},
|
{status: UserStatusArchived, normalized: UserStatusArchived, worksDeprovisioned: true},
|
||||||
{status: UserStatusInactive, normalized: UserStatusPreboarding},
|
{status: "inactive", normalized: UserStatusPreboarding},
|
||||||
{status: UserStatusLeaveOfAbsence, normalized: UserStatusTemporaryLeave, baronAllowed: true, orgVisible: true, worksProvisioned: true},
|
{status: "leave_of_absence", normalized: UserStatusTemporaryLeave, baronAllowed: true, orgVisible: true, worksProvisioned: true},
|
||||||
{status: "BARON_ONLY", normalized: UserStatusBaronGuest, baronAllowed: true, worksDeprovisioned: true},
|
{status: "BARON_ONLY", normalized: UserStatusBaronGuest, baronAllowed: true, worksDeprovisioned: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2618,7 +2618,7 @@ func normalizeKratosState(status *string) string {
|
|||||||
}
|
}
|
||||||
value := strings.ToLower(strings.TrimSpace(*status))
|
value := strings.ToLower(strings.TrimSpace(*status))
|
||||||
if value == "blocked" {
|
if value == "blocked" {
|
||||||
return domain.UserStatusInactive
|
return "inactive"
|
||||||
}
|
}
|
||||||
if value == domain.UserStatusActive {
|
if value == domain.UserStatusActive {
|
||||||
return domain.UserStatusActive
|
return domain.UserStatusActive
|
||||||
@@ -2630,7 +2630,7 @@ func normalizeKratosState(status *string) string {
|
|||||||
normalized == domain.UserStatusBaronGuest ||
|
normalized == domain.UserStatusBaronGuest ||
|
||||||
normalized == domain.UserStatusExtendedLeave ||
|
normalized == domain.UserStatusExtendedLeave ||
|
||||||
normalized == domain.UserStatusArchived {
|
normalized == domain.UserStatusArchived {
|
||||||
return domain.UserStatusInactive
|
return "inactive"
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -376,7 +376,7 @@ func TestWorksmobileUserStatusAction(t *testing.T) {
|
|||||||
require.Equal(t, domain.WorksmobileActionDelete, WorksmobileUserStatusAction(domain.UserStatusExtendedLeave))
|
require.Equal(t, domain.WorksmobileActionDelete, WorksmobileUserStatusAction(domain.UserStatusExtendedLeave))
|
||||||
require.Equal(t, domain.WorksmobileActionDelete, WorksmobileUserStatusAction(domain.UserStatusBaronGuest))
|
require.Equal(t, domain.WorksmobileActionDelete, WorksmobileUserStatusAction(domain.UserStatusBaronGuest))
|
||||||
require.Equal(t, domain.WorksmobileActionDelete, WorksmobileUserStatusAction(domain.UserStatusArchived))
|
require.Equal(t, domain.WorksmobileActionDelete, WorksmobileUserStatusAction(domain.UserStatusArchived))
|
||||||
require.Equal(t, WorksmobileUserActionUpsert, WorksmobileUserStatusAction(domain.UserStatusLeaveOfAbsence))
|
require.Equal(t, WorksmobileUserActionUpsert, WorksmobileUserStatusAction("leave_of_absence"))
|
||||||
require.Equal(t, domain.WorksmobileActionDelete, WorksmobileUserStatusAction("baron_only"))
|
require.Equal(t, domain.WorksmobileActionDelete, WorksmobileUserStatusAction("baron_only"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -736,6 +736,9 @@ func isWorksmobileOrgUnitTenant(tenant domain.Tenant, tenantByID map[string]doma
|
|||||||
if tenant.Type == domain.TenantTypeOrganization {
|
if tenant.Type == domain.TenantTypeOrganization {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if tenant.Type == domain.TenantTypeUserGroup {
|
||||||
|
return true
|
||||||
|
}
|
||||||
if tenant.Type == domain.TenantTypeCompany {
|
if tenant.Type == domain.TenantTypeCompany {
|
||||||
return isWorksmobileBarongroupChildCompany(tenant, tenantByID)
|
return isWorksmobileBarongroupChildCompany(tenant, tenantByID)
|
||||||
}
|
}
|
||||||
@@ -749,7 +752,7 @@ func isWorksmobileUserScopeTenant(tenant domain.Tenant) bool {
|
|||||||
func worksmobileDomainClassificationTenant(tenant domain.Tenant, tenantByID map[string]domain.Tenant) domain.Tenant {
|
func worksmobileDomainClassificationTenant(tenant domain.Tenant, tenantByID map[string]domain.Tenant) domain.Tenant {
|
||||||
current := tenant
|
current := tenant
|
||||||
for {
|
for {
|
||||||
if current.Type == domain.TenantTypeCompany || len(current.Domains) > 0 {
|
if isWorksmobileDomainRootTenant(current) {
|
||||||
return current
|
return current
|
||||||
}
|
}
|
||||||
parentID := worksmobileTenantParentID(current)
|
parentID := worksmobileTenantParentID(current)
|
||||||
@@ -764,6 +767,25 @@ func worksmobileDomainClassificationTenant(tenant domain.Tenant, tenantByID map[
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isWorksmobileDomainRootTenant(tenant domain.Tenant) bool {
|
||||||
|
slug := strings.ToLower(strings.TrimSpace(tenant.Slug))
|
||||||
|
switch slug {
|
||||||
|
case "saman", "hanmac", "gpdtdc", "baron-group":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if tenantHasDomain(tenant, "samaneng.com") ||
|
||||||
|
tenantHasDomain(tenant, "hanmaceng.co.kr") ||
|
||||||
|
tenantHasDomain(tenant, "baroncs.co.kr") ||
|
||||||
|
tenantHasDomain(tenant, "brsw.kr") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(tenant.Name)
|
||||||
|
return name == "삼안" ||
|
||||||
|
name == "한맥기술" ||
|
||||||
|
name == "총괄기획&기술개발센터" ||
|
||||||
|
name == "바론그룹"
|
||||||
|
}
|
||||||
|
|
||||||
func isWorksmobileBarongroupChildCompany(tenant domain.Tenant, tenantByID map[string]domain.Tenant) bool {
|
func isWorksmobileBarongroupChildCompany(tenant domain.Tenant, tenantByID map[string]domain.Tenant) bool {
|
||||||
if tenant.Type != domain.TenantTypeCompany || tenant.Slug == "baron-group" {
|
if tenant.Type != domain.TenantTypeCompany || tenant.Slug == "baron-group" {
|
||||||
return false
|
return false
|
||||||
@@ -972,14 +994,14 @@ func worksmobileUserPrimaryOrgSlug(user domain.User, localTenants map[string]dom
|
|||||||
}
|
}
|
||||||
|
|
||||||
func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []WorksmobileRemoteGroup, includeMatched bool) []WorksmobileComparisonItem {
|
func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []WorksmobileRemoteGroup, includeMatched bool) []WorksmobileComparisonItem {
|
||||||
remoteByExternalID := map[string]WorksmobileRemoteGroup{}
|
remoteByExternalID := map[string][]WorksmobileRemoteGroup{}
|
||||||
remoteByID := map[string]WorksmobileRemoteGroup{}
|
remoteByID := map[string]WorksmobileRemoteGroup{}
|
||||||
for _, remote := range remoteGroups {
|
for _, remote := range remoteGroups {
|
||||||
if remote.ID != "" {
|
if remote.ID != "" {
|
||||||
remoteByID[remote.ID] = remote
|
remoteByID[remote.ID] = remote
|
||||||
}
|
}
|
||||||
if remote.ExternalID != "" {
|
if remote.ExternalID != "" {
|
||||||
remoteByExternalID[remote.ExternalID] = remote
|
remoteByExternalID[remote.ExternalID] = append(remoteByExternalID[remote.ExternalID], remote)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tenantByID := worksmobileTenantByID(localTenants)
|
tenantByID := worksmobileTenantByID(localTenants)
|
||||||
@@ -993,11 +1015,7 @@ func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []Works
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
localByID[tenant.ID] = tenant
|
localByID[tenant.ID] = tenant
|
||||||
remote, matched := remoteByExternalID[tenant.ID]
|
remote, matched := matchingWorksmobileRemoteGroupForTenant(tenant, remoteByExternalID[tenant.ID], tenantByID)
|
||||||
if matched && !includeMatched {
|
|
||||||
matchedRemoteIDs[remote.ID] = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
item := WorksmobileComparisonItem{
|
item := WorksmobileComparisonItem{
|
||||||
ResourceType: "GROUP",
|
ResourceType: "GROUP",
|
||||||
BaronID: tenant.ID,
|
BaronID: tenant.ID,
|
||||||
@@ -1018,7 +1036,13 @@ func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []Works
|
|||||||
item.WorksmobileDomainName = remote.DomainName
|
item.WorksmobileDomainName = remote.DomainName
|
||||||
item.WorksmobileParentID = remote.ParentID
|
item.WorksmobileParentID = remote.ParentID
|
||||||
item.WorksmobileParentName = remote.ParentName
|
item.WorksmobileParentName = remote.ParentName
|
||||||
if parentRemote, ok := remoteByExternalID[item.BaronParentID]; ok {
|
if parent, ok := tenantByID[item.BaronParentID]; ok {
|
||||||
|
if parentRemote, ok := matchingWorksmobileRemoteGroupForTenant(parent, remoteByExternalID[item.BaronParentID], tenantByID); ok {
|
||||||
|
item.BaronParentWorksmobileID = parentRemote.ID
|
||||||
|
item.BaronParentWorksmobileName = parentRemote.DisplayName
|
||||||
|
item.BaronParentWorksmobileEmail = parentRemote.Email
|
||||||
|
}
|
||||||
|
} else if parentRemote, ok := firstWorksmobileRemoteGroup(remoteByExternalID[item.BaronParentID]); ok {
|
||||||
item.BaronParentWorksmobileID = parentRemote.ID
|
item.BaronParentWorksmobileID = parentRemote.ID
|
||||||
item.BaronParentWorksmobileName = parentRemote.DisplayName
|
item.BaronParentWorksmobileName = parentRemote.DisplayName
|
||||||
item.BaronParentWorksmobileEmail = parentRemote.Email
|
item.BaronParentWorksmobileEmail = parentRemote.Email
|
||||||
@@ -1031,8 +1055,14 @@ func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []Works
|
|||||||
item.WorksmobileParentExternalKey = parentRemote.ExternalID
|
item.WorksmobileParentExternalKey = parentRemote.ExternalID
|
||||||
}
|
}
|
||||||
item = fillWorksmobileParentFromBaronParentMatch(item)
|
item = fillWorksmobileParentFromBaronParentMatch(item)
|
||||||
|
if worksmobileGroupNeedsUpdate(tenant, remote, remoteByID, remoteByExternalID, tenantByID) {
|
||||||
|
item.Status = "needs_update"
|
||||||
|
}
|
||||||
matchedRemoteIDs[remote.ID] = true
|
matchedRemoteIDs[remote.ID] = true
|
||||||
}
|
}
|
||||||
|
if matched && item.Status == "matched" && !includeMatched {
|
||||||
|
continue
|
||||||
|
}
|
||||||
result = append(result, item)
|
result = append(result, item)
|
||||||
}
|
}
|
||||||
for _, remote := range remoteGroups {
|
for _, remote := range remoteGroups {
|
||||||
@@ -1091,6 +1121,79 @@ func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []Works
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func matchingWorksmobileRemoteGroupForTenant(tenant domain.Tenant, remotes []WorksmobileRemoteGroup, tenantByID map[string]domain.Tenant) (WorksmobileRemoteGroup, bool) {
|
||||||
|
if len(remotes) == 0 {
|
||||||
|
return WorksmobileRemoteGroup{}, false
|
||||||
|
}
|
||||||
|
expectedDomainID, hasExpectedDomainID := expectedWorksmobileDomainIDForTenant(tenant, tenantByID)
|
||||||
|
if !hasExpectedDomainID {
|
||||||
|
return remotes[0], true
|
||||||
|
}
|
||||||
|
var unknownDomain WorksmobileRemoteGroup
|
||||||
|
hasUnknownDomain := false
|
||||||
|
for i := range remotes {
|
||||||
|
remote := remotes[i]
|
||||||
|
if remote.DomainID == expectedDomainID {
|
||||||
|
return remote, true
|
||||||
|
}
|
||||||
|
if remote.DomainID == 0 && !hasUnknownDomain {
|
||||||
|
unknownDomain = remote
|
||||||
|
hasUnknownDomain = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasUnknownDomain {
|
||||||
|
return unknownDomain, true
|
||||||
|
}
|
||||||
|
return WorksmobileRemoteGroup{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstWorksmobileRemoteGroup(remotes []WorksmobileRemoteGroup) (WorksmobileRemoteGroup, bool) {
|
||||||
|
if len(remotes) == 0 {
|
||||||
|
return WorksmobileRemoteGroup{}, false
|
||||||
|
}
|
||||||
|
return remotes[0], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func expectedWorksmobileDomainIDForTenant(tenant domain.Tenant, tenantByID map[string]domain.Tenant) (int64, bool) {
|
||||||
|
domainTenant := worksmobileDomainClassificationTenant(tenant, tenantByID)
|
||||||
|
domainID, err := ResolveWorksmobileDomainIDFromTenant(domainTenant, nil)
|
||||||
|
if err != nil || domainID <= 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return domainID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func worksmobileGroupNeedsUpdate(tenant domain.Tenant, remote WorksmobileRemoteGroup, remoteByID map[string]WorksmobileRemoteGroup, remoteByExternalID map[string][]WorksmobileRemoteGroup, tenantByID map[string]domain.Tenant) bool {
|
||||||
|
if strings.TrimSpace(tenant.Name) != strings.TrimSpace(remote.DisplayName) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedParentExternalKey := expectedWorksmobileParentExternalKey(tenant, remoteByExternalID, tenantByID)
|
||||||
|
actualParentExternalKey := ""
|
||||||
|
if remote.ParentID != "" {
|
||||||
|
actualParentExternalKey = strings.TrimSpace(remoteByID[remote.ParentID].ExternalID)
|
||||||
|
}
|
||||||
|
return expectedParentExternalKey != actualParentExternalKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func expectedWorksmobileParentExternalKey(tenant domain.Tenant, remoteByExternalID map[string][]WorksmobileRemoteGroup, tenantByID map[string]domain.Tenant) string {
|
||||||
|
parentID := worksmobileTenantParentID(tenant)
|
||||||
|
if parentID == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if parent, ok := tenantByID[parentID]; ok && parent.Slug == "baron-group" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parent, ok := tenantByID[parentID]
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if _, ok := matchingWorksmobileRemoteGroupForTenant(parent, remoteByExternalID[parentID], tenantByID); !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return parentID
|
||||||
|
}
|
||||||
|
|
||||||
func fillWorksmobileParentFromBaronParentMatch(item WorksmobileComparisonItem) WorksmobileComparisonItem {
|
func fillWorksmobileParentFromBaronParentMatch(item WorksmobileComparisonItem) WorksmobileComparisonItem {
|
||||||
if item.WorksmobileParentID == "" || item.WorksmobileParentID != item.BaronParentWorksmobileID {
|
if item.WorksmobileParentID == "" || item.WorksmobileParentID != item.BaronParentWorksmobileID {
|
||||||
return item
|
return item
|
||||||
|
|||||||
@@ -200,28 +200,28 @@ func TestCompareWorksmobileGroupsUsesOrganizationsAndBarongroupChildCompanies(t
|
|||||||
Type: domain.TenantTypeOrganization,
|
Type: domain.TenantTypeOrganization,
|
||||||
ParentID: &hanmac.ID,
|
ParentID: &hanmac.ID,
|
||||||
}
|
}
|
||||||
legacyUserGroup := domain.Tenant{
|
userGroup := domain.Tenant{
|
||||||
ID: "legacy-user-group-tenant",
|
ID: "legacy-user-group-tenant",
|
||||||
Name: "레거시 사용자 그룹",
|
Name: "사용자 그룹 조직",
|
||||||
Type: domain.TenantTypeUserGroup,
|
Type: domain.TenantTypeUserGroup,
|
||||||
ParentID: &hanmac.ID,
|
ParentID: &hanmac.ID,
|
||||||
}
|
}
|
||||||
|
|
||||||
items := compareWorksmobileGroups(
|
items := compareWorksmobileGroups(
|
||||||
[]domain.Tenant{root, hanmac, barongroup, barongroupChildCompany, organization, legacyUserGroup},
|
[]domain.Tenant{root, hanmac, barongroup, barongroupChildCompany, organization, userGroup},
|
||||||
[]WorksmobileRemoteGroup{
|
[]WorksmobileRemoteGroup{
|
||||||
{ID: "works-root", ExternalID: root.ID, DisplayName: root.Name},
|
{ID: "works-root", ExternalID: root.ID, DisplayName: root.Name},
|
||||||
{ID: "works-hanmac", ExternalID: hanmac.ID, DisplayName: hanmac.Name, Email: "hanmac@hanmaceng.co.kr"},
|
{ID: "works-hanmac", ExternalID: hanmac.ID, DisplayName: hanmac.Name, Email: "hanmac@hanmaceng.co.kr"},
|
||||||
{ID: "works-barongroup", ExternalID: barongroup.ID, DisplayName: barongroup.Name},
|
{ID: "works-barongroup", ExternalID: barongroup.ID, DisplayName: barongroup.Name},
|
||||||
{ID: "works-barongroup-child", ExternalID: barongroupChildCompany.ID, DisplayName: barongroupChildCompany.Name},
|
{ID: "works-barongroup-child", ExternalID: barongroupChildCompany.ID, DisplayName: barongroupChildCompany.Name},
|
||||||
{ID: "works-organization", ExternalID: organization.ID, DisplayName: organization.Name, ParentID: "works-hanmac"},
|
{ID: "works-organization", ExternalID: organization.ID, DisplayName: organization.Name, ParentID: "works-hanmac"},
|
||||||
{ID: "works-legacy-user-group", ExternalID: legacyUserGroup.ID, DisplayName: legacyUserGroup.Name},
|
{ID: "works-user-group", ExternalID: userGroup.ID, DisplayName: userGroup.Name, ParentID: "works-hanmac"},
|
||||||
{ID: "works-orphan", ExternalID: "works-orphan", DisplayName: "WORKS 전용 조직"},
|
{ID: "works-orphan", ExternalID: "works-orphan", DisplayName: "WORKS 전용 조직"},
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
|
|
||||||
require.Len(t, items, 3)
|
require.Len(t, items, 4)
|
||||||
require.Equal(t, barongroupChildCompany.ID, items[0].BaronID)
|
require.Equal(t, barongroupChildCompany.ID, items[0].BaronID)
|
||||||
require.Equal(t, "matched", items[0].Status)
|
require.Equal(t, "matched", items[0].Status)
|
||||||
require.Equal(t, organization.ID, items[1].BaronID)
|
require.Equal(t, organization.ID, items[1].BaronID)
|
||||||
@@ -233,8 +233,159 @@ func TestCompareWorksmobileGroupsUsesOrganizationsAndBarongroupChildCompanies(t
|
|||||||
require.Equal(t, "works-hanmac", items[1].BaronParentWorksmobileID)
|
require.Equal(t, "works-hanmac", items[1].BaronParentWorksmobileID)
|
||||||
require.Equal(t, hanmac.Name, items[1].BaronParentWorksmobileName)
|
require.Equal(t, hanmac.Name, items[1].BaronParentWorksmobileName)
|
||||||
require.Equal(t, "hanmac@hanmaceng.co.kr", items[1].BaronParentWorksmobileEmail)
|
require.Equal(t, "hanmac@hanmaceng.co.kr", items[1].BaronParentWorksmobileEmail)
|
||||||
require.Equal(t, "works-orphan", items[2].ExternalKey)
|
require.Equal(t, userGroup.ID, items[2].BaronID)
|
||||||
require.Equal(t, "missing_in_baron", items[2].Status)
|
require.Equal(t, "matched", items[2].Status)
|
||||||
|
require.Equal(t, "works-orphan", items[3].ExternalKey)
|
||||||
|
require.Equal(t, "missing_in_baron", items[3].Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareWorksmobileGroupsShowsUserGroupMissingInWorksmobile(t *testing.T) {
|
||||||
|
parentID := "company-tenant"
|
||||||
|
userGroup := domain.Tenant{
|
||||||
|
ID: "team-tenant",
|
||||||
|
Name: "신규 팀",
|
||||||
|
Slug: "new-team",
|
||||||
|
Type: domain.TenantTypeUserGroup,
|
||||||
|
ParentID: &parentID,
|
||||||
|
}
|
||||||
|
|
||||||
|
items := compareWorksmobileGroups(
|
||||||
|
[]domain.Tenant{
|
||||||
|
{ID: parentID, Slug: "company", Name: "계열사", Type: domain.TenantTypeCompany},
|
||||||
|
userGroup,
|
||||||
|
},
|
||||||
|
nil,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
|
||||||
|
require.Len(t, items, 1)
|
||||||
|
require.Equal(t, userGroup.ID, items[0].BaronID)
|
||||||
|
require.Equal(t, "missing_in_worksmobile", items[0].Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareWorksmobileGroupsMarksMatchedOrgUnitNeedsUpdate(t *testing.T) {
|
||||||
|
parentID := "parent-tenant"
|
||||||
|
tenant := domain.Tenant{
|
||||||
|
ID: "team-tenant",
|
||||||
|
Name: "변경된 팀명",
|
||||||
|
Slug: "team",
|
||||||
|
Type: domain.TenantTypeUserGroup,
|
||||||
|
ParentID: &parentID,
|
||||||
|
}
|
||||||
|
|
||||||
|
items := compareWorksmobileGroups(
|
||||||
|
[]domain.Tenant{
|
||||||
|
{ID: parentID, Slug: "parent", Name: "상위 조직", Type: domain.TenantTypeUserGroup},
|
||||||
|
tenant,
|
||||||
|
},
|
||||||
|
[]WorksmobileRemoteGroup{
|
||||||
|
{ID: "works-parent", ExternalID: parentID, DisplayName: "상위 조직"},
|
||||||
|
{ID: "works-team", ExternalID: tenant.ID, DisplayName: "이전 팀명", ParentID: "works-parent"},
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
|
||||||
|
require.Len(t, items, 1)
|
||||||
|
require.Equal(t, tenant.ID, items[0].BaronID)
|
||||||
|
require.Equal(t, "needs_update", items[0].Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareWorksmobileGroupsCoversHanmacOrganizationRegressionIDs(t *testing.T) {
|
||||||
|
rootID := "038326b6-954a-48a7-a85f-efd83f62b82a"
|
||||||
|
samanID := "9caf62e1-297d-4e8f-870b-61780998bbeb"
|
||||||
|
hanmacID := "369c1843-56af-4344-9c21-0e01197ab861"
|
||||||
|
baronGroupID := "96369f12-6b66-4b2a-a916-d1c99d326f02"
|
||||||
|
changedID := "818c856b-9545-442f-b827-d1c569f200b0"
|
||||||
|
hanmacOnlyID := "2d217948-9c5a-42ea-805b-eef9c7421775"
|
||||||
|
baronOnlyID := "32464fd6-da51-473f-844a-ab88603ad1f0"
|
||||||
|
localTenants := []domain.Tenant{
|
||||||
|
{ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
|
||||||
|
{ID: samanID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID},
|
||||||
|
{ID: hanmacID, Slug: "hanmac", Name: "한맥기술", Type: domain.TenantTypeCompany, ParentID: &rootID},
|
||||||
|
{ID: baronGroupID, Slug: "baron-group", Name: "바론그룹", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID},
|
||||||
|
{ID: changedID, Slug: "rnd-saman", Name: "삼안기술개발센터(조직도용)", Type: domain.TenantTypeOrganization, ParentID: &samanID},
|
||||||
|
{ID: hanmacOnlyID, Slug: "rnd-hanmac", Name: "한맥기술개발센터(조직도용)", Type: domain.TenantTypeOrganization, ParentID: &hanmacID},
|
||||||
|
{ID: baronOnlyID, Slug: "rnd-baron", Name: "바론기술개발센터(조직도용)", Type: domain.TenantTypeOrganization, ParentID: &baronGroupID},
|
||||||
|
}
|
||||||
|
remoteGroups := []WorksmobileRemoteGroup{
|
||||||
|
{ID: "works-saman", ExternalID: samanID, DisplayName: "삼안"},
|
||||||
|
{ID: "works-hanmac", ExternalID: hanmacID, DisplayName: "한맥기술"},
|
||||||
|
{
|
||||||
|
ID: "works-rnd-saman",
|
||||||
|
ExternalID: changedID,
|
||||||
|
DisplayName: "삼안기술개발센터(조직도용)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
items := compareWorksmobileGroups(localTenants, remoteGroups, false)
|
||||||
|
itemsByBaronID := map[string]WorksmobileComparisonItem{}
|
||||||
|
for _, item := range items {
|
||||||
|
itemsByBaronID[item.BaronID] = item
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Equal(t, "needs_update", itemsByBaronID[changedID].Status)
|
||||||
|
require.Equal(t, "missing_in_worksmobile", itemsByBaronID[hanmacOnlyID].Status)
|
||||||
|
require.Equal(t, "missing_in_worksmobile", itemsByBaronID[baronOnlyID].Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareWorksmobileGroupsDoesNotMatchBaronGroupOrganizationInGPDTDCDomain(t *testing.T) {
|
||||||
|
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
||||||
|
t.Setenv("BARONGROUP_DOMAIN_ID", "1004")
|
||||||
|
rootID := "038326b6-954a-48a7-a85f-efd83f62b82a"
|
||||||
|
baronGroupID := "96369f12-6b66-4b2a-a916-d1c99d326f02"
|
||||||
|
orgID := "32464fd6-da51-473f-844a-ab88603ad1f0"
|
||||||
|
localTenants := []domain.Tenant{
|
||||||
|
{ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
|
||||||
|
{ID: baronGroupID, Slug: "baron-group", Name: "바론그룹", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID},
|
||||||
|
{ID: orgID, Slug: "rnd-baron", Name: "바론기술개발센터(조직도용)", Type: domain.TenantTypeOrganization, ParentID: &baronGroupID},
|
||||||
|
}
|
||||||
|
remoteGroups := []WorksmobileRemoteGroup{
|
||||||
|
{
|
||||||
|
ID: "works-rnd-baron-gpdtdc",
|
||||||
|
ExternalID: orgID,
|
||||||
|
DisplayName: "바론기술개발센터(조직도용)",
|
||||||
|
DomainID: 1003,
|
||||||
|
DomainName: "총괄기획&기술개발센터",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
items := compareWorksmobileGroups(localTenants, remoteGroups, false)
|
||||||
|
|
||||||
|
require.Len(t, items, 1)
|
||||||
|
require.Equal(t, orgID, items[0].BaronID)
|
||||||
|
require.Equal(t, "missing_in_worksmobile", items[0].Status)
|
||||||
|
require.Empty(t, items[0].WorksmobileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareWorksmobileGroupsMatchesBaronGroupOrganizationInBaronGroupDomain(t *testing.T) {
|
||||||
|
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
||||||
|
t.Setenv("BARONGROUP_DOMAIN_ID", "1004")
|
||||||
|
rootID := "038326b6-954a-48a7-a85f-efd83f62b82a"
|
||||||
|
baronGroupID := "96369f12-6b66-4b2a-a916-d1c99d326f02"
|
||||||
|
orgID := "32464fd6-da51-473f-844a-ab88603ad1f0"
|
||||||
|
localTenants := []domain.Tenant{
|
||||||
|
{ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
|
||||||
|
{ID: baronGroupID, Slug: "baron-group", Name: "바론그룹", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID},
|
||||||
|
{ID: orgID, Slug: "rnd-baron", Name: "바론기술개발센터(조직도용)", Type: domain.TenantTypeOrganization, ParentID: &baronGroupID},
|
||||||
|
}
|
||||||
|
remoteGroups := []WorksmobileRemoteGroup{
|
||||||
|
{
|
||||||
|
ID: "works-rnd-baron",
|
||||||
|
ExternalID: orgID,
|
||||||
|
DisplayName: "바론기술개발센터(조직도용)",
|
||||||
|
DomainID: 1004,
|
||||||
|
DomainName: "바론그룹",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
diffOnly := compareWorksmobileGroups(localTenants, remoteGroups, false)
|
||||||
|
all := compareWorksmobileGroups(localTenants, remoteGroups, true)
|
||||||
|
|
||||||
|
require.Empty(t, diffOnly)
|
||||||
|
require.Len(t, all, 1)
|
||||||
|
require.Equal(t, orgID, all[0].BaronID)
|
||||||
|
require.Equal(t, "matched", all[0].Status)
|
||||||
|
require.Equal(t, int64(1004), all[0].WorksmobileDomainID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWorksmobileSyncServiceRejectsDomainCompanyOrgUnitSync(t *testing.T) {
|
func TestWorksmobileSyncServiceRejectsDomainCompanyOrgUnitSync(t *testing.T) {
|
||||||
@@ -733,6 +884,108 @@ func TestWorksmobileSyncServiceTreatsHanmacFamilyChildCompaniesAsDomainRoots(t *
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWorksmobileSyncServiceUsesBaronGroupDomainForBaronGroupChildOrganization(t *testing.T) {
|
||||||
|
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
||||||
|
t.Setenv("BARONGROUP_DOMAIN_ID", "1004")
|
||||||
|
rootID := "038326b6-954a-48a7-a85f-efd83f62b82a"
|
||||||
|
baronGroupID := "96369f12-6b66-4b2a-a916-d1c99d326f02"
|
||||||
|
orgID := "32464fd6-da51-473f-844a-ab88603ad1f0"
|
||||||
|
root := domain.Tenant{
|
||||||
|
ID: rootID,
|
||||||
|
Slug: HanmacFamilyTenantSlug,
|
||||||
|
Name: "한맥가족",
|
||||||
|
Type: domain.TenantTypeCompanyGroup,
|
||||||
|
}
|
||||||
|
baronGroup := domain.Tenant{
|
||||||
|
ID: baronGroupID,
|
||||||
|
Slug: "baron-group",
|
||||||
|
Name: "바론그룹",
|
||||||
|
Type: domain.TenantTypeCompanyGroup,
|
||||||
|
ParentID: &rootID,
|
||||||
|
}
|
||||||
|
organization := domain.Tenant{
|
||||||
|
ID: orgID,
|
||||||
|
Slug: "rnd-baron",
|
||||||
|
Name: "바론기술개발센터(조직도용)",
|
||||||
|
Type: domain.TenantTypeOrganization,
|
||||||
|
ParentID: &baronGroupID,
|
||||||
|
}
|
||||||
|
outboxRepo := &fakeWorksmobileOutboxRepo{}
|
||||||
|
service := NewWorksmobileSyncService(
|
||||||
|
&fakeWorksmobileTenantService{
|
||||||
|
tenants: map[string]domain.Tenant{
|
||||||
|
root.ID: root,
|
||||||
|
baronGroup.ID: baronGroup,
|
||||||
|
organization.ID: organization,
|
||||||
|
},
|
||||||
|
list: []domain.Tenant{root, baronGroup, organization},
|
||||||
|
},
|
||||||
|
&fakeWorksmobileUserRepo{},
|
||||||
|
outboxRepo,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
item, err := service.EnqueueOrgUnitSync(context.Background(), rootID, organization.ID)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, item)
|
||||||
|
require.Len(t, outboxRepo.created, 1)
|
||||||
|
request := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload)
|
||||||
|
require.Equal(t, int64(1004), request.DomainID)
|
||||||
|
require.Equal(t, "rnd-baron@brsw.kr", request.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorksmobileSyncServiceUsesGPDTDCDomainForGPDTDCChildOrganization(t *testing.T) {
|
||||||
|
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
||||||
|
t.Setenv("BARONGROUP_DOMAIN_ID", "1004")
|
||||||
|
rootID := "038326b6-954a-48a7-a85f-efd83f62b82a"
|
||||||
|
gpdtdcID := "5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee"
|
||||||
|
orgID := "gpdtdc-child-organization"
|
||||||
|
root := domain.Tenant{
|
||||||
|
ID: rootID,
|
||||||
|
Slug: HanmacFamilyTenantSlug,
|
||||||
|
Name: "한맥가족",
|
||||||
|
Type: domain.TenantTypeCompanyGroup,
|
||||||
|
}
|
||||||
|
gpdtdc := domain.Tenant{
|
||||||
|
ID: gpdtdcID,
|
||||||
|
Slug: "gpdtdc",
|
||||||
|
Name: "총괄기획&기술개발센터",
|
||||||
|
Type: domain.TenantTypeOrganization,
|
||||||
|
ParentID: &rootID,
|
||||||
|
}
|
||||||
|
organization := domain.Tenant{
|
||||||
|
ID: orgID,
|
||||||
|
Slug: "planning",
|
||||||
|
Name: "기획",
|
||||||
|
Type: domain.TenantTypeOrganization,
|
||||||
|
ParentID: &gpdtdcID,
|
||||||
|
}
|
||||||
|
outboxRepo := &fakeWorksmobileOutboxRepo{}
|
||||||
|
service := NewWorksmobileSyncService(
|
||||||
|
&fakeWorksmobileTenantService{
|
||||||
|
tenants: map[string]domain.Tenant{
|
||||||
|
root.ID: root,
|
||||||
|
gpdtdc.ID: gpdtdc,
|
||||||
|
organization.ID: organization,
|
||||||
|
},
|
||||||
|
list: []domain.Tenant{root, gpdtdc, organization},
|
||||||
|
},
|
||||||
|
&fakeWorksmobileUserRepo{},
|
||||||
|
outboxRepo,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
item, err := service.EnqueueOrgUnitSync(context.Background(), rootID, organization.ID)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, item)
|
||||||
|
require.Len(t, outboxRepo.created, 1)
|
||||||
|
request := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload)
|
||||||
|
require.Equal(t, int64(1003), request.DomainID)
|
||||||
|
require.Equal(t, "planning@baroncs.co.kr", request.Email)
|
||||||
|
}
|
||||||
|
|
||||||
func TestWorksmobileDomainClassificationUsesAncestorCompanyForGPDTDCOrganization(t *testing.T) {
|
func TestWorksmobileDomainClassificationUsesAncestorCompanyForGPDTDCOrganization(t *testing.T) {
|
||||||
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
||||||
rootID := "root-tenant"
|
rootID := "root-tenant"
|
||||||
|
|||||||
@@ -344,6 +344,7 @@ Worksmobile 운영 화면은 `orgfront`가 아니라 `adminfront`의 tenant deta
|
|||||||
- `baron_guest`, `extended_leave`, `archived`는 Worksmobile delete/deprovision으로 동기화합니다.
|
- `baron_guest`, `extended_leave`, `archived`는 Worksmobile delete/deprovision으로 동기화합니다.
|
||||||
- Baron user delete는 Worksmobile delete로 동기화합니다.
|
- Baron user delete는 Worksmobile delete로 동기화합니다.
|
||||||
- 기존 `inactive` 입력은 `preboarding`, `leave_of_absence` 입력은 `temporary_leave`, `baron_only` 입력은 `baron_guest`로 호환 처리합니다.
|
- 기존 `inactive` 입력은 `preboarding`, `leave_of_absence` 입력은 `temporary_leave`, `baron_only` 입력은 `baron_guest`로 호환 처리합니다.
|
||||||
|
- backend bootstrap은 위 legacy `users.status` 값이 남아 있으면 canonical 상태값으로 자동 정규화합니다.
|
||||||
|
|
||||||
## 테스트 전략
|
## 테스트 전략
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user