const { test } = require("node:test"); const assert = require("node:assert"); const { masterItemToAttributes } = require("../src/commands/sync-keycloak"); const { RESULT_ID_ATTR } = require("../src/lib/keycloakColumnSync"); const EMPTY_CHOICE_MAPS = { positionIdToTitle: new Map(), levelIdToTitle: new Map(), userTypeIdToTitle: new Map() }; // applySyncは常にsyncOrgUnitTree(組織階層グループ同期)を実行するため、全テストのfetchImplで // GET /groups?max=1000(トップレベル一覧)とPOST /groups("組織"ルートグループ作成、無ければ)の // 両方を素通りさせるヘルパー。orgUnits=[]なら以降のツリー構築ループは何も行わない function withOrgRootGroupMocks(fetchImpl) { return async (url, opts) => { const urlStr = String(url); if (urlStr.endsWith("/groups?max=1000")) return { ok: true, json: async () => [] }; if (urlStr.endsWith("/groups") && opts && opts.method === "POST") { const body = JSON.parse(opts.body); if (body.name === "組織") { return { ok: true, headers: { get: (n) => (n.toLowerCase() === "location" ? "https://kc.example.test/admin/realms/nexthd/groups/org-root" : null) } }; } } return fetchImpl(url, opts); }; } test("masterItemToAttributes extracts every column's value as a Keycloak string-array attribute, plus the ResultId key", () => { const columns = [{ ColumnName: "ClassA" }, { ColumnName: "Check062" }, { ColumnName: "Class001" }]; const item = { ResultId: 1350, ClassA: "u1-uuid", Check062: true, Class001: "山田" }; const attrs = masterItemToAttributes(item, columns); assert.deepStrictEqual(attrs[RESULT_ID_ATTR], ["1350"]); assert.deepStrictEqual(attrs.ClassA, ["u1-uuid"]); assert.deepStrictEqual(attrs.Check062, ["true"]); assert.deepStrictEqual(attrs.Class001, ["山田"]); }); test("masterItemToAttributes converts missing/null/undefined column values to an empty string", () => { const { masterItemToAttributes } = require("../src/commands/sync-keycloak"); const attrs = masterItemToAttributes({ ResultId: 1 }, [{ ColumnName: "Class999" }]); assert.deepStrictEqual(attrs.Class999, [""]); }); test("masterItemToAttributes converts Pleasanter's unset-date sentinel (1899-...) to an empty string", () => { const item = { ResultId: 1, Date007: "1899-12-30T00:00:00", Date060: "1899/12/31" }; const columns = [{ ColumnName: "Date007" }, { ColumnName: "Date060" }]; const attrs = masterItemToAttributes(item, columns); assert.deepStrictEqual(attrs.Date007, [""]); assert.deepStrictEqual(attrs.Date060, [""]); }); test("masterItemToAttributes keeps a real date value and does not treat falsy non-date values (false, 0) as unset", () => { const item = { ResultId: 1, Date007: "2026-01-15T00:00:00", Check062: false, Class999: 0 }; const columns = [{ ColumnName: "Date007" }, { ColumnName: "Check062" }, { ColumnName: "Class999" }]; const attrs = masterItemToAttributes(item, columns); assert.deepStrictEqual(attrs.Date007, ["2026-01-15T00:00:00"]); assert.deepStrictEqual(attrs.Check062, ["false"]); assert.deepStrictEqual(attrs.Class999, ["0"]); }); test("masterItemToKeycloakUser builds a emailLocalPart_ResultId username, firstName/lastName from Class002/Class001, and enabled from Check062 (not ClassZ)", () => { const { masterItemToKeycloakUser } = require("../src/commands/sync-keycloak"); const user = masterItemToKeycloakUser({ ResultId: 831, ClassA: "uuid-1", ClassB: "taro.yamada@next-hd.co.jp", Class001: "山田", Class002: "太郎", Check062: true, ClassZ: "有" }, [{ ColumnName: "ClassA" }]); assert.strictEqual(user.username, "taro.yamada_831"); assert.strictEqual(user.firstName, "太郎"); assert.strictEqual(user.lastName, "山田"); assert.strictEqual(user.email, "taro.yamada@next-hd.co.jp"); assert.strictEqual(user.enabled, true); }); test("masterItemToKeycloakUser falls back to empty string for username-local-part/firstName/lastName when ClassB/Class001/Class002 are missing", () => { const { masterItemToKeycloakUser } = require("../src/commands/sync-keycloak"); const user = masterItemToKeycloakUser({ ResultId: 999, Check062: false, ClassZ: "無" }, []); assert.strictEqual(user.username, "_999"); assert.strictEqual(user.firstName, ""); assert.strictEqual(user.lastName, ""); }); test("mergeKeycloakUser keeps the existing username unchanged (Keycloak editUsernameAllowed=false rejects username edits) while overwriting firstName/lastName/email/enabled with desired values, keeping protected/current-column attributes, and dropping attributes for columns no longer in Columns", () => { const { mergeKeycloakUser } = require("../src/commands/sync-keycloak"); const { RESULT_ID_ATTR } = require("../src/lib/keycloakColumnSync"); const columns = [{ ColumnName: "ClassA" }]; // Class999はもうColumnsに無い(削除された想定) const existingUser = { id: "kc-1", username: "taro.yamada_831", email: "old@example.com", enabled: true, firstName: "Existing", lastName: "User", attributes: { [RESULT_ID_ATTR]: ["831"], ClassA: ["old-value"], Class999: ["deleted-column-old-value"], // 削除されるべき }, }; const desired = { username: "different-username_831", firstName: "太郎", lastName: "山田", email: "new@example.com", enabled: false, attributes: { [RESULT_ID_ATTR]: ["831"], ClassA: ["new-value"] }, }; const merged = mergeKeycloakUser(existingUser, desired, columns); assert.strictEqual(merged.username, "taro.yamada_831"); // 既存usernameを維持(desired.usernameでは上書きしない) assert.strictEqual(merged.firstName, "太郎"); // マスタが正、firstNameは上書き assert.strictEqual(merged.lastName, "山田"); // マスタが正、lastNameは上書き assert.deepStrictEqual(merged.attributes.ClassA, ["new-value"]); // 新しい値で上書き assert.strictEqual(merged.attributes.Class999, undefined); // Columnsから消えた属性は削除される assert.deepStrictEqual(merged.attributes[RESULT_ID_ATTR], ["831"]); // 突合キーは常に保持 }); test("flatGroupNamesFromMaster resolves Class057/058/059 choice-master ResultIds to prefixed names, skipping unresolved ones (Pleasanter Groups is no longer consulted)", () => { const { flatGroupNamesFromMaster } = require("../src/commands/sync-keycloak"); const choiceMaps = { positionIdToTitle: new Map([["100", "課長"]]), levelIdToTitle: new Map([["200", "正社員"]]), userTypeIdToTitle: new Map([["300", "一般"]]), }; const names = flatGroupNamesFromMaster({ Class057: "100", Class058: "200", Class059: "300" }, choiceMaps); assert.deepStrictEqual(names, ["役職:課長", "職級:正社員", "利用権限タイプ:一般"]); assert.deepStrictEqual(flatGroupNamesFromMaster({ Class057: "999" }, choiceMaps), []); // 未解決IDは無視 }); test("parseOrgUnitsField parses \"orgUnitId=orgUnitName;...\" (merge-master.js's orgUnitsToText format) back into refs", () => { const { parseOrgUnitsField } = require("../src/commands/sync-keycloak"); assert.deepStrictEqual( parseOrgUnitsField("ou-1=情報システム部;ou-2=管理本部"), [{ orgUnitId: "ou-1", orgUnitName: "情報システム部" }, { orgUnitId: "ou-2", orgUnitName: "管理本部" }] ); assert.deepStrictEqual(parseOrgUnitsField(""), []); assert.deepStrictEqual(parseOrgUnitsField(undefined), []); }); test("topoSortOrgUnits orders parents before children by displayLevel then displayOrder", () => { const { topoSortOrgUnits } = require("../src/commands/sync-keycloak"); const orgUnits = [ { orgUnitId: "c", displayLevel: 2, displayOrder: 1 }, { orgUnitId: "a", displayLevel: 1, displayOrder: 2 }, { orgUnitId: "b", displayLevel: 1, displayOrder: 1 }, ]; assert.deepStrictEqual(topoSortOrgUnits(orgUnits).map((o) => o.orgUnitId), ["b", "a", "c"]); }); test("syncOrgUnitTree creates a new hierarchy of Keycloak subgroups under the \"組織\" root, keyed by attributes.orgUnitId (regression: same-name org units under different parents, e.g. 営業設計部)", async () => { const { syncOrgUnitTree } = require("../src/commands/sync-keycloak"); const orgUnits = [ { orgUnitId: "west", orgUnitName: "西東京建設", parentOrgUnitId: null, displayLevel: 1, displayOrder: 1 }, { orgUnitId: "daiichi", orgUnitName: "第一建材工業", parentOrgUnitId: null, displayLevel: 1, displayOrder: 2 }, { orgUnitId: "west-eigyo", orgUnitName: "営業設計部", parentOrgUnitId: "west", displayLevel: 2, displayOrder: 1 }, { orgUnitId: "daiichi-eigyo", orgUnitName: "営業設計部", parentOrgUnitId: "daiichi", displayLevel: 2, displayOrder: 1 }, ]; const createSubGroupCalls = []; const fetchImpl = async (url, opts) => { const urlStr = String(url); if (urlStr.endsWith("/groups?max=1000")) return { ok: true, json: async () => [] }; if (urlStr.endsWith("/groups") && opts.method === "POST") { return { ok: true, headers: { get: (n) => (n.toLowerCase() === "location" ? "https://kc.example.test/admin/realms/nexthd/groups/org-root" : null) } }; } if (urlStr.match(/\/groups\/[^/]+\/children$/) && opts.method === "POST") { const body = JSON.parse(opts.body); createSubGroupCalls.push({ parentId: urlStr.match(/\/groups\/([^/]+)\/children$/)[1], body }); return { ok: true, headers: { get: (n) => (n.toLowerCase() === "location" ? `https://kc.example.test/admin/realms/nexthd/groups/g-${body.attributes.orgUnitId[0]}` : null) } }; } throw new Error(`unexpected fetch: ${urlStr}`); }; const { orgUnitIdToGroupId, created } = await syncOrgUnitTree(orgUnits, { baseUrl: "https://kc.example.test", realm: "nexthd", accessToken: "t", fetchImpl }); assert.strictEqual(created, 5); // ルート「組織」+4 org units // 同名「営業設計部」でも別のKeycloakグループIDに解決される(orgUnitId軸のため取り違えない) assert.notStrictEqual(orgUnitIdToGroupId.get("west-eigyo"), orgUnitIdToGroupId.get("daiichi-eigyo")); const westEigyoCall = createSubGroupCalls.find((c) => c.body.attributes.orgUnitId[0] === "west-eigyo"); assert.strictEqual(westEigyoCall.parentId, orgUnitIdToGroupId.get("west")); // 親は西東京建設グループ }); test("syncOrgUnitTree in dryRun mode does not call any write API but still returns usable (dummy) group ids for downstream user-group-assignment estimation", async () => { const { syncOrgUnitTree } = require("../src/commands/sync-keycloak"); const orgUnits = [{ orgUnitId: "ou-1", orgUnitName: "西東京建設", parentOrgUnitId: null, displayLevel: 1, displayOrder: 1 }]; const fetchImpl = async (url) => { const urlStr = String(url); if (urlStr.endsWith("/groups?max=1000")) return { ok: true, json: async () => [] }; throw new Error(`unexpected write call in dry run: ${urlStr}`); }; const { orgUnitIdToGroupId, created } = await syncOrgUnitTree(orgUnits, { baseUrl: "https://kc.example.test", realm: "nexthd", accessToken: "t", fetchImpl, dryRun: true }); assert.strictEqual(created, 2); // ルート + 1 org unit(見積もりのみ) assert.ok(orgUnitIdToGroupId.get("ou-1")); // ダミーIDが払い出される }); test("planSync includes every master item without filtering by ClassZ (needed for delete detection)", () => { const { planSync } = require("../src/commands/sync-keycloak"); const masterItems = [ { ResultId: 1, ClassA: "u1", ClassZ: "有" }, { ResultId: 2, ClassA: "u2", ClassZ: "無" }, ]; const plan = planSync(masterItems); assert.strictEqual(plan.length, 2); }); test("applySync creates a new Keycloak user only when ClassZ is 有, and skips creation for LINEWORKS-absent (ClassZ=無) master-only items with no existing Keycloak user", async () => { const { planSync, applySync } = require("../src/commands/sync-keycloak"); const columns = [{ ColumnName: "ClassA", LabelText: "社員ID" }]; const masterItems = [ { ResultId: 831, ClassA: "u1-uuid", ClassB: "taro@next-hd.co.jp", Check062: true, ClassZ: "有", Description062: "" }, { ResultId: 1350, ClassA: "", ClassB: "", Check062: false, ClassZ: "無", Description062: "" }, // プリザンター専用、Keycloak未登録 ]; const plan = planSync(masterItems); const calls = []; let linkedFederatedIdentityBody; const fetchImpl = withOrgRootGroupMocks(async (url, opts) => { const urlStr = String(url); calls.push(urlStr); if (urlStr.endsWith("/users/profile") && (!opts || !opts.method || opts.method === "GET")) return { ok: true, json: async () => ({ attributes: [{ name: "email" }] }) }; if (urlStr.endsWith("/users/profile") && opts.method === "PUT") return { ok: true }; if (urlStr.includes("/users?q=")) return { ok: true, json: async () => [] }; // どちらも未登録 if (urlStr.includes("/users?email=")) return { ok: true, json: async () => [] }; if (urlStr.endsWith("/users") && opts.method === "POST") { return { ok: true, headers: { get: (n) => (n.toLowerCase() === "location" ? "https://kc.example.test/admin/realms/nexthd/users/new-user-id" : null) } }; } if (urlStr.endsWith("/users/new-user-id/federated-identity/lineworks") && opts.method === "POST") { linkedFederatedIdentityBody = JSON.parse(opts.body); return { ok: true }; } throw new Error(`unexpected fetch: ${urlStr}`); }); const result = await applySync(plan, columns, [], EMPTY_CHOICE_MAPS, { baseUrl: "https://kc.example.test", realm: "nexthd", accessToken: "t", fetchImpl }); assert.strictEqual(result.created, 1); // ResultId=831のみ作成 assert.ok(!calls.some((u) => u.endsWith("/users") && u.includes("POST"))); // 簡易チェック(実際はPOST回数で見る) // 新規作成直後、確認なしログインのためlineworks IdPとのfederated-identityを事前登録する assert.deepStrictEqual(linkedFederatedIdentityBody, { identityProvider: "lineworks", userId: "taro@next-hd.co.jp", userName: "taro@next-hd.co.jp" }); }); test("applySync updates an existing user's enabled flag based on Check062 even when ClassZ is 無 (deactivation, not deletion)", async () => { const { planSync, applySync } = require("../src/commands/sync-keycloak"); const columns = [{ ColumnName: "ClassA", LabelText: "社員ID" }]; const masterItems = [{ ResultId: 10, ClassA: "u1", ClassB: "taro@next-hd.co.jp", Check062: false, ClassZ: "無", Description062: "" }]; // 退職済み、レコード自体は残っている const plan = planSync(masterItems); let putUserBody, deleteCalled = false, linkedFederatedIdentityBody; const fetchImpl = withOrgRootGroupMocks(async (url, opts) => { const urlStr = String(url); if (urlStr.endsWith("/users/profile")) return { ok: true, json: async () => ({ attributes: [{ name: "ClassA" }, { name: "pleasanterResultId" }] }) }; if (urlStr.includes("/users?q=")) return { ok: true, json: async () => [{ id: "existing-id", username: "pleasanter-10", attributes: { pleasanterResultId: ["10"], ClassA: ["old"] } }] }; if (urlStr.endsWith("/users/existing-id") && opts.method === "PUT") { putUserBody = JSON.parse(opts.body); return { ok: true }; } if (urlStr.endsWith("/users/existing-id") && opts.method === "DELETE") { deleteCalled = true; return { ok: true }; } if (urlStr.endsWith("/users/existing-id/federated-identity") && (!opts || !opts.method || opts.method === "GET")) return { ok: true, json: async () => [] }; if (urlStr.endsWith("/users/existing-id/federated-identity/lineworks") && opts.method === "POST") { linkedFederatedIdentityBody = JSON.parse(opts.body); return { ok: true }; } throw new Error(`unexpected fetch: ${urlStr}`); }); const result = await applySync(plan, columns, [], EMPTY_CHOICE_MAPS, { baseUrl: "https://kc.example.test", realm: "nexthd", accessToken: "t", fetchImpl, lastSyncedResultIds: [10] }); assert.strictEqual(result.updated, 1); assert.strictEqual(putUserBody.enabled, false); // 無効化 assert.strictEqual(deleteCalled, false); // 削除はされない(レコード自体は存在するため) // 既存ユーザーでもfederated-identity未リンクなら事前登録する assert.deepStrictEqual(linkedFederatedIdentityBody, { identityProvider: "lineworks", userId: "taro@next-hd.co.jp", userName: "taro@next-hd.co.jp" }); }); test("applySync does not re-link federated-identity when the user is already linked to lineworks", async () => { const { planSync, applySync } = require("../src/commands/sync-keycloak"); const columns = [{ ColumnName: "ClassA", LabelText: "社員ID" }]; const masterItems = [{ ResultId: 10, ClassA: "u1", ClassB: "taro@next-hd.co.jp", Check062: true, ClassZ: "有", Description062: "" }]; const plan = planSync(masterItems); let linkCalled = false; const fetchImpl = withOrgRootGroupMocks(async (url, opts) => { const urlStr = String(url); if (urlStr.endsWith("/users/profile")) return { ok: true, json: async () => ({ attributes: [{ name: "ClassA" }, { name: "pleasanterResultId" }] }) }; if (urlStr.includes("/users?q=")) return { ok: true, json: async () => [{ id: "existing-id", username: "pleasanter-10", attributes: { pleasanterResultId: ["10"], ClassA: ["old"] } }] }; if (urlStr.endsWith("/users/existing-id") && opts.method === "PUT") return { ok: true }; if (urlStr.endsWith("/users/existing-id/federated-identity") && (!opts || !opts.method || opts.method === "GET")) { return { ok: true, json: async () => [{ identityProvider: "lineworks", userId: "taro@next-hd.co.jp" }] }; } if (urlStr.endsWith("/users/existing-id/federated-identity/lineworks") && opts.method === "POST") { linkCalled = true; return { ok: true }; } throw new Error(`unexpected fetch: ${urlStr}`); }); await applySync(plan, columns, [], EMPTY_CHOICE_MAPS, { baseUrl: "https://kc.example.test", realm: "nexthd", accessToken: "t", fetchImpl, lastSyncedResultIds: [10] }); assert.strictEqual(linkCalled, false); }); test("applySync deletes the Keycloak user for a ResultId that was synced last time but is absent from today's master (record physically removed)", async () => { const { applySync } = require("../src/commands/sync-keycloak"); const columns = [{ ColumnName: "ClassA", LabelText: "社員ID" }]; const plan = []; // 今回のマスタにResultId=99は存在しない let deletedUserId; const fetchImpl = withOrgRootGroupMocks(async (url, opts) => { const urlStr = String(url); if (urlStr.endsWith("/users/profile")) return { ok: true, json: async () => ({ attributes: [{ name: "pleasanterResultId" }] }) }; if (urlStr.includes("/users?q=pleasanterResultId%3A99")) return { ok: true, json: async () => [{ id: "to-delete-id" }] }; if (urlStr.endsWith("/users/to-delete-id") && opts.method === "DELETE") { deletedUserId = "to-delete-id"; return { ok: true }; } throw new Error(`unexpected fetch: ${urlStr}`); }); const result = await applySync(plan, columns, [], EMPTY_CHOICE_MAPS, { baseUrl: "https://kc.example.test", realm: "nexthd", accessToken: "t", fetchImpl, lastSyncedResultIds: [99] }); assert.strictEqual(result.deleted, 1); assert.strictEqual(deletedUserId, "to-delete-id"); }); test("applySync in dryRun mode counts additions/removals/create/update/delete/org-group-creations without calling any write API", async () => { const { planSync, applySync } = require("../src/commands/sync-keycloak"); const columns = [{ ColumnName: "ClassA", LabelText: "社員ID" }]; const masterItems = [{ ResultId: 1, ClassA: "u1", ClassB: "", Check062: true, ClassZ: "有", Description062: "" }]; const plan = planSync(masterItems); const orgUnits = [{ orgUnitId: "ou-1", orgUnitName: "西東京建設", parentOrgUnitId: null, displayLevel: 1, displayOrder: 1 }]; const calls = []; const fetchImpl = async (url, opts) => { const urlStr = String(url); calls.push({ url: urlStr, method: opts && opts.method }); if (urlStr.endsWith("/users/profile")) return { ok: true, json: async () => ({ attributes: [{ name: "Class999" }, { name: "pleasanterResultId" }] }) }; if (urlStr.endsWith("/groups?max=1000")) return { ok: true, json: async () => [] }; if (urlStr.includes("/users?q=")) return { ok: true, json: async () => [] }; throw new Error(`unexpected fetch in dry run: ${urlStr}`); }; const result = await applySync(plan, columns, orgUnits, EMPTY_CHOICE_MAPS, { baseUrl: "https://kc.example.test", realm: "nexthd", accessToken: "t", fetchImpl, dryRun: true, lastSyncedColumnNames: ["Class999"], lastSyncedResultIds: [99], // 99は今回planに無いので削除予定1件になるはず }); assert.strictEqual(result.profileAttributesAdded, 1); // ClassA assert.strictEqual(result.profileAttributesRemoved, 1); // Class999 assert.strictEqual(result.created, 1); assert.strictEqual(result.deleted, 1); // 99が削除予定(dryRunなので実際のDELETE呼び出しは無い) assert.strictEqual(result.orgGroupsCreated, 2); // ルート「組織」+ ou-1(見積もりのみ) assert.ok(!calls.some((c) => c.method === "POST" || c.method === "PUT" || c.method === "DELETE")); });