ken_nogi/NodeSrv/apps/org-master-sync/test/sync-keycloak.test.js
Kenichiro NOGI ce58cb4be4 初回コミット: dev配下(NodeSrv/Pleasanter等)をGitea管理下に統合
GitHub(nextgroup2706/ken_nogi)は今後使わず自社Gitea運用に切替え。
NodeSrvは旧リポジトリの履歴を破棄しファイルのみ統合(Dokploy用サービスアカウントは
別途mygit-admin/NodeSrv.gitに履歴あり)。notepmエクスポート(12GB)とPleasanter
インストーラzip(208MB)はサイズが大きいため.gitignoreで除外。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 15:37:06 +09:00

345 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"));
});