Răsfoiți Sursa

Merge pull request #8089 from weseek/imprv/129542-129840-show-sync-status-on-external-group-sync

Imprv/129542 129840 show sync status on external group sync
Yuki Takei 2 ani în urmă
părinte
comite
4bfcbb946e

+ 4 - 6
apps/app/public/static/locales/en_US/admin.json

@@ -1064,8 +1064,9 @@
     "update_sync_settings_failed": "Failed to update sync settings",
     "description_form_detail": "Please note that edited value will be overwritten on next sync if description mapper is set in sync settings",
     "only_description_edit_allowed": "Only description can be edited for external user groups",
-    "sync_succeeded": "Sync succeeded",
-    "sync_failed": "Sync failed",
+    "sync_being_executed": "There is a running external group sync process started by you or another user. The next sync cannot be executed until this finishes.",
+    "sync_succeeded": "External group sync succeeded",
+    "sync_failed": "External group sync failed",
     "ldap": {
       "group_sync_settings": "LDAP Group Sync Settings",
       "group_search_base_DN": "Group Search Base DN",
@@ -1080,10 +1081,7 @@
       "name_mapper_detail": "Attribute to map as group name",
       "updated_group_sync_settings": "Updated LDAP group sync settings",
       "password": "Password",
-      "password_detail": "Login password is necessary because Bind type is set to User Bind",
-      "circular_reference": "Sync failed because there is a possible circular reference in your LDAP group tree structure",
-      "group_search_failed": "LDAP group search failed. Please check your LDAP security settings and group sync settings.",
-      "user_search_failed": "LDAP user search failed. Please check your LDAP security settings and group sync settings."
+      "password_detail": "Login password is necessary because Bind type is set to User Bind"
     },
     "keycloak": {
       "group_sync_settings": "Keycloak Group Sync Settings",

+ 4 - 5
apps/app/public/static/locales/ja_JP/admin.json

@@ -1073,7 +1073,9 @@
     "update_sync_settings_failed": "同期設定の更新が失敗しました",
     "description_form_detail": "同期設定で「説明」の mapper が設定されている場合、編集内容は再同期によって上書きされることに注意してください",
     "only_description_edit_allowed": "外部グループは説明の編集のみが可能です",
-    "sync_succeeded": "同期に成功しました",
+    "sync_being_executed": "自身または他のユーザが実行した外部グループ同期が終了するまで次の実行ができません",
+    "sync_succeeded": "外部グループ同期に成功しました",
+    "sync_failed": "外部グループ同期に失敗しました",
     "ldap": {
       "group_sync_settings": "LDAP グループ同期設定",
       "group_search_base_DN": "グループ検索ベース DN",
@@ -1088,10 +1090,7 @@
       "name_mapper_detail": "グループの「名前」として読み込む属性",
       "updated_group_sync_settings": "LDAP グループ同期設定を更新しました",
       "password": "パスワード",
-      "password_detail": "認証設定がユーザ Bind のため、ログイン時のパスワードの入力が必要となります",
-      "circular_reference": "LDAP グループの木構造に循環参照が行われている可能性があるため、同期に失敗しました",
-      "group_search_failed": "LDAP グループ検索に失敗しました。LDAP セキュリティ設定、グループ同期設定が正しいことを確認してください。",
-      "user_search_failed": "LDAP ユーザ検索に失敗しました。LDAP セキュリティ設定、グループ同期設定が正しいことを確認してください。"
+      "password_detail": "認証設定がユーザ Bind のため、ログイン時のパスワードの入力が必要となります"
     },
     "keycloak": {
       "group_sync_settings": "Keycloak グループ同期設定",

+ 4 - 5
apps/app/public/static/locales/zh_CN/admin.json

@@ -1072,7 +1072,9 @@
     "update_sync_settings_failed": "Failed to update sync settings",
     "description_form_detail": "Please note that edited value will be overwritten on next sync if description mapper is set in sync settings",
     "only_description_edit_allowed": "Only description can be edited for external user groups",
-    "sync_succeeded": "Sync succeeded",
+    "sync_being_executed": "There is a running external group sync process started by you or another user. The next sync cannot be executed until this finishes.",
+    "sync_succeeded": "External group sync succeeded",
+    "sync_failed": "External group sync failed",
     "ldap": {
       "group_sync_settings": "LDAP Group Sync Settings",
       "group_search_base_DN": "Group Search Base DN",
@@ -1087,10 +1089,7 @@
       "name_mapper_detail": "Attribute to map as group name",
       "updated_group_sync_settings": "Updated LDAP group sync settings",
       "password": "Password",
-      "password_detail": "Login password is necessary because Bind type is set to User Bind",
-      "circular_reference": "Sync failed because there is a possible circular reference in your LDAP group tree structure",
-      "group_search_failed": "LDAP group search failed. Please check your LDAP security settings and group sync settings.",
-      "user_search_failed": "LDAP user search failed. Please check your LDAP security settings and group sync settings."
+      "password_detail": "Login password is necessary because Bind type is set to User Bind"
     },
     "keycloak": {
       "group_sync_settings": "Keycloak Group Sync Settings",

+ 3 - 2
apps/app/src/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx

@@ -3,6 +3,7 @@ import React from 'react';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 
+import { SocketEventName } from '~/interfaces/websocket';
 import { useAdminSocket } from '~/stores/socket-io';
 
 import LabeledProgressBar from '../Common/LabeledProgressBar';
@@ -27,7 +28,7 @@ class RebuildIndexControls extends React.Component {
     const { socket } = this.props;
 
     if (socket != null) {
-      socket.on('addPageProgress', (data) => {
+      socket.on(SocketEventName.AddPageProgress, (data) => {
         this.setState({
           total: data.totalCount,
           current: data.count,
@@ -35,7 +36,7 @@ class RebuildIndexControls extends React.Component {
         });
       });
 
-      socket.on('finishAddPage', (data) => {
+      socket.on(SocketEventName.FinishAddPage, (data) => {
         this.setState({
           total: data.totalCount,
           current: data.count,

+ 6 - 21
apps/app/src/features/external-user-group/client/components/ExternalUserGroup/KeycloakGroupManagement.tsx

@@ -1,36 +1,21 @@
 import { FC, useCallback } from 'react';
 
-import { useTranslation } from 'react-i18next';
-
 import { apiv3Put } from '~/client/util/apiv3-client';
-import { toastError, toastSuccess } from '~/client/util/toastr';
+import { ExternalGroupProviderType } from '~/features/external-user-group/interfaces/external-user-group';
 
 import { KeycloakGroupSyncSettingsForm } from './KeycloakGroupSyncSettingsForm';
+import { SyncExecution } from './SyncExecution';
 
 export const KeycloakGroupManagement: FC = () => {
-  const { t } = useTranslation('admin');
 
-  const onSyncBtnClick = useCallback(async(e) => {
-    e.preventDefault();
-    try {
-      await apiv3Put('/external-user-groups/keycloak/sync');
-      toastSuccess(t('external_user_group.sync_succeeded'));
-    }
-    catch (errs) {
-      toastError(t(errs[0]?.code));
-    }
-  }, [t]);
+  const requestSyncAPI = useCallback(async() => {
+    await apiv3Put('/external-user-groups/keycloak/sync');
+  }, []);
 
   return (
     <>
       <KeycloakGroupSyncSettingsForm />
-      <h3 className="border-bottom mb-3">{t('external_user_group.execute_sync')}</h3>
-      <form onSubmit={onSyncBtnClick}>
-        <div className="row">
-          <div className="col-md-3"></div>
-          <div className="col-md-6"><button className="btn btn-primary" type="submit">{t('external_user_group.sync')}</button></div>
-        </div>
-      </form>
+      <SyncExecution provider={ExternalGroupProviderType.keycloak} requestSyncAPI={requestSyncAPI} />
     </>
   );
 };

+ 30 - 38
apps/app/src/features/external-user-group/client/components/ExternalUserGroup/LdapGroupManagement.tsx

@@ -5,9 +5,11 @@ import {
 import { useTranslation } from 'react-i18next';
 
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
-import { toastError, toastSuccess } from '~/client/util/toastr';
+import { toastError } from '~/client/util/toastr';
+import { ExternalGroupProviderType } from '~/features/external-user-group/interfaces/external-user-group';
 
 import { LdapGroupSyncSettingsForm } from './LdapGroupSyncSettingsForm';
+import { SyncExecution } from './SyncExecution';
 
 export const LdapGroupManagement: FC = () => {
   const [isUserBind, setIsUserBind] = useState(false);
@@ -27,49 +29,39 @@ export const LdapGroupManagement: FC = () => {
     getIsUserBind();
   }, []);
 
-  const onSyncBtnClick = useCallback(async(e) => {
-    e.preventDefault();
-    try {
-      if (isUserBind) {
-        const password = e.target.password.value;
-        await apiv3Put('/external-user-groups/ldap/sync', { password });
-      }
-      else {
-        await apiv3Put('/external-user-groups/ldap/sync');
-      }
-      toastSuccess(t('external_user_group.sync_succeeded'));
+  const requestSyncAPI = useCallback(async(e) => {
+    if (isUserBind) {
+      const password = e.target.password.value;
+      await apiv3Put('/external-user-groups/ldap/sync', { password });
     }
-    catch (errs) {
-      toastError(t(errs[0]?.message));
+    else {
+      await apiv3Put('/external-user-groups/ldap/sync');
     }
-  }, [t, isUserBind]);
+  }, [isUserBind]);
+
+  const AdditionalForm = (): JSX.Element => {
+    return isUserBind ? (
+      <div className="row form-group">
+        <label htmlFor="ldapGroupSyncPassword" className="text-left text-md-right col-md-3 col-form-label">{t('external_user_group.ldap.password')}</label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="password"
+            name="password"
+            id="ldapGroupSyncPassword"
+          />
+          <p className="form-text text-muted">
+            <small>{t('external_user_group.ldap.password_detail')}</small>
+          </p>
+        </div>
+      </div>
+    ) : <></>;
+  };
 
   return (
     <>
       <LdapGroupSyncSettingsForm />
-      <h3 className="border-bottom mb-3">{t('external_user_group.execute_sync')}</h3>
-      <form onSubmit={onSyncBtnClick}>
-        {isUserBind && (
-          <div className="row form-group">
-            <label htmlFor="ldapGroupSyncPassword" className="text-left text-md-right col-md-3 col-form-label">{t('external_user_group.ldap.password')}</label>
-            <div className="col-md-6">
-              <input
-                className="form-control"
-                type="password"
-                name="password"
-                id="ldapGroupSyncPassword"
-              />
-              <p className="form-text text-muted">
-                <small>{t('external_user_group.ldap.password_detail')}</small>
-              </p>
-            </div>
-          </div>
-        )}
-        <div className="row">
-          <div className="col-md-3"></div>
-          <div className="col-md-6"><button className="btn btn-primary" type="submit">{t('external_user_group.sync')}</button></div>
-        </div>
-      </form>
+      <SyncExecution provider={ExternalGroupProviderType.ldap} requestSyncAPI={requestSyncAPI} AdditionalForm={AdditionalForm} />
     </>
   );
 };

+ 127 - 0
apps/app/src/features/external-user-group/client/components/ExternalUserGroup/SyncExecution.tsx

@@ -0,0 +1,127 @@
+import {
+  FC, useCallback, useEffect, useState,
+} from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import LabeledProgressBar from '~/components/Admin/Common/LabeledProgressBar';
+import { ExternalGroupProviderType } from '~/features/external-user-group/interfaces/external-user-group';
+import { SocketEventName } from '~/interfaces/websocket';
+import { useAdminSocket } from '~/stores/socket-io';
+
+import { useSWRxExternalUserGroupList } from '../../stores/external-user-group';
+
+type SyncExecutionProps = {
+  provider: ExternalGroupProviderType
+  requestSyncAPI: (e) => Promise<void>
+  AdditionalForm?: FC
+}
+
+enum SyncStatus {
+  beforeSync,
+  syncExecuting,
+  syncCompleted,
+  syncFailed,
+}
+
+export const SyncExecution = ({
+  provider,
+  requestSyncAPI,
+  AdditionalForm = () => <></>,
+}: SyncExecutionProps): JSX.Element => {
+  const { t } = useTranslation('admin');
+  const { data: socket } = useAdminSocket();
+  const { mutate: mutateExternalUserGroups } = useSWRxExternalUserGroupList();
+  const [syncStatus, setSyncStatus] = useState<SyncStatus>(SyncStatus.beforeSync);
+  const [progress, setProgress] = useState({
+    total: 0,
+    current: 0,
+  });
+
+  useEffect(() => {
+    if (socket == null) return;
+
+    const eventName = SocketEventName.externalUserGroup[provider];
+
+    socket.on(eventName.GroupSyncProgress, (data) => {
+      setSyncStatus(SyncStatus.syncExecuting);
+      setProgress({
+        total: data.totalCount,
+        current: data.count,
+      });
+    });
+
+    socket.on(eventName.GroupSyncCompleted, () => {
+      setSyncStatus(SyncStatus.syncCompleted);
+      mutateExternalUserGroups();
+      toastSuccess(t('external_user_group.sync_succeeded'));
+    });
+
+    socket.on(eventName.GroupSyncFailed, () => {
+      setSyncStatus(SyncStatus.syncFailed);
+      mutateExternalUserGroups();
+      toastError(t('external_user_group.sync_failed'));
+    });
+
+    return () => {
+      socket.off(eventName.GroupSyncProgress);
+      socket.off(eventName.GroupSyncCompleted);
+      socket.off(eventName.GroupSyncFailed);
+    };
+  }, [socket, mutateExternalUserGroups, t, provider]);
+
+  const onSyncBtnClick = useCallback(async(e) => {
+    e.preventDefault();
+    try {
+      await requestSyncAPI(e);
+      setProgress({ total: 0, current: 0 });
+      setSyncStatus(SyncStatus.syncExecuting);
+    }
+    catch (errs) {
+      toastError(t(errs[0]?.code));
+    }
+  }, [t, requestSyncAPI]);
+
+  const renderProgressBar = () => {
+    if (syncStatus === SyncStatus.beforeSync) return null;
+
+    let header;
+    if (syncStatus === SyncStatus.syncExecuting) {
+      header = 'Processing..';
+    }
+    else if (syncStatus === SyncStatus.syncCompleted) {
+      header = 'Completed';
+    }
+    else {
+      header = 'Failed';
+    }
+
+    return (
+      <LabeledProgressBar
+        header={header}
+        currentCount={progress.current}
+        totalCount={progress.total}
+      />
+    );
+  };
+
+  return (
+    <>
+      <h3 className="border-bottom mb-3">{t('external_user_group.execute_sync')}</h3>
+      <div className="row">
+        <div className="col-md-3"></div>
+        <div className="col-md-9">
+          {renderProgressBar()}
+        </div>
+      </div>
+      <form onSubmit={onSyncBtnClick}>
+        <AdditionalForm />
+        <div className="row">
+          <div className="col-md-3"></div>
+          <div className="col-md-6"><button className="btn btn-primary" type="submit">{t('external_user_group.sync')}</button></div>
+        </div>
+      </form>
+    </>
+  );
+};

+ 23 - 22
apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts

@@ -17,9 +17,6 @@ import { configManager } from '~/server/service/config-manager';
 import UserGroupService from '~/server/service/user-group';
 import loggerFactory from '~/utils/logger';
 
-import KeycloakUserGroupSyncService from '../../service/keycloak-user-group-sync';
-import LdapUserGroupSyncService from '../../service/ldap-user-group-sync';
-
 const logger = loggerFactory('growi:routes:apiv3:external-user-group');
 
 const router = Router();
@@ -35,6 +32,10 @@ module.exports = (crowi: Crowi): Router => {
 
   const activityEvent = crowi.event('activity');
 
+  const isExecutingSync = () => {
+    return crowi.ldapUserGroupSyncService?.isExecutingSync || crowi.keycloakUserGroupSyncService?.isExecutingSync || false;
+  };
+
   const validators = {
     ldapSyncSettings: [
       body('ldapGroupSearchBase').optional({ nullable: true }).isString(),
@@ -307,19 +308,26 @@ module.exports = (crowi: Crowi): Router => {
     });
 
   router.put('/ldap/sync', loginRequiredStrictly, adminRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
-    try {
-      const ldapUserGroupSyncService = new LdapUserGroupSyncService(crowi.passportService, req.user.name, req.body.password);
-      await ldapUserGroupSyncService.syncExternalUserGroups();
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err(err.message, 500);
+    if (isExecutingSync()) {
+      return res.apiv3Err(
+        new ErrorV3('There is an ongoing sync process', 'external_user_group.sync_being_executed'), 409,
+      );
     }
 
-    return res.apiv3({}, 204);
+    // Do not await for sync to finish. Result (completed, failed) will be notified to the client by socket-io.
+    await crowi.ldapUserGroupSyncService?.init(req.user.name, req.body.password);
+    crowi.ldapUserGroupSyncService?.syncExternalUserGroups();
+
+    return res.apiv3({}, 202);
   });
 
   router.put('/keycloak/sync', loginRequiredStrictly, adminRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    if (isExecutingSync()) {
+      return res.apiv3Err(
+        new ErrorV3('There is an ongoing sync process', 'external_user_group.sync_being_executed'), 409,
+      );
+    }
+
     const getAuthProviderType = () => {
       const kcHost = configManager?.getConfig('crowi', 'external-user-group:keycloak:host');
       const kcGroupRealm = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupRealm');
@@ -348,18 +356,11 @@ module.exports = (crowi: Crowi): Router => {
       );
     }
 
-    try {
-      const keycloakUserGroupSyncService = new KeycloakUserGroupSyncService(authProviderType);
-      await keycloakUserGroupSyncService.syncExternalUserGroups();
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err(
-        new ErrorV3('Sync failed', 'external_user_group.sync_failed'), 500,
-      );
-    }
+    // Do not await for sync to finish. Result (completed, failed) will be notified to the client by socket-io.
+    crowi.keycloakUserGroupSyncService?.init(authProviderType);
+    crowi.keycloakUserGroupSyncService?.syncExternalUserGroups();
 
-    return res.apiv3({}, 204);
+    return res.apiv3({}, 202);
   });
 
   return router;

+ 112 - 25
apps/app/src/features/external-user-group/server/service/external-user-group-sync.ts

@@ -1,7 +1,12 @@
 import type { IUserHasId } from '@growi/core';
 
+import { SocketEventName } from '~/interfaces/websocket';
 import ExternalAccount from '~/server/models/external-account';
+import S2sMessage from '~/server/models/vo/s2s-message';
+import { S2sMessagingService } from '~/server/service/s2s-messaging/base';
+import { S2sMessageHandlable } from '~/server/service/s2s-messaging/handlable';
 import { excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
+import loggerFactory from '~/utils/logger';
 import { batchProcessPromiseAll } from '~/utils/promise';
 
 import { configManager } from '../../../../server/service/config-manager';
@@ -12,21 +17,69 @@ import {
 import ExternalUserGroup from '../models/external-user-group';
 import ExternalUserGroupRelation from '../models/external-user-group-relation';
 
+const logger = loggerFactory('growi:service:external-user-group-sync-service');
+
 // When d = max depth of group trees
 // Max space complexity of syncExternalUserGroups will be:
 // O(TREES_BATCH_SIZE * d * USERS_BATCH_SIZE)
 const TREES_BATCH_SIZE = 10;
 const USERS_BATCH_SIZE = 30;
 
-abstract class ExternalUserGroupSyncService {
+class ExternalUserGroupSyncS2sMessage extends S2sMessage {
+
+  isExecutingSync: boolean;
+
+}
+
+abstract class ExternalUserGroupSyncService implements S2sMessageHandlable {
 
   groupProviderType: ExternalGroupProviderType; // name of external service that contains user group info (e.g: ldap, keycloak)
 
-  authProviderType: string; // auth provider type (e.g: ldap, oidc)
+  authProviderType: string | null; // auth provider type (e.g: ldap, oidc). Has to be set before syncExternalUserGroups execution.
 
-  constructor(groupProviderType: ExternalGroupProviderType, authProviderType: string) {
+  socketIoService: any;
+
+  s2sMessagingService: S2sMessagingService | null;
+
+  isExecutingSync = false;
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  constructor(groupProviderType: ExternalGroupProviderType, s2sMessagingService: S2sMessagingService | null, socketIoService) {
     this.groupProviderType = groupProviderType;
-    this.authProviderType = authProviderType;
+    this.s2sMessagingService = s2sMessagingService;
+    this.socketIoService = socketIoService;
+  }
+
+  /**
+   * @inheritdoc
+   */
+  shouldHandleS2sMessage(s2sMessage: ExternalUserGroupSyncS2sMessage): boolean {
+    return s2sMessage.eventName === 'switchExternalUserGroupExecSyncStatus';
+  }
+
+  /**
+   * @inheritdoc
+   */
+  async handleS2sMessage(s2sMessage: ExternalUserGroupSyncS2sMessage): Promise<void> {
+    logger.info(`Set isExecutingSync to ${s2sMessage.isExecutingSync} by pubsub notification`);
+    this.isExecutingSync = s2sMessage.isExecutingSync;
+  }
+
+  async switchIsExecutingSync(isExecutingSync: boolean): Promise<void> {
+    this.isExecutingSync = isExecutingSync;
+
+    if (this.s2sMessagingService != null) {
+      const s2sMessage = new ExternalUserGroupSyncS2sMessage('switchExternalUserGroupExecSyncStatus', {
+        isExecutingSync,
+      });
+
+      try {
+        await this.s2sMessagingService.publish(s2sMessage);
+      }
+      catch (e) {
+        logger.error('Failed to publish update message with S2sMessagingService: ', e.message);
+      }
+    }
   }
 
   /** External user group tree sync method
@@ -35,28 +88,49 @@ abstract class ExternalUserGroupSyncService {
    * 3. If preserveDeletedLDAPGroups is false、delete all ExternalUserGroups that were not found during tree search
   */
   async syncExternalUserGroups(): Promise<void> {
-    const trees = await this.generateExternalUserGroupTrees();
+    if (this.authProviderType == null) throw new Error('auth provider type is not set');
+    if (this.isExecutingSync) throw new Error('External user group sync is already being executed');
+    await this.switchIsExecutingSync(true);
 
+    const preserveDeletedLdapGroups: boolean = configManager?.getConfig('crowi', `external-user-group:${this.groupProviderType}:preserveDeletedGroups`);
     const existingExternalUserGroupIds: string[] = [];
 
-    const syncNode = async(node: ExternalUserGroupTreeNode, parentId?: string) => {
-      const externalUserGroup = await this.createUpdateExternalUserGroup(node, parentId);
-      existingExternalUserGroupIds.push(externalUserGroup._id);
-      // Do not use Promise.all, because the number of promises processed can
-      // exponentially grow when group tree is enormous
-      for await (const childNode of node.childGroupNodes) {
-        await syncNode(childNode, externalUserGroup._id);
+    const socket = this.socketIoService?.getAdminSocket();
+
+    try {
+      const trees = await this.generateExternalUserGroupTrees();
+      const totalCount = trees.map(tree => this.getGroupCountOfTree(tree))
+        .reduce((sum, current) => sum + current);
+      let count = 0;
+
+      const syncNode = async(node: ExternalUserGroupTreeNode, parentId?: string) => {
+        const externalUserGroup = await this.createUpdateExternalUserGroup(node, parentId);
+        existingExternalUserGroupIds.push(externalUserGroup._id);
+        count++;
+        socket?.emit(SocketEventName.externalUserGroup[this.groupProviderType].GroupSyncProgress, { totalCount, count });
+        // Do not use Promise.all, because the number of promises processed can
+        // exponentially grow when group tree is enormous
+        for await (const childNode of node.childGroupNodes) {
+          await syncNode(childNode, externalUserGroup._id);
+        }
+      };
+
+      await batchProcessPromiseAll(trees, TREES_BATCH_SIZE, async(tree) => {
+        return syncNode(tree);
+      });
+
+      if (!preserveDeletedLdapGroups) {
+        await ExternalUserGroup.deleteMany({ _id: { $nin: existingExternalUserGroupIds }, groupProviderType: this.groupProviderType });
+        await ExternalUserGroupRelation.removeAllInvalidRelations();
       }
-    };
-
-    await batchProcessPromiseAll(trees, TREES_BATCH_SIZE, (root) => {
-      return syncNode(root);
-    });
-
-    const preserveDeletedLdapGroups: boolean = configManager?.getConfig('crowi', `external-user-group:${this.groupProviderType}:preserveDeletedGroups`);
-    if (!preserveDeletedLdapGroups) {
-      await ExternalUserGroup.deleteMany({ _id: { $nin: existingExternalUserGroupIds }, groupProviderType: this.groupProviderType });
-      await ExternalUserGroupRelation.removeAllInvalidRelations();
+      socket?.emit(SocketEventName.externalUserGroup[this.groupProviderType].GroupSyncCompleted);
+    }
+    catch (e) {
+      logger.error(e.message);
+      socket?.emit(SocketEventName.externalUserGroup[this.groupProviderType].GroupSyncFailed);
+    }
+    finally {
+      await this.switchIsExecutingSync(false);
     }
   }
 
@@ -68,7 +142,7 @@ abstract class ExternalUserGroupSyncService {
    * @param {string} parentId Parent group id (id in GROWI) of the group we want to create/update
    * @returns {Promise<IExternalUserGroupHasId>} ExternalUserGroup that was created/updated
   */
-  async createUpdateExternalUserGroup(node: ExternalUserGroupTreeNode, parentId?: string): Promise<IExternalUserGroupHasId> {
+  private async createUpdateExternalUserGroup(node: ExternalUserGroupTreeNode, parentId?: string): Promise<IExternalUserGroupHasId> {
     const externalUserGroup = await ExternalUserGroup.findAndUpdateOrCreateGroup(
       node.name, node.id, this.groupProviderType, node.description, parentId,
     );
@@ -97,14 +171,17 @@ abstract class ExternalUserGroupSyncService {
    * @param {ExternalUserInfo} externalUserInfo Search external app/server using this identifier
    * @returns {Promise<IUserHasId | null>} User when found or created, null when neither
    */
-  async getMemberUser(userInfo: ExternalUserInfo): Promise<IUserHasId | null> {
+  private async getMemberUser(userInfo: ExternalUserInfo): Promise<IUserHasId | null> {
+    const authProviderType = this.authProviderType;
+    if (authProviderType == null) throw new Error('auth provider type is not set');
+
     const autoGenerateUserOnGroupSync = configManager?.getConfig('crowi', `external-user-group:${this.groupProviderType}:autoGenerateUserOnGroupSync`);
 
     const getExternalAccount = async() => {
       if (autoGenerateUserOnGroupSync && externalAccountService != null) {
         return externalAccountService.getOrCreateUser({
           id: userInfo.id, username: userInfo.username, name: userInfo.name, email: userInfo.email,
-        }, this.authProviderType);
+        }, authProviderType);
       }
       return ExternalAccount.findOne({ providerType: this.groupProviderType, accountId: userInfo.id });
     };
@@ -117,6 +194,16 @@ abstract class ExternalUserGroupSyncService {
     return null;
   }
 
+  getGroupCountOfTree(tree: ExternalUserGroupTreeNode): number {
+    if (tree.childGroupNodes.length === 0) return 1;
+
+    let count = 1;
+    tree.childGroupNodes.forEach((childGroup) => {
+      count += this.getGroupCountOfTree(childGroup);
+    });
+    return count;
+  }
+
   /** Method to generate external group tree structure
    * 1. Fetch user group info from external app/server
    * 2. Convert each group tree structure to ExternalUserGroupTreeNode

+ 25 - 6
apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.ts

@@ -1,18 +1,22 @@
 import { GroupRepresentation, KeycloakAdminClient, UserRepresentation } from '@s3pweb/keycloak-admin-client-cjs';
 
 import { configManager } from '~/server/service/config-manager';
+import { S2sMessagingService } from '~/server/service/s2s-messaging/base';
+import loggerFactory from '~/utils/logger';
 import { batchProcessPromiseAll } from '~/utils/promise';
 
 import { ExternalGroupProviderType, ExternalUserGroupTreeNode, ExternalUserInfo } from '../../interfaces/external-user-group';
 
 import ExternalUserGroupSyncService from './external-user-group-sync';
 
+const logger = loggerFactory('growi:service:keycloak-user-group-sync-service');
+
 // When d = max depth of group trees
 // Max space complexity of generateExternalUserGroupTrees will be:
 // O(TREES_BATCH_SIZE * d)
 const TREES_BATCH_SIZE = 10;
 
-class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
+export class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
 
   kcAdminClient: KeycloakAdminClient;
 
@@ -20,19 +24,36 @@ class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
 
   groupDescriptionAttribute: string; // attribute to map to group description
 
-  constructor(authProviderType: string) {
+  isInitialized = false;
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  constructor(s2sMessagingService: S2sMessagingService, socketIoService) {
     const kcHost = configManager?.getConfig('crowi', 'external-user-group:keycloak:host');
     const kcGroupRealm = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupRealm');
     const kcGroupSyncClientRealm = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientRealm');
     const kcGroupDescriptionAttribute = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupDescriptionAttribute');
 
-    super(ExternalGroupProviderType.keycloak, authProviderType);
+    super(ExternalGroupProviderType.keycloak, s2sMessagingService, socketIoService);
     this.kcAdminClient = new KeycloakAdminClient({ baseUrl: kcHost, realmName: kcGroupSyncClientRealm });
     this.realm = kcGroupRealm;
     this.groupDescriptionAttribute = kcGroupDescriptionAttribute;
   }
 
-  async generateExternalUserGroupTrees(): Promise<ExternalUserGroupTreeNode[]> {
+  init(authProviderType: 'oidc' | 'saml'): void {
+    this.authProviderType = authProviderType;
+    this.isInitialized = true;
+  }
+
+  override syncExternalUserGroups(): Promise<void> {
+    if (!this.isInitialized) {
+      const msg = 'Service not initialized';
+      logger.error(msg);
+      throw new Error(msg);
+    }
+    return super.syncExternalUserGroups();
+  }
+
+  override async generateExternalUserGroupTrees(): Promise<ExternalUserGroupTreeNode[]> {
     await this.auth();
 
     // Type is 'GroupRepresentation', but 'find' does not return 'attributes' field. Hence, attribute for description is not present.
@@ -119,5 +140,3 @@ class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
   }
 
 }
-
-export default KeycloakUserGroupSyncService;

+ 37 - 39
apps/app/src/features/external-user-group/server/service/ldap-user-group-sync.ts

@@ -1,6 +1,7 @@
 import { configManager } from '~/server/service/config-manager';
-import LdapService, { SearchResultEntry } from '~/server/service/ldap';
+import { ldapService, SearchResultEntry } from '~/server/service/ldap';
 import PassportService from '~/server/service/passport';
+import { S2sMessagingService } from '~/server/service/s2s-messaging/base';
 import loggerFactory from '~/utils/logger';
 import { batchProcessPromiseAll } from '~/utils/promise';
 
@@ -10,7 +11,7 @@ import {
 
 import ExternalUserGroupSyncService from './external-user-group-sync';
 
-const logger = loggerFactory('growi:service:ldap-user-sync-service');
+const logger = loggerFactory('growi:service:ldap-user-group-sync-service');
 
 // When d = max depth of group trees
 // Max space complexity of generateExternalUserGroupTrees will be:
@@ -18,53 +19,59 @@ const logger = loggerFactory('growi:service:ldap-user-sync-service');
 const TREES_BATCH_SIZE = 10;
 const USERS_BATCH_SIZE = 30;
 
-class LdapUserGroupSyncService extends ExternalUserGroupSyncService {
+export class LdapUserGroupSyncService extends ExternalUserGroupSyncService {
 
   passportService: PassportService;
 
-  ldapService: LdapService;
+  isInitialized = false;
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-  constructor(passportService, userBindUsername?: string, userBindPassword?: string) {
-    super(ExternalGroupProviderType.ldap, 'ldap');
+  constructor(passportService: PassportService, s2sMessagingService: S2sMessagingService, socketIoService) {
+    super(ExternalGroupProviderType.ldap, s2sMessagingService, socketIoService);
+    this.authProviderType = 'ldap';
     this.passportService = passportService;
-    this.ldapService = new LdapService(userBindUsername, userBindPassword);
   }
 
-  async generateExternalUserGroupTrees(): Promise<ExternalUserGroupTreeNode[]> {
+  async init(userBindUsername?: string, userBindPassword?: string): Promise<void> {
+    await ldapService.initClient(userBindUsername, userBindPassword);
+    this.isInitialized = true;
+  }
+
+  override syncExternalUserGroups(): Promise<void> {
+    if (!this.isInitialized) {
+      const msg = 'Service not initialized';
+      logger.error(msg);
+      throw new Error(msg);
+    }
+    return super.syncExternalUserGroups();
+  }
+
+  override async generateExternalUserGroupTrees(): Promise<ExternalUserGroupTreeNode[]> {
     const groupChildGroupAttribute: string = configManager.getConfig('crowi', 'external-user-group:ldap:groupChildGroupAttribute');
     const groupMembershipAttribute: string = configManager.getConfig('crowi', 'external-user-group:ldap:groupMembershipAttribute');
     const groupNameAttribute: string = configManager.getConfig('crowi', 'external-user-group:ldap:groupNameAttribute');
     const groupDescriptionAttribute: string = configManager.getConfig('crowi', 'external-user-group:ldap:groupDescriptionAttribute');
-    const groupBase: string = this.ldapService.getGroupSearchBase();
+    const groupBase: string = ldapService.getGroupSearchBase();
 
-    let groupEntries: SearchResultEntry[];
-    try {
-      await this.ldapService.bind();
-      groupEntries = await this.ldapService.searchGroupDir();
-    }
-    catch (e) {
-      logger.error(e.message);
-      throw Error('external_user_group.ldap.group_search_failed');
-    }
+    const groupEntries = await ldapService.searchGroupDir();
 
     const getChildGroupDnsFromGroupEntry = (groupEntry: SearchResultEntry) => {
       // groupChildGroupAttribute and groupMembershipAttribute may be the same,
       // so filter values of groupChildGroupAttribute to ones that include groupBase
-      return this.ldapService.getArrayValFromSearchResultEntry(groupEntry, groupChildGroupAttribute).filter(attr => attr.includes(groupBase));
+      return ldapService.getArrayValFromSearchResultEntry(groupEntry, groupChildGroupAttribute).filter(attr => attr.includes(groupBase));
     };
     const getUserIdsFromGroupEntry = (groupEntry: SearchResultEntry) => {
       // groupChildGroupAttribute and groupMembershipAttribute may be the same,
       // so filter values of groupMembershipAttribute to ones that does not include groupBase
-      return this.ldapService.getArrayValFromSearchResultEntry(groupEntry, groupMembershipAttribute).filter(attr => !attr.includes(groupBase));
+      return ldapService.getArrayValFromSearchResultEntry(groupEntry, groupMembershipAttribute).filter(attr => !attr.includes(groupBase));
     };
 
     const convert = async(entry: SearchResultEntry, converted: string[]): Promise<ExternalUserGroupTreeNode | null> => {
-      const name = this.ldapService.getStringValFromSearchResultEntry(entry, groupNameAttribute);
+      const name = ldapService.getStringValFromSearchResultEntry(entry, groupNameAttribute);
       if (name == null) return null;
 
       if (converted.includes(entry.objectName)) {
-        throw Error('external_user_group.ldap.circular_reference');
+        throw Error('Circular reference inside LDAP group tree');
       }
       converted.push(entry.objectName);
 
@@ -73,7 +80,7 @@ class LdapUserGroupSyncService extends ExternalUserGroupSyncService {
       const userInfos = (await batchProcessPromiseAll(userIds, USERS_BATCH_SIZE, (id) => {
         return this.getUserInfo(id);
       })).filter((info): info is NonNullable<ExternalUserInfo> => info != null);
-      const description = this.ldapService.getStringValFromSearchResultEntry(entry, groupDescriptionAttribute);
+      const description = ldapService.getStringValFromSearchResultEntry(entry, groupDescriptionAttribute);
       const childGroupDNs = getChildGroupDnsFromGroupEntry(entry);
 
       const childGroupNodesWithNull: (ExternalUserGroupTreeNode | null)[] = [];
@@ -118,29 +125,22 @@ class LdapUserGroupSyncService extends ExternalUserGroupSyncService {
     // get full user info from LDAP server using externalUserInfo (DN or UID)
     const getUserEntries = async() => {
       if (groupMembershipAttributeType === LdapGroupMembershipAttributeType.dn) {
-        return this.ldapService.search(undefined, userId, 'base');
+        return ldapService.search(undefined, userId, 'base');
       }
       if (groupMembershipAttributeType === LdapGroupMembershipAttributeType.uid) {
-        return this.ldapService.search(`(uid=${userId})`, undefined);
+        return ldapService.search(`(uid=${userId})`, undefined);
       }
     };
 
-    let userEntries: SearchResultEntry[] | undefined;
-    try {
-      userEntries = await getUserEntries();
-    }
-    catch (e) {
-      logger.error(e.message);
-      throw Error('external_user_group.ldap.user_search_failed');
-    }
+    const userEntries = await getUserEntries();
 
     if (userEntries != null && userEntries.length > 0) {
       const userEntry = userEntries[0];
-      const uid = this.ldapService.getStringValFromSearchResultEntry(userEntry, 'uid');
+      const uid = ldapService.getStringValFromSearchResultEntry(userEntry, 'uid');
       if (uid != null) {
-        const usernameToBeRegistered = attrMapUsername === 'uid' ? uid : this.ldapService.getStringValFromSearchResultEntry(userEntry, attrMapUsername);
-        const nameToBeRegistered = this.ldapService.getStringValFromSearchResultEntry(userEntry, attrMapName);
-        const mailToBeRegistered = this.ldapService.getStringValFromSearchResultEntry(userEntry, attrMapMail);
+        const usernameToBeRegistered = attrMapUsername === 'uid' ? uid : ldapService.getStringValFromSearchResultEntry(userEntry, attrMapUsername);
+        const nameToBeRegistered = ldapService.getStringValFromSearchResultEntry(userEntry, attrMapName);
+        const mailToBeRegistered = ldapService.getStringValFromSearchResultEntry(userEntry, attrMapMail);
 
         return usernameToBeRegistered != null ? {
           id: uid,
@@ -154,5 +154,3 @@ class LdapUserGroupSyncService extends ExternalUserGroupSyncService {
   }
 
 }
-
-export default LdapUserGroupSyncService;

+ 23 - 0
apps/app/src/interfaces/websocket.ts

@@ -1,3 +1,23 @@
+import { ExternalGroupProviderType } from '~/features/external-user-group/interfaces/external-user-group';
+
+const generateGroupSyncEvents = () => {
+  const events = {};
+  Object.values(ExternalGroupProviderType).forEach((provider) => {
+    events[provider] = {
+      GroupSyncProgress: `${provider}:groupSyncProgress`,
+      GroupSyncCompleted: `${provider}:groupSyncCompleted`,
+      GroupSyncFailed: `${provider}:groupSyncFailed`,
+    };
+  });
+  return events as {
+    [key in ExternalGroupProviderType]: {
+      GroupSyncProgress: string,
+      GroupSyncCompleted: string,
+      GroupSyncFailed: string,
+    }
+  };
+};
+
 export const SocketEventName = {
   // Update descendantCount
   UpdateDescCount: 'UpdateDescCount',
@@ -17,6 +37,9 @@ export const SocketEventName = {
   FinishAddPage: 'finishAddPage',
   RebuildingFailed: 'rebuildingFailed',
 
+  // External user group sync
+  externalUserGroup: generateGroupSyncEvents(),
+
   // Page Operation
   PageCreated: 'page:create',
   PageUpdated: 'page:update',

+ 12 - 2
apps/app/src/server/crowi/index.js

@@ -10,6 +10,8 @@ import next from 'next';
 
 import pkg from '^/package.json';
 
+import { KeycloakUserGroupSyncService } from '~/features/external-user-group/server/service/keycloak-user-group-sync';
+import { LdapUserGroupSyncService } from '~/features/external-user-group/server/service/ldap-user-group-sync';
 import QuestionnaireService from '~/features/questionnaire/server/service/questionnaire';
 import QuestionnaireCronService from '~/features/questionnaire/server/service/questionnaire-cron';
 import CdnResourcesService from '~/services/cdn-resources-service';
@@ -153,13 +155,15 @@ Crowi.prototype.init = async function() {
     this.setupSyncPageStatusService(),
     this.setupQuestionnaireService(),
     this.setUpCustomize(), // depends on pluginService
-    this.setupExternalAccountService(),
   ]);
 
-  // globalNotification depends on slack and mailer
   await Promise.all([
+    // globalNotification depends on slack and mailer
     this.setUpGlobalNotification(),
     this.setUpUserNotification(),
+    // depends on passport service
+    this.setupExternalAccountService(),
+    this.setupExternalUserGroupSyncService(),
   ]);
 
   await this.autoInstall();
@@ -787,4 +791,10 @@ Crowi.prototype.setupExternalAccountService = function() {
   instanciateExternalAccountService(this.passportService);
 };
 
+// execute after setupPassport, s2sMessagingService, socketIoService
+Crowi.prototype.setupExternalUserGroupSyncService = function() {
+  this.ldapUserGroupSyncService = new LdapUserGroupSyncService(this.passportService, this.s2sMessagingService, this.socketIoService);
+  this.keycloakUserGroupSyncService = new KeycloakUserGroupSyncService(this.s2sMessagingService, this.socketIoService);
+};
+
 export default Crowi;

+ 25 - 17
apps/app/src/server/service/ldap.ts

@@ -23,23 +23,21 @@ export interface SearchResultEntry {
 */
 class LdapService {
 
-  username?: string; // Necessary when bind type is user bind
-
-  password?: string; // Necessary when bind type is user bind
-
-  client: ldap.Client;
+  client: ldap.Client | null;
 
   searchBase: string;
 
-  constructor(username?: string, password?: string) {
+  /**
+   * Initialize LDAP client and bind.
+   * @param {string} userBindUsername Necessary when bind type is user bind
+   * @param {string} userBindPassword Necessary when bind type is user bind
+   */
+  initClient(userBindUsername?: string, userBindPassword?: string): void {
     const serverUrl = configManager?.getConfig('crowi', 'security:passport-ldap:serverUrl');
 
-    this.username = username;
-    this.password = password;
-
     // parse serverUrl
     // see: https://regex101.com/r/0tuYBB/1
-    const match = serverUrl.match(/(ldaps?:\/\/[^/]+)\/(.*)?/);
+    const match = serverUrl?.match(/(ldaps?:\/\/[^/]+)\/(.*)?/);
     if (match == null || match.length < 1) {
       const urlInvalidMessage = 'serverUrl is invalid';
       logger.error(urlInvalidMessage);
@@ -51,13 +49,19 @@ class LdapService {
     this.client = ldap.createClient({
       url,
     });
+    this.bind(userBindUsername, userBindPassword);
   }
 
   /**
    * Bind to LDAP server.
    * This method is declared independently, so multiple operations can be requested to the LDAP server with a single bind.
+   * @param {string} userBindUsername Necessary when bind type is user bind
+   * @param {string} userBindPassword Necessary when bind type is user bind
    */
-  bind(): Promise<void> {
+  bind(userBindUsername?: string, userBindPassword?: string): Promise<void> {
+    const client = this.client;
+    if (client == null) throw new Error('LDAP client is not initialized');
+
     const isLdapEnabled = configManager?.getConfig('crowi', 'security:passport-ldap:isEnabled');
     if (!isLdapEnabled) {
       const notEnabledMessage = 'LDAP is not enabled';
@@ -72,12 +76,12 @@ class LdapService {
 
     // user bind
     const fixedBindDN = (isUserBind)
-      ? bindDN.replace(/{{username}}/, this.username)
+      ? bindDN.replace(/{{username}}/, userBindUsername)
       : bindDN;
-    const fixedBindCredentials = (isUserBind) ? this.password : bindCredentials;
+    const fixedBindCredentials = (isUserBind) ? userBindPassword : bindCredentials;
 
     return new Promise<void>((resolve, reject) => {
-      this.client.bind(fixedBindDN, fixedBindCredentials, (err) => {
+      client.bind(fixedBindDN, fixedBindCredentials, (err) => {
         if (err != null) {
           reject(err);
         }
@@ -94,15 +98,18 @@ class LdapService {
    * @returns {SearchEntry[]} Search result. Default scope is set to 'sub'.
    */
   search(filter?: string, base?: string, scope: 'sub' | 'base' | 'one' = 'sub'): Promise<SearchResultEntry[]> {
+    const client = this.client;
+    if (client == null) throw new Error('LDAP client is not initialized');
+
     const searchResults: SearchResultEntry[] = [];
 
     return new Promise((resolve, reject) => {
       // reject on client connection error (occures when not binded or host is not found)
-      this.client.on('error', (err) => {
+      client.on('error', (err) => {
         reject(err);
       });
 
-      this.client.search(base || this.searchBase, {
+      client.search(base || this.searchBase, {
         scope, filter, paged: true, sizeLimit: 200,
       }, (err, res) => {
         if (err != null) {
@@ -162,4 +169,5 @@ class LdapService {
 
 }
 
-export default LdapService;
+// export the singleton instance
+export const ldapService = new LdapService();

+ 4 - 3
apps/app/src/server/service/search-delegator/elasticsearch.ts

@@ -9,6 +9,7 @@ import { SearchDelegatorName } from '~/interfaces/named-query';
 import {
   ISearchResult, ISearchResultData, SORT_AXIS, SORT_ORDER,
 } from '~/interfaces/search';
+import { SocketEventName } from '~/interfaces/websocket';
 import loggerFactory from '~/utils/logger';
 
 import {
@@ -301,7 +302,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
       logger.error('error.meta.body', error?.meta?.body);
 
       const socket = this.socketIoService.getAdminSocket();
-      socket.emit('rebuildingFailed', { error: error.message });
+      socket.emit(SocketEventName.RebuildingFailed, { error: error.message });
 
       throw error;
     }
@@ -582,7 +583,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
           logger.info(`Adding pages progressing: (count=${count}, errors=${bulkResponse.errors}, took=${bulkResponse.took}ms)`);
 
           if (shouldEmitProgress) {
-            socket?.emit('addPageProgress', { totalCount, count, skipped });
+            socket?.emit(SocketEventName.AddPageProgress, { totalCount, count, skipped });
           }
         }
         catch (err) {
@@ -606,7 +607,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
         logger.info(`Adding pages has completed: (totalCount=${totalCount}, skipped=${skipped})`);
 
         if (shouldEmitProgress) {
-          socket?.emit('finishAddPage', { totalCount, count, skipped });
+          socket?.emit(SocketEventName.FinishAddPage, { totalCount, count, skipped });
         }
         callback();
       },

+ 4 - 4
apps/app/test/integration/service/external-user-group-sync.test.ts

@@ -13,12 +13,12 @@ import { instanciate } from '../../../src/server/service/external-account';
 import PassportService from '../../../src/server/service/passport';
 import { getInstance } from '../setup-crowi';
 
-
 // dummy class to implement generateExternalUserGroupTrees which returns test data
 class TestExternalUserGroupSyncService extends ExternalUserGroupSyncService {
 
-  constructor() {
-    super(ExternalGroupProviderType.ldap, 'ldap');
+  constructor(s2sMessagingService, socketIoService) {
+    super('ldap', s2sMessagingService, socketIoService);
+    this.authProviderType = ExternalGroupProviderType.ldap;
   }
 
   async generateExternalUserGroupTrees(): Promise<ExternalUserGroupTreeNode[]> {
@@ -77,7 +77,7 @@ class TestExternalUserGroupSyncService extends ExternalUserGroupSyncService {
 
 }
 
-const testService = new TestExternalUserGroupSyncService();
+const testService = new TestExternalUserGroupSyncService(null, null);
 
 const checkGroup = (group: IExternalUserGroupHasId, expected: Omit<IExternalUserGroup, 'createdAt'>) => {
   const actual = {

+ 11 - 12
apps/app/test/integration/service/ldap-user-group-sync.test.ts

@@ -1,15 +1,14 @@
 import ldap, { Client } from 'ldapjs';
 
-import LdapUserGroupSyncService from '../../../src/features/external-user-group/server/service/ldap-user-group-sync';
+import { LdapUserGroupSyncService } from '../../../src/features/external-user-group/server/service/ldap-user-group-sync';
 import { configManager } from '../../../src/server/service/config-manager';
-import LdapService from '../../../src/server/service/ldap';
+import { ldapService } from '../../../src/server/service/ldap';
 import PassportService from '../../../src/server/service/passport';
 import { getInstance } from '../setup-crowi';
 
-
 describe('LdapUserGroupSyncService.generateExternalUserGroupTrees', () => {
   let crowi;
-  let ldapGroupSyncService: LdapUserGroupSyncService;
+  let ldapUserGroupSyncService: LdapUserGroupSyncService;
 
   const configParams = {
     'security:passport-ldap:attrMapName': 'name',
@@ -23,8 +22,8 @@ describe('LdapUserGroupSyncService.generateExternalUserGroupTrees', () => {
   };
 
   jest.mock('../../../src/server/service/ldap');
-  const mockBind = jest.spyOn(LdapService.prototype, 'bind');
-  const mockLdapSearch = jest.spyOn(LdapService.prototype, 'search');
+  const mockBind = jest.spyOn(ldapService, 'bind');
+  const mockLdapSearch = jest.spyOn(ldapService, 'search');
   const mockLdapCreateClient = jest.spyOn(ldap, 'createClient');
 
   beforeAll(async() => {
@@ -37,7 +36,7 @@ describe('LdapUserGroupSyncService.generateExternalUserGroupTrees', () => {
     mockLdapCreateClient.mockImplementation(() => { return {} as Client });
 
     const passportService = new PassportService(crowi);
-    ldapGroupSyncService = new LdapUserGroupSyncService(passportService);
+    ldapUserGroupSyncService = new LdapUserGroupSyncService(passportService, null, null);
   });
 
   describe('When there is no circular reference in group tree', () => {
@@ -150,12 +149,12 @@ describe('LdapUserGroupSyncService.generateExternalUserGroupTrees', () => {
         return Promise.reject(new Error('not found'));
       });
 
-      const rootNodes = await ldapGroupSyncService.generateExternalUserGroupTrees();
+      const rootNodes = await ldapUserGroupSyncService?.generateExternalUserGroupTrees();
 
-      expect(rootNodes.length).toBe(2);
+      expect(rootNodes?.length).toBe(2);
 
       // check grandParentGroup
-      const grandParentNode = rootNodes.find(node => node.id === 'cn=grandParentGroup,ou=groups,dc=example,dc=org');
+      const grandParentNode = rootNodes?.find(node => node.id === 'cn=grandParentGroup,ou=groups,dc=example,dc=org');
       const expectedChildNode = {
         id: 'cn=childGroup,ou=groups,dc=example,dc=org',
         userInfos: [{
@@ -195,7 +194,7 @@ describe('LdapUserGroupSyncService.generateExternalUserGroupTrees', () => {
       expect(grandParentNode).toStrictEqual(expectedGrandParentNode);
 
       // check rootGroup
-      const rootNode = rootNodes.find(node => node.id === 'cn=rootGroup,ou=groups,dc=example,dc=org');
+      const rootNode = rootNodes?.find(node => node.id === 'cn=rootGroup,ou=groups,dc=example,dc=org');
       const expectedRootNode = {
         id: 'cn=rootGroup,ou=groups,dc=example,dc=org',
         userInfos: [{
@@ -258,7 +257,7 @@ describe('LdapUserGroupSyncService.generateExternalUserGroupTrees', () => {
         return Promise.reject(new Error('not found'));
       });
 
-      await expect(ldapGroupSyncService.generateExternalUserGroupTrees()).rejects.toThrow('external_user_group.ldap.circular_reference');
+      await expect(ldapUserGroupSyncService?.generateExternalUserGroupTrees()).rejects.toThrow('Circular reference inside LDAP group tree');
     });
   });
 });