Просмотр исходного кода

Merge remote-tracking branch 'origin/master' into support/use-jotai

Yuki Takei 6 месяцев назад
Родитель
Сommit
03f51a982f
46 измененных файлов с 3797 добавлено и 1803 удалено
  1. 3 0
      apps/app/.eslintrc.js
  2. 18 23
      apps/app/src/features/growi-plugin/client/Admin/components/PluginsExtensionPageContents/PluginCard.tsx
  3. 16 14
      apps/app/src/features/growi-plugin/client/Admin/components/PluginsExtensionPageContents/PluginDeleteModal.tsx
  4. 49 33
      apps/app/src/features/growi-plugin/client/Admin/components/PluginsExtensionPageContents/PluginInstallerForm.tsx
  5. 34 29
      apps/app/src/features/growi-plugin/client/Admin/components/PluginsExtensionPageContents/PluginsExtensionPageContents.tsx
  6. 11 8
      apps/app/src/features/growi-plugin/client/components/GrowiPluginsActivator.tsx
  7. 1 5
      apps/app/src/features/growi-plugin/client/utils/growi-facade-utils.ts
  8. 30 25
      apps/app/src/features/growi-plugin/interfaces/growi-plugin.ts
  9. 19 18
      apps/app/src/features/growi-plugin/server/models/growi-plugin.integ.ts
  10. 45 20
      apps/app/src/features/growi-plugin/server/models/growi-plugin.ts
  11. 11 9
      apps/app/src/features/growi-plugin/server/models/vo/github-url.spec.ts
  12. 13 10
      apps/app/src/features/growi-plugin/server/models/vo/github-url.ts
  13. 62 36
      apps/app/src/features/growi-plugin/server/routes/apiv3/admin/index.ts
  14. 11 3
      apps/app/src/features/growi-plugin/server/services/growi-plugin/generate-template-plugin-meta.ts
  15. 4 1
      apps/app/src/features/growi-plugin/server/services/growi-plugin/generate-theme-plugin-meta.ts
  16. 42 25
      apps/app/src/features/growi-plugin/server/services/growi-plugin/growi-plugin.integ.ts
  17. 190 123
      apps/app/src/features/growi-plugin/server/services/growi-plugin/growi-plugin.ts
  18. 1 0
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron.integ.ts
  19. 10 7
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron.ts
  20. 6 4
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/errors.ts
  21. 38 24
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/index.ts
  22. 1 1
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/request-pdf-converter.ts
  23. 1 1
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/compress-and-upload.ts
  24. 10 2
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/create-page-snapshots-async.ts
  25. 10 2
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/export-pages-to-fs-async.ts
  26. 14 12
      apps/app/src/features/rate-limiter/config/index.ts
  27. 10 8
      apps/app/src/features/rate-limiter/middleware/consume-points.integ.ts
  28. 15 5
      apps/app/src/features/rate-limiter/middleware/consume-points.ts
  29. 26 22
      apps/app/src/features/rate-limiter/middleware/factory.ts
  30. 4 3
      apps/app/src/features/rate-limiter/middleware/rate-limiter-factory.ts
  31. 30 17
      apps/app/src/features/rate-limiter/utils/config-generator.ts
  32. 1 1
      apps/app/src/server/service/customize.ts
  33. 1 1
      apps/app/src/server/service/page-grant.ts
  34. 19 0
      apps/app/src/server/service/page/page-service.ts
  35. 142 70
      apps/app/test/integration/service/external-user-group-sync.test.ts
  36. 79 44
      apps/app/test/integration/service/ldap-user-group-sync.test.ts
  37. 437 231
      apps/app/test/integration/service/page-grant.test.ts
  38. 488 188
      apps/app/test/integration/service/page.test.js
  39. 45 23
      apps/app/test/integration/service/search/search-service.test.js
  40. 227 87
      apps/app/test/integration/service/user-groups.test.ts
  41. 500 152
      apps/app/test/integration/service/v5.migration.test.js
  42. 454 164
      apps/app/test/integration/service/v5.non-public-page.test.ts
  43. 172 132
      apps/app/test/integration/service/v5.page.test.ts
  44. 492 212
      apps/app/test/integration/service/v5.public-page.test.ts
  45. 2 1
      apps/app/test/integration/tsconfig.json
  46. 3 7
      biome.json

+ 3 - 0
apps/app/.eslintrc.js

@@ -25,6 +25,7 @@ module.exports = {
     'test/integration/middlewares/**',
     'test/integration/migrations/**',
     'test/integration/models/**',
+    'test/integration/service/**',
     'test/integration/setup.js',
     'bin/**',
     'config/**',
@@ -38,7 +39,9 @@ module.exports = {
     'src/features/plantuml/**',
     'src/features/external-user-group/**',
     'src/features/page-bulk-export/**',
+    'src/features/growi-plugin/**',
     'src/features/opentelemetry/**',
+    'src/features/rate-limiter/**',
     'src/stores-universal/**',
     'src/interfaces/**',
     'src/utils/**',

+ 18 - 23
apps/app/src/features/growi-plugin/client/Admin/components/PluginsExtensionPageContents/PluginCard.tsx

@@ -1,34 +1,31 @@
-import React, { useState, type JSX } from 'react';
+import Link from 'next/link';
 
 import { useTranslation } from 'next-i18next';
-import Link from 'next/link';
+import React, { type JSX, useState } from 'react';
 
 import { apiv3Put } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 
 import styles from './PluginCard.module.scss';
 
 type Props = {
-  id: string,
-  name: string,
-  url: string,
-  isEnabled: boolean,
-  desc?: string,
-  onDelete: () => void,
-}
+  id: string;
+  name: string;
+  url: string;
+  isEnabled: boolean;
+  desc?: string;
+  onDelete: () => void;
+};
 
 export const PluginCard = (props: Props): JSX.Element => {
-
-  const {
-    id, name, url, isEnabled, desc,
-  } = props;
+  const { id, name, url, isEnabled, desc } = props;
 
   const { t } = useTranslation('admin');
 
   const PluginCardButton = (): JSX.Element => {
     const [_isEnabled, setIsEnabled] = useState<boolean>(isEnabled);
 
-    const onChangeHandler = async() => {
+    const onChangeHandler = async () => {
       try {
         if (_isEnabled) {
           const reqUrl = `/plugins/${id}/deactivate`;
@@ -36,16 +33,14 @@ export const PluginCard = (props: Props): JSX.Element => {
           setIsEnabled(!_isEnabled);
           const pluginName = res.data.pluginName;
           toastSuccess(t('toaster.deactivate_plugin_success', { pluginName }));
-        }
-        else {
+        } else {
           const reqUrl = `/plugins/${id}/activate`;
           const res = await apiv3Put(reqUrl);
           setIsEnabled(!_isEnabled);
           const pluginName = res.data.pluginName;
           toastSuccess(t('toaster.activate_plugin_success', { pluginName }));
         }
-      }
-      catch (err) {
+      } catch (err) {
         toastError(err);
       }
     };
@@ -69,7 +64,6 @@ export const PluginCard = (props: Props): JSX.Element => {
   };
 
   const PluginDeleteButton = (): JSX.Element => {
-
     return (
       <div>
         <button
@@ -89,7 +83,9 @@ export const PluginCard = (props: Props): JSX.Element => {
         <div className="row mb-3">
           <div className="col-9">
             <h2 className="card-title h3 border-bottom pb-2 mb-3">
-              <Link href={`${url}`} legacyBehavior>{name}</Link>
+              <Link href={`${url}`} legacyBehavior>
+                {name}
+              </Link>
             </h2>
             <p className="card-text text-muted">{desc}</p>
           </div>
@@ -104,8 +100,7 @@ export const PluginCard = (props: Props): JSX.Element => {
         </div>
       </div>
       <div className="card-footer px-5 border-top-0">
-        <p className="d-flex justify-content-between align-self-center mb-0">
-        </p>
+        <p className="d-flex justify-content-between align-self-center mb-0"></p>
       </div>
     </div>
   );

+ 16 - 14
apps/app/src/features/growi-plugin/client/Admin/components/PluginsExtensionPageContents/PluginDeleteModal.tsx

@@ -1,19 +1,15 @@
-import React, { useCallback } from 'react';
-
-import { useTranslation } from 'next-i18next';
+import type React from 'react';
+import { useCallback } from 'react';
 import Link from 'next/link';
-import {
-  Button, Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
+import { useTranslation } from 'next-i18next';
+import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 import { apiv3Delete } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 
-import { usePluginDeleteModalStatus, usePluginDeleteModalActions } from '../../states/modal/plugin-delete';
 import { useSWRxAdminPlugins } from '../../stores/admin-plugins';
 
 export const PluginDeleteModal: React.FC = () => {
-
   const { t } = useTranslation('admin');
   const { mutate } = useSWRxAdminPlugins();
   const pluginDeleteModalData = usePluginDeleteModalStatus();
@@ -27,7 +23,7 @@ export const PluginDeleteModal: React.FC = () => {
     closePluginDeleteModal();
   }, [closePluginDeleteModal]);
 
-  const onClickDeleteButtonHandler = useCallback(async() => {
+  const onClickDeleteButtonHandler = useCallback(async () => {
     const reqUrl = `/plugins/${id}/remove`;
 
     try {
@@ -36,15 +32,19 @@ export const PluginDeleteModal: React.FC = () => {
       closePluginDeleteModal();
       toastSuccess(t('toaster.remove_plugin_success', { pluginName }));
       mutate();
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
     }
   }, [id, closePluginDeleteModal, t, mutate]);
 
   return (
     <Modal isOpen={isOpen} toggle={toggleHandler}>
-      <ModalHeader tag="h4" toggle={toggleHandler} className="text-danger" name={name}>
+      <ModalHeader
+        tag="h4"
+        toggle={toggleHandler}
+        className="text-danger"
+        name={name}
+      >
         <span>
           <span className="material-symbols-outlined">delete_forever</span>
           {t('plugins.confirm')}
@@ -52,7 +52,9 @@ export const PluginDeleteModal: React.FC = () => {
       </ModalHeader>
       <ModalBody>
         <div className="card well mt-2 p-2" key={id}>
-          <Link href={`${url}`} legacyBehavior>{name}</Link>
+          <Link href={`${url}`} legacyBehavior>
+            {name}
+          </Link>
         </div>
       </ModalBody>
       <ModalFooter>

+ 49 - 33
apps/app/src/features/growi-plugin/client/Admin/components/PluginsExtensionPageContents/PluginInstallerForm.tsx

@@ -1,9 +1,8 @@
-import React, { useCallback, type JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
+import React, { type JSX, useCallback } from 'react';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 
 import type { IGrowiPluginOrigin } from '../../../../interfaces';
 import { useSWRxAdminPlugins } from '../../stores/admin-plugins';
@@ -12,40 +11,46 @@ export const PluginInstallerForm = (): JSX.Element => {
   const { mutate } = useSWRxAdminPlugins();
   const { t } = useTranslation('admin');
 
-  const submitHandler = useCallback(async(e) => {
-    e.preventDefault();
+  const submitHandler = useCallback(
+    async (e) => {
+      e.preventDefault();
 
-    const formData = e.target.elements;
+      const formData = e.target.elements;
 
-    const {
-      'pluginInstallerForm[url]': { value: url },
-      'pluginInstallerForm[ghBranch]': { value: ghBranch },
-      // 'pluginInstallerForm[ghTag]': { value: ghTag },
-    } = formData;
+      const {
+        'pluginInstallerForm[url]': { value: url },
+        'pluginInstallerForm[ghBranch]': { value: ghBranch },
+        // 'pluginInstallerForm[ghTag]': { value: ghTag },
+      } = formData;
 
-    const pluginInstallerForm: IGrowiPluginOrigin = {
-      url,
-      ghBranch: ghBranch || 'main',
-      // ghTag,
-    };
+      const pluginInstallerForm: IGrowiPluginOrigin = {
+        url,
+        ghBranch: ghBranch || 'main',
+        // ghTag,
+      };
 
-    try {
-      const res = await apiv3Post('/plugins', { pluginInstallerForm });
-      const pluginName = res.data.pluginName;
-      toastSuccess(t('toaster.install_plugin_success', { pluginName }));
-    }
-    catch (e) {
-      toastError(e);
-    }
-    finally {
-      mutate();
-    }
-  }, [mutate, t]);
+      try {
+        const res = await apiv3Post('/plugins', { pluginInstallerForm });
+        const pluginName = res.data.pluginName;
+        toastSuccess(t('toaster.install_plugin_success', { pluginName }));
+      } catch (e) {
+        toastError(e);
+      } finally {
+        mutate();
+      }
+    },
+    [mutate, t],
+  );
 
   return (
-    <form role="form" onSubmit={submitHandler}>
+    <form onSubmit={submitHandler}>
       <div className="row">
-        <label className="text-start text-md-end col-md-3 col-form-label">{t('plugins.form.label_url')}</label>
+        <label
+          className="text-start text-md-end col-md-3 col-form-label"
+          htmlFor="repoUrl"
+        >
+          {t('plugins.form.label_url')}
+        </label>
         <div className="col-md-6">
           <input
             className="form-control"
@@ -53,26 +58,37 @@ export const PluginInstallerForm = (): JSX.Element => {
             name="pluginInstallerForm[url]"
             placeholder="https://github.com/growilabs/growi-plugins-example"
             required
+            id="repoUrl"
           />
           <p className="form-text text-muted">{t('plugins.form.desc_url')}</p>
         </div>
       </div>
       <div className="row">
-        <label className="text-start text-md-end col-md-3 col-form-label">{t('plugins.form.label_branch')}</label>
+        <label
+          className="text-start text-md-end col-md-3 col-form-label"
+          htmlFor="branchName"
+        >
+          {t('plugins.form.label_branch')}
+        </label>
         <div className="col-md-6">
           <input
             className="form-control col-md-3"
             type="text"
             name="pluginInstallerForm[ghBranch]"
             placeholder="main"
+            id="branchName"
           />
-          <p className="form-text text-muted">{t('plugins.form.desc_branch')}</p>
+          <p className="form-text text-muted">
+            {t('plugins.form.desc_branch')}
+          </p>
         </div>
       </div>
 
       <div className="row my-3">
         <div className="mx-auto">
-          <button type="submit" className="btn btn-primary">{t('plugins.install')}</button>
+          <button type="submit" className="btn btn-primary">
+            {t('plugins.install')}
+          </button>
         </div>
       </div>
     </form>

+ 34 - 29
apps/app/src/features/growi-plugin/client/Admin/components/PluginsExtensionPageContents/PluginsExtensionPageContents.tsx

@@ -1,12 +1,10 @@
-import React, { type JSX } from 'react';
-
-import { useTranslation } from 'next-i18next';
+import type { JSX } from 'react';
 import dynamic from 'next/dynamic';
+import { useTranslation } from 'next-i18next';
 import { Spinner } from 'reactstrap';
 
 import { usePluginDeleteModalActions } from '../../states/modal/plugin-delete';
 import { useSWRxAdminPlugins } from '../../stores/admin-plugins';
-
 import { PluginCard } from './PluginCard';
 import { PluginInstallerForm } from './PluginInstallerForm';
 
@@ -20,8 +18,10 @@ const Loading = (): JSX.Element => {
 
 export const PluginsExtensionPageContents = (): JSX.Element => {
   const { t } = useTranslation('admin');
-  const PluginDeleteModal = dynamic(() => import('./PluginDeleteModal')
-    .then(mod => mod.PluginDeleteModal), { ssr: false });
+  const PluginDeleteModal = dynamic(
+    () => import('./PluginDeleteModal').then((mod) => mod.PluginDeleteModal),
+    { ssr: false },
+  );
   const { data, mutate } = useSWRxAdminPlugins();
   const { open: openPluginDeleteModal } = usePluginDeleteModalActions();
 
@@ -29,7 +29,9 @@ export const PluginsExtensionPageContents = (): JSX.Element => {
     <div>
       <div className="row mb-5">
         <div className="col-lg-12">
-          <h2 className="admin-setting-header">{t('plugins.plugin_installer')}</h2>
+          <h2 className="admin-setting-header">
+            {t('plugins.plugin_installer')}
+          </h2>
           <PluginInstallerForm />
         </div>
       </div>
@@ -38,34 +40,37 @@ export const PluginsExtensionPageContents = (): JSX.Element => {
         <div className="col-lg-12">
           <h2 className="admin-setting-header">
             {t('plugins.plugin_card')}
-            <button type="button" className="btn btn-sm ms-auto grw-btn-reload" onClick={() => mutate()}>
+            <button
+              type="button"
+              className="btn btn-sm ms-auto grw-btn-reload"
+              onClick={() => mutate()}
+            >
               <span className="material-symbols-outlined">refresh</span>
             </button>
           </h2>
-          {data?.plugins == null
-            ? <Loading />
-            : (
-              <div className="d-grid gap-5">
-                { data.plugins.length === 0 && (
-                  <div>{t('plugins.plugin_is_not_installed')}</div>
-                )}
-                {data.plugins.map(plugin => (
-                  <PluginCard
-                    key={plugin._id}
-                    id={plugin._id}
-                    name={plugin.meta.name}
-                    url={plugin.origin.url}
-                    isEnabled={plugin.isEnabled}
-                    desc={plugin.meta.desc}
-                    onDelete={() => openPluginDeleteModal(plugin)}
-                  />
-                ))}
-              </div>
-            )}
+          {data?.plugins == null ? (
+            <Loading />
+          ) : (
+            <div className="d-grid gap-5">
+              {data.plugins.length === 0 && (
+                <div>{t('plugins.plugin_is_not_installed')}</div>
+              )}
+              {data.plugins.map((plugin) => (
+                <PluginCard
+                  key={plugin._id}
+                  id={plugin._id}
+                  name={plugin.meta.name}
+                  url={plugin.origin.url}
+                  isEnabled={plugin.isEnabled}
+                  desc={plugin.meta.desc}
+                  onDelete={() => openPluginDeleteModal(plugin)}
+                />
+              ))}
+            </div>
+          )}
         </div>
       </div>
       <PluginDeleteModal />
-
     </div>
   );
 };

+ 11 - 8
apps/app/src/features/growi-plugin/client/components/GrowiPluginsActivator.tsx

@@ -1,14 +1,17 @@
-import React, { useEffect, type JSX } from 'react';
+import React, { type JSX, useEffect } from 'react';
 
-import { initializeGrowiFacade, registerGrowiFacade } from '../utils/growi-facade-utils';
+import {
+  initializeGrowiFacade,
+  registerGrowiFacade,
+} from '../utils/growi-facade-utils';
 
 declare global {
   // eslint-disable-next-line vars-on-top, no-var
   var pluginActivators: {
     [key: string]: {
-      activate: () => void,
-      deactivate: () => void,
-    },
+      activate: () => void;
+      deactivate: () => void;
+    };
   };
 }
 
@@ -16,7 +19,9 @@ async function activateAll(): Promise<void> {
   initializeGrowiFacade();
 
   // register renderer options to facade
-  const { generateViewOptions, generatePreviewOptions } = await import('~/client/services/renderer/renderer');
+  const { generateViewOptions, generatePreviewOptions } = await import(
+    '~/client/services/renderer/renderer'
+  );
   registerGrowiFacade({
     markdownRenderer: {
       optionsGenerators: {
@@ -36,9 +41,7 @@ async function activateAll(): Promise<void> {
   });
 }
 
-
 export const GrowiPluginsActivator = (): JSX.Element => {
-
   useEffect(() => {
     activateAll();
   }, []);

+ 1 - 5
apps/app/src/features/growi-plugin/client/utils/growi-facade-utils.ts

@@ -7,7 +7,6 @@ declare global {
   var growiFacade: GrowiFacade;
 }
 
-
 export const initializeGrowiFacade = (): void => {
   if (isServer()) {
     return;
@@ -33,8 +32,5 @@ export const registerGrowiFacade = (addedFacade: GrowiFacade): void => {
     throw new Error('This method is available only in client.');
   }
 
-  window.growiFacade = deepmerge(
-    getGrowiFacade(),
-    addedFacade,
-  );
+  window.growiFacade = deepmerge(getGrowiFacade(), addedFacade);
 };

+ 30 - 25
apps/app/src/features/growi-plugin/interfaces/growi-plugin.ts

@@ -1,39 +1,44 @@
-import type { GrowiPluginType, GrowiThemeMetadata, HasObjectId } from '@growi/core';
+import type {
+  GrowiPluginType,
+  GrowiThemeMetadata,
+  HasObjectId,
+} from '@growi/core';
 import type { TemplateSummary } from '@growi/pluginkit/dist/v4';
 
 export type IGrowiPluginOrigin = {
-  url: string,
-  ghBranch?: string,
-  ghTag?: string,
-}
+  url: string;
+  ghBranch?: string;
+  ghTag?: string;
+};
 
 export type IGrowiPlugin<M extends IGrowiPluginMeta = IGrowiPluginMeta> = {
-  isEnabled: boolean,
-  installedPath: string,
-  organizationName: string,
-  origin: IGrowiPluginOrigin,
-  meta: M,
-}
+  isEnabled: boolean;
+  installedPath: string;
+  organizationName: string;
+  origin: IGrowiPluginOrigin;
+  meta: M;
+};
 
 export type IGrowiPluginMeta = {
-  name: string,
-  types: GrowiPluginType[],
-  desc?: string,
-  author?: string,
-}
+  name: string;
+  types: GrowiPluginType[];
+  desc?: string;
+  author?: string;
+};
 
 export type IGrowiThemePluginMeta = IGrowiPluginMeta & {
-  themes: GrowiThemeMetadata[],
-}
+  themes: GrowiThemeMetadata[];
+};
 
 export type IGrowiTemplatePluginMeta = IGrowiPluginMeta & {
-  templateSummaries: TemplateSummary[],
-}
+  templateSummaries: TemplateSummary[];
+};
 
-export type IGrowiPluginMetaByType<T extends GrowiPluginType = any> = T extends 'theme'
-  ? IGrowiThemePluginMeta
-  : T extends 'template'
-    ? IGrowiTemplatePluginMeta
-    : IGrowiPluginMeta;
+export type IGrowiPluginMetaByType<T extends GrowiPluginType = any> =
+  T extends 'theme'
+    ? IGrowiThemePluginMeta
+    : T extends 'template'
+      ? IGrowiTemplatePluginMeta
+      : IGrowiPluginMeta;
 
 export type IGrowiPluginHasId = IGrowiPlugin & HasObjectId;

+ 19 - 18
apps/app/src/features/growi-plugin/server/models/growi-plugin.integ.ts

@@ -3,8 +3,7 @@ import { GrowiPluginType } from '@growi/core';
 import { GrowiPlugin } from './growi-plugin';
 
 describe('GrowiPlugin find methods', () => {
-
-  beforeAll(async() => {
+  beforeAll(async () => {
     await GrowiPlugin.insertMany([
       {
         isEnabled: false,
@@ -57,16 +56,16 @@ describe('GrowiPlugin find methods', () => {
     ]);
   });
 
-  afterAll(async() => {
+  afterAll(async () => {
     await GrowiPlugin.deleteMany({});
   });
 
   describe.concurrent('.findEnabledPlugins', () => {
-    it('shoud returns documents which isEnabled is true', async() => {
+    it('shoud returns documents which isEnabled is true', async () => {
       // when
       const results = await GrowiPlugin.findEnabledPlugins();
 
-      const pluginNames = results.map(p => p.meta.name);
+      const pluginNames = results.map((p) => p.meta.name);
 
       // then
       expect(results.length === 2).toBeTruthy();
@@ -76,24 +75,23 @@ describe('GrowiPlugin find methods', () => {
   });
 
   describe.concurrent('.findEnabledPluginsByType', () => {
-    it("shoud returns documents which type is 'template'", async() => {
+    it("shoud returns documents which type is 'template'", async () => {
       // when
-      const results = await GrowiPlugin.findEnabledPluginsByType(GrowiPluginType.Template);
+      const results = await GrowiPlugin.findEnabledPluginsByType(
+        GrowiPluginType.Template,
+      );
 
-      const pluginNames = results.map(p => p.meta.name);
+      const pluginNames = results.map((p) => p.meta.name);
 
       // then
       expect(results.length === 1).toBeTruthy();
       expect(pluginNames.includes('@growi/growi-plugin-example2')).toBeTruthy();
     });
   });
-
 });
 
-
 describe('GrowiPlugin activate/deactivate', () => {
-
-  beforeAll(async() => {
+  beforeAll(async () => {
     await GrowiPlugin.insertMany([
       {
         isEnabled: false,
@@ -110,12 +108,12 @@ describe('GrowiPlugin activate/deactivate', () => {
     ]);
   });
 
-  afterAll(async() => {
+  afterAll(async () => {
     await GrowiPlugin.deleteMany({});
   });
 
   describe('.activatePlugin', () => {
-    it('shoud update the property "isEnabled" to true', async() => {
+    it('shoud update the property "isEnabled" to true', async () => {
       // setup
       const plugin = await GrowiPlugin.findOne({});
       assert(plugin != null);
@@ -124,7 +122,9 @@ describe('GrowiPlugin activate/deactivate', () => {
 
       // when
       const result = await GrowiPlugin.activatePlugin(plugin._id);
-      const pluginAfterActivated = await GrowiPlugin.findOne({ _id: plugin._id });
+      const pluginAfterActivated = await GrowiPlugin.findOne({
+        _id: plugin._id,
+      });
 
       // then
       expect(result).toEqual('@growi/growi-plugin-example1'); // equals to meta.name
@@ -135,7 +135,7 @@ describe('GrowiPlugin activate/deactivate', () => {
   });
 
   describe('.deactivatePlugin', () => {
-    it('shoud update the property "isEnabled" to true', async() => {
+    it('shoud update the property "isEnabled" to true', async () => {
       // setup
       const plugin = await GrowiPlugin.findOne({});
       assert(plugin != null);
@@ -144,7 +144,9 @@ describe('GrowiPlugin activate/deactivate', () => {
 
       // when
       const result = await GrowiPlugin.deactivatePlugin(plugin._id);
-      const pluginAfterActivated = await GrowiPlugin.findOne({ _id: plugin._id });
+      const pluginAfterActivated = await GrowiPlugin.findOne({
+        _id: plugin._id,
+      });
 
       // then
       expect(result).toEqual('@growi/growi-plugin-example1'); // equals to meta.name
@@ -153,5 +155,4 @@ describe('GrowiPlugin activate/deactivate', () => {
       expect(pluginAfterActivated.isEnabled).toBeFalsy(); // isEnabled: false
     });
   });
-
 });

+ 45 - 20
apps/app/src/features/growi-plugin/server/models/growi-plugin.ts

@@ -1,25 +1,35 @@
 import { GrowiPluginType } from '@growi/core';
-import {
-  Schema, type Model, type Document, type Types,
-} from 'mongoose';
+import { type Document, type Model, Schema, type Types } from 'mongoose';
 
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
 
 import type {
-  IGrowiPlugin, IGrowiPluginMeta, IGrowiPluginMetaByType, IGrowiPluginOrigin, IGrowiTemplatePluginMeta, IGrowiThemePluginMeta,
+  IGrowiPlugin,
+  IGrowiPluginMeta,
+  IGrowiPluginMetaByType,
+  IGrowiPluginOrigin,
+  IGrowiTemplatePluginMeta,
+  IGrowiThemePluginMeta,
 } from '../../interfaces';
 
-export interface IGrowiPluginDocument<M extends IGrowiPluginMeta = IGrowiPluginMeta> extends IGrowiPlugin<M>, Document {
-  metaJson: IGrowiPluginMeta & IGrowiThemePluginMeta & IGrowiTemplatePluginMeta,
+export interface IGrowiPluginDocument<
+  M extends IGrowiPluginMeta = IGrowiPluginMeta,
+> extends IGrowiPlugin<M>,
+    Document {
+  metaJson: IGrowiPluginMeta & IGrowiThemePluginMeta & IGrowiTemplatePluginMeta;
 }
 export interface IGrowiPluginModel extends Model<IGrowiPluginDocument> {
-  findEnabledPlugins(): Promise<IGrowiPluginDocument[]>
-  findEnabledPluginsByType<T extends GrowiPluginType>(type: T): Promise<IGrowiPluginDocument<IGrowiPluginMetaByType<T>>[]>
-  activatePlugin(id: Types.ObjectId): Promise<string>
-  deactivatePlugin(id: Types.ObjectId): Promise<string>
+  findEnabledPlugins(): Promise<IGrowiPluginDocument[]>;
+  findEnabledPluginsByType<T extends GrowiPluginType>(
+    type: T,
+  ): Promise<IGrowiPluginDocument<IGrowiPluginMetaByType<T>>[]>;
+  activatePlugin(id: Types.ObjectId): Promise<string>;
+  deactivatePlugin(id: Types.ObjectId): Promise<string>;
 }
 
-const growiPluginMetaSchema = new Schema<IGrowiPluginMeta & IGrowiThemePluginMeta & IGrowiTemplatePluginMeta>({
+const growiPluginMetaSchema = new Schema<
+  IGrowiPluginMeta & IGrowiThemePluginMeta & IGrowiTemplatePluginMeta
+>({
   name: { type: String, required: true },
   types: {
     type: [String],
@@ -46,21 +56,28 @@ const growiPluginSchema = new Schema<IGrowiPluginDocument, IGrowiPluginModel>({
   meta: growiPluginMetaSchema,
 });
 
-growiPluginSchema.statics.findEnabledPlugins = async function(): Promise<IGrowiPlugin[]> {
+growiPluginSchema.statics.findEnabledPlugins = async function (): Promise<
+  IGrowiPlugin[]
+> {
   return this.find({ isEnabled: true }).lean();
 };
 
-growiPluginSchema.statics.findEnabledPluginsByType = async function<T extends GrowiPluginType>(
-    type: T,
-): Promise<IGrowiPlugin<IGrowiPluginMetaByType<T>>[]> {
+growiPluginSchema.statics.findEnabledPluginsByType = async function <
+  T extends GrowiPluginType,
+>(type: T): Promise<IGrowiPlugin<IGrowiPluginMetaByType<T>>[]> {
   return this.find({
     isEnabled: true,
     'meta.types': { $in: type },
   }).lean();
 };
 
-growiPluginSchema.statics.activatePlugin = async function(id: Types.ObjectId): Promise<string> {
-  const growiPlugin = await this.findOneAndUpdate({ _id: id }, { isEnabled: true });
+growiPluginSchema.statics.activatePlugin = async function (
+  id: Types.ObjectId,
+): Promise<string> {
+  const growiPlugin = await this.findOneAndUpdate(
+    { _id: id },
+    { isEnabled: true },
+  );
   if (growiPlugin == null) {
     const message = 'No plugin found for this ID.';
     throw new Error(message);
@@ -69,8 +86,13 @@ growiPluginSchema.statics.activatePlugin = async function(id: Types.ObjectId): P
   return pluginName;
 };
 
-growiPluginSchema.statics.deactivatePlugin = async function(id: Types.ObjectId): Promise<string> {
-  const growiPlugin = await this.findOneAndUpdate({ _id: id }, { isEnabled: false });
+growiPluginSchema.statics.deactivatePlugin = async function (
+  id: Types.ObjectId,
+): Promise<string> {
+  const growiPlugin = await this.findOneAndUpdate(
+    { _id: id },
+    { isEnabled: false },
+  );
   if (growiPlugin == null) {
     const message = 'No plugin found for this ID.';
     throw new Error(message);
@@ -79,4 +101,7 @@ growiPluginSchema.statics.deactivatePlugin = async function(id: Types.ObjectId):
   return pluginName;
 };
 
-export const GrowiPlugin = getOrCreateModel<IGrowiPluginDocument, IGrowiPluginModel>('GrowiPlugin', growiPluginSchema);
+export const GrowiPlugin = getOrCreateModel<
+  IGrowiPluginDocument,
+  IGrowiPluginModel
+>('GrowiPlugin', growiPluginSchema);

+ 11 - 9
apps/app/src/features/growi-plugin/server/models/vo/github-url.spec.ts

@@ -1,7 +1,6 @@
 import { GitHubUrl } from './github-url';
 
 describe('GitHubUrl Constructor throws an error when the url string is', () => {
-
   it.concurrent.each`
     url
     ${'//example.com/org/repos'}
@@ -14,11 +13,9 @@ describe('GitHubUrl Constructor throws an error when the url string is', () => {
     // then
     expect(caller).toThrowError(`The specified URL is invalid. : url='${url}'`);
   });
-
 });
 
 describe('The constructor is successfully processed', () => {
-
   it('with http schemed url', () => {
     // when
     const githubUrl = new GitHubUrl('http://github.com/org/repos');
@@ -51,7 +48,6 @@ describe('The constructor is successfully processed', () => {
     expect(githubUrl.reposName).toEqual('repos');
     expect(githubUrl.branchName).toEqual('fix/bug');
   });
-
 });
 
 describe('archiveUrl()', () => {
@@ -63,12 +59,13 @@ describe('archiveUrl()', () => {
     const { archiveUrl } = githubUrl;
 
     // then
-    expect(archiveUrl).toEqual('https://github.com/org/repos/archive/refs/heads/fix%2Fbug.zip');
+    expect(archiveUrl).toEqual(
+      'https://github.com/org/repos/archive/refs/heads/fix%2Fbug.zip',
+    );
   });
 });
 
 describe('extractedArchiveDirName()', () => {
-
   describe('certain characters in the branch name are converted to slashes, and if they are consecutive, they become a single hyphen', () => {
     it.concurrent.each`
       branchName
@@ -76,7 +73,10 @@ describe('extractedArchiveDirName()', () => {
       ${'a---b'}
     `("'$branchName'", ({ branchName }) => {
       // setup
-      const githubUrl = new GitHubUrl('https://github.com/org/repos', branchName);
+      const githubUrl = new GitHubUrl(
+        'https://github.com/org/repos',
+        branchName,
+      );
 
       // when
       const { extractedArchiveDirName } = githubUrl;
@@ -93,7 +93,10 @@ describe('extractedArchiveDirName()', () => {
       ${'a_b'}
     `("'$branchName'", ({ branchName }) => {
       // setup
-      const githubUrl = new GitHubUrl('https://github.com/org/repos', branchName);
+      const githubUrl = new GitHubUrl(
+        'https://github.com/org/repos',
+        branchName,
+      );
 
       // when
       const { extractedArchiveDirName } = githubUrl;
@@ -102,5 +105,4 @@ describe('extractedArchiveDirName()', () => {
       expect(extractedArchiveDirName).toEqual(branchName);
     });
   });
-
 });

+ 13 - 10
apps/app/src/features/growi-plugin/server/models/vo/github-url.ts

@@ -1,4 +1,3 @@
-
 import sanitize from 'sanitize-filename';
 
 // https://regex101.com/r/fK2rV3/1
@@ -11,7 +10,6 @@ const sanitizeSymbolsChars = new RegExp(/[^a-zA-Z0-9_.]+/g);
 const sanitizeVersionChars = new RegExp(/^v[\d]/gi);
 
 export class GitHubUrl {
-
   private _organizationName: string;
 
   private _reposName: string;
@@ -39,19 +37,26 @@ export class GitHubUrl {
   get archiveUrl(): string {
     const encodedBranchName = encodeURIComponent(this.branchName);
     const encodedTagName = encodeURIComponent(this.tagName);
-    const zipUrl = encodedTagName !== '' ? `tags/${encodedTagName}` : `heads/${encodedBranchName}`;
-    const ghUrl = new URL(`/${this.organizationName}/${this.reposName}/archive/refs/${zipUrl}.zip`, 'https://github.com');
+    const zipUrl =
+      encodedTagName !== ''
+        ? `tags/${encodedTagName}`
+        : `heads/${encodedBranchName}`;
+    const ghUrl = new URL(
+      `/${this.organizationName}/${this.reposName}/archive/refs/${zipUrl}.zip`,
+      'https://github.com',
+    );
     return ghUrl.toString();
   }
 
   get extractedArchiveDirName(): string {
     const name = this._tagName !== '' ? this._tagName : this._branchName;
-    return name.replace(sanitizeVersionChars, m => m.substring(1)).replaceAll(sanitizeSymbolsChars, '-');
+    return name
+      .replace(sanitizeVersionChars, (m) => m.substring(1))
+      .replaceAll(sanitizeSymbolsChars, '-');
   }
 
   constructor(url: string, branchName = 'main', tagName = '') {
-
-    let matched;
+    let matched: RegExpMatchArray | null;
     try {
       const ghUrl = new URL(url);
 
@@ -60,8 +65,7 @@ export class GitHubUrl {
       if (ghUrl.hostname !== 'github.com' || matched == null) {
         throw new Error();
       }
-    }
-    catch (err) {
+    } catch (err) {
       throw new Error(`The specified URL is invalid. : url='${url}'`);
     }
 
@@ -71,5 +75,4 @@ export class GitHubUrl {
     this._organizationName = sanitize(matched[1]);
     this._reposName = sanitize(matched[2]);
   }
-
 }

+ 62 - 36
apps/app/src/features/growi-plugin/server/routes/apiv3/admin/index.ts

@@ -1,9 +1,8 @@
+import { SCOPE } from '@growi/core/dist/interfaces';
 import type { Request, Router } from 'express';
 import express from 'express';
 import { body, query } from 'express-validator';
 import mongoose from 'mongoose';
-
-import { SCOPE } from '@growi/core/dist/interfaces';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
@@ -11,7 +10,6 @@ import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-respo
 import { GrowiPlugin } from '../../../models';
 import { growiPluginService } from '../../../services';
 
-
 const ObjectID = mongoose.Types.ObjectId;
 
 /*
@@ -22,26 +20,34 @@ const validator = {
     query('id').isMongoId().withMessage('pluginId is required'),
   ],
   pluginFormValueisRequired: [
-    body('pluginInstallerForm').isString().withMessage('pluginFormValue is required'),
+    body('pluginInstallerForm')
+      .isString()
+      .withMessage('pluginFormValue is required'),
   ],
 };
 
 module.exports = (crowi: Crowi): Router => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
+    crowi,
+  );
   const adminRequired = require('~/server/middlewares/admin-required')(crowi);
 
   const router = express.Router();
 
-  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.PLUGIN]), loginRequiredStrictly, adminRequired, async(req: Request, res: ApiV3Response) => {
-    try {
-      const data = await GrowiPlugin.find({});
-      return res.apiv3({ plugins: data });
-    }
-    catch (err) {
-      return res.apiv3Err(err);
-    }
-  });
-
+  router.get(
+    '/',
+    accessTokenParser([SCOPE.READ.ADMIN.PLUGIN]),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req: Request, res: ApiV3Response) => {
+      try {
+        const data = await GrowiPlugin.find({});
+        return res.apiv3({ plugins: data });
+      } catch (err) {
+        return res.apiv3Err(err);
+      }
+    },
+  );
 
   /**
    * @swagger
@@ -82,18 +88,23 @@ module.exports = (crowi: Crowi): Router => {
    *                   description: The name of the installed plugin
    *
    */
-  router.post('/', accessTokenParser([SCOPE.WRITE.ADMIN.PLUGIN]), loginRequiredStrictly, adminRequired, validator.pluginFormValueisRequired,
-    async(req: Request, res: ApiV3Response) => {
+  router.post(
+    '/',
+    accessTokenParser([SCOPE.WRITE.ADMIN.PLUGIN]),
+    loginRequiredStrictly,
+    adminRequired,
+    validator.pluginFormValueisRequired,
+    async (req: Request, res: ApiV3Response) => {
       const { pluginInstallerForm: formValue } = req.body;
 
       try {
         const pluginName = await growiPluginService.install(formValue);
         return res.apiv3({ pluginName });
-      }
-      catch (err) {
+      } catch (err) {
         return res.apiv3Err(err);
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -123,33 +134,43 @@ module.exports = (crowi: Crowi): Router => {
    *                   type: string
    *                   description: The name of the activated plugin
    */
-  router.put('/:id/activate', accessTokenParser([SCOPE.WRITE.ADMIN.PLUGIN]), loginRequiredStrictly, adminRequired, validator.pluginIdisRequired,
-    async(req: Request, res: ApiV3Response) => {
+  router.put(
+    '/:id/activate',
+    accessTokenParser([SCOPE.WRITE.ADMIN.PLUGIN]),
+    loginRequiredStrictly,
+    adminRequired,
+    validator.pluginIdisRequired,
+    async (req: Request, res: ApiV3Response) => {
       const { id } = req.params;
       const pluginId = new ObjectID(id);
 
       try {
         const pluginName = await GrowiPlugin.activatePlugin(pluginId);
         return res.apiv3({ pluginName });
-      }
-      catch (err) {
+      } catch (err) {
         return res.apiv3Err(err);
       }
-    });
-
-  router.put('/:id/deactivate', accessTokenParser([SCOPE.WRITE.ADMIN.PLUGIN]), loginRequiredStrictly, adminRequired, validator.pluginIdisRequired,
-    async(req: Request, res: ApiV3Response) => {
+    },
+  );
+
+  router.put(
+    '/:id/deactivate',
+    accessTokenParser([SCOPE.WRITE.ADMIN.PLUGIN]),
+    loginRequiredStrictly,
+    adminRequired,
+    validator.pluginIdisRequired,
+    async (req: Request, res: ApiV3Response) => {
       const { id } = req.params;
       const pluginId = new ObjectID(id);
 
       try {
         const pluginName = await GrowiPlugin.deactivatePlugin(pluginId);
         return res.apiv3({ pluginName });
-      }
-      catch (err) {
+      } catch (err) {
         return res.apiv3Err(err);
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -179,19 +200,24 @@ module.exports = (crowi: Crowi): Router => {
    *                   type: string
    *                   description: The name of the removed plugin
    */
-  router.delete('/:id/remove', accessTokenParser([SCOPE.WRITE.ADMIN.PLUGIN]), loginRequiredStrictly, adminRequired, validator.pluginIdisRequired,
-    async(req: Request, res: ApiV3Response) => {
+  router.delete(
+    '/:id/remove',
+    accessTokenParser([SCOPE.WRITE.ADMIN.PLUGIN]),
+    loginRequiredStrictly,
+    adminRequired,
+    validator.pluginIdisRequired,
+    async (req: Request, res: ApiV3Response) => {
       const { id } = req.params;
       const pluginId = new ObjectID(id);
 
       try {
         const pluginName = await growiPluginService.deletePlugin(pluginId);
         return res.apiv3({ pluginName });
-      }
-      catch (err) {
+      } catch (err) {
         return res.apiv3Err(err);
       }
-    });
+    },
+  );
 
   return router;
 };

+ 11 - 3
apps/app/src/features/growi-plugin/server/services/growi-plugin/generate-template-plugin-meta.ts

@@ -1,11 +1,19 @@
 import type { GrowiPluginValidationData } from '@growi/pluginkit';
 import { scanAllTemplates } from '@growi/pluginkit/dist/v4/server/index.cjs';
 
-import type { IGrowiPlugin, IGrowiTemplatePluginMeta } from '../../../interfaces';
+import type {
+  IGrowiPlugin,
+  IGrowiTemplatePluginMeta,
+} from '../../../interfaces';
 
-export const generateTemplatePluginMeta = async(plugin: IGrowiPlugin, validationData: GrowiPluginValidationData): Promise<IGrowiTemplatePluginMeta> => {
+export const generateTemplatePluginMeta = async (
+  plugin: IGrowiPlugin,
+  validationData: GrowiPluginValidationData,
+): Promise<IGrowiTemplatePluginMeta> => {
   return {
     ...plugin.meta,
-    templateSummaries: await scanAllTemplates(validationData.projectDirRoot, { pluginId: plugin.installedPath }),
+    templateSummaries: await scanAllTemplates(validationData.projectDirRoot, {
+      pluginId: plugin.installedPath,
+    }),
   };
 };

+ 4 - 1
apps/app/src/features/growi-plugin/server/services/growi-plugin/generate-theme-plugin-meta.ts

@@ -2,7 +2,10 @@ import type { GrowiPluginValidationData } from '@growi/pluginkit';
 
 import type { IGrowiPlugin, IGrowiThemePluginMeta } from '../../../interfaces';
 
-export const generateThemePluginMeta = async(plugin: IGrowiPlugin, validationData: GrowiPluginValidationData): Promise<IGrowiThemePluginMeta> => {
+export const generateThemePluginMeta = async (
+  plugin: IGrowiPlugin,
+  validationData: GrowiPluginValidationData,
+): Promise<IGrowiThemePluginMeta> => {
   // TODO: validate as a theme plugin
 
   return {

+ 42 - 25
apps/app/src/features/growi-plugin/server/services/growi-plugin/growi-plugin.integ.ts

@@ -7,27 +7,34 @@ import { GrowiPlugin } from '../../models';
 import { growiPluginService } from './growi-plugin';
 
 describe('Installing a GROWI template plugin', () => {
-
-  it('install() should success', async() => {
+  it('install() should success', async () => {
     // when
     const result = await growiPluginService.install({
       url: 'https://github.com/growilabs/growi-plugin-templates-for-office',
     });
-    const count = await GrowiPlugin.count({ 'meta.name': 'growi-plugin-templates-for-office' });
+    const count = await GrowiPlugin.count({
+      'meta.name': 'growi-plugin-templates-for-office',
+    });
 
     // expect
     expect(result).toEqual('growi-plugin-templates-for-office');
     expect(count).toBe(1);
-    expect(fs.existsSync(path.join(
-      PLUGIN_STORING_PATH,
-      'growilabs',
-      'growi-plugin-templates-for-office',
-    ))).toBeTruthy();
+    expect(
+      fs.existsSync(
+        path.join(
+          PLUGIN_STORING_PATH,
+          'growilabs',
+          'growi-plugin-templates-for-office',
+        ),
+      ),
+    ).toBeTruthy();
   });
 
-  it('install() should success (re-install)', async() => {
+  it('install() should success (re-install)', async () => {
     // confirm
-    const count1 = await GrowiPlugin.count({ 'meta.name': 'growi-plugin-templates-for-office' });
+    const count1 = await GrowiPlugin.count({
+      'meta.name': 'growi-plugin-templates-for-office',
+    });
     expect(count1).toBe(1);
 
     // setup
@@ -44,38 +51,46 @@ describe('Installing a GROWI template plugin', () => {
     const result = await growiPluginService.install({
       url: 'https://github.com/growilabs/growi-plugin-templates-for-office',
     });
-    const count2 = await GrowiPlugin.count({ 'meta.name': 'growi-plugin-templates-for-office' });
+    const count2 = await GrowiPlugin.count({
+      'meta.name': 'growi-plugin-templates-for-office',
+    });
 
     // expect
     expect(result).toEqual('growi-plugin-templates-for-office');
     expect(count2).toBe(1);
     expect(fs.existsSync(dummyFilePath)).toBeFalsy(); // the dummy file should be removed
   });
-
 });
 
 describe('Installing a GROWI theme plugin', () => {
-
-  it('install() should success', async() => {
+  it('install() should success', async () => {
     // when
     const result = await growiPluginService.install({
       url: 'https://github.com/growilabs/growi-plugin-theme-vivid-internet',
     });
-    const count = await GrowiPlugin.count({ 'meta.name': 'growi-plugin-theme-vivid-internet' });
+    const count = await GrowiPlugin.count({
+      'meta.name': 'growi-plugin-theme-vivid-internet',
+    });
 
     // expect
     expect(result).toEqual('growi-plugin-theme-vivid-internet');
     expect(count).toBe(1);
-    expect(fs.existsSync(path.join(
-      PLUGIN_STORING_PATH,
-      'growilabs',
-      'growi-plugin-theme-vivid-internet',
-    ))).toBeTruthy();
+    expect(
+      fs.existsSync(
+        path.join(
+          PLUGIN_STORING_PATH,
+          'growilabs',
+          'growi-plugin-theme-vivid-internet',
+        ),
+      ),
+    ).toBeTruthy();
   });
 
-  it('findThemePlugin() should return data with metadata and manifest', async() => {
+  it('findThemePlugin() should return data with metadata and manifest', async () => {
     // confirm
-    const count = await GrowiPlugin.count({ 'meta.name': 'growi-plugin-theme-vivid-internet' });
+    const count = await GrowiPlugin.count({
+      'meta.name': 'growi-plugin-theme-vivid-internet',
+    });
     expect(count).toBe(1);
 
     // when
@@ -87,8 +102,10 @@ describe('Installing a GROWI theme plugin', () => {
     expect(results.growiPlugin).not.toBeNull();
     expect(results.themeMetadata).not.toBeNull();
     expect(results.themeHref).not.toBeNull();
-    expect(results.themeHref
-      .startsWith('/static/plugins/growilabs/growi-plugin-theme-vivid-internet/dist/assets/style-')).toBeTruthy();
+    expect(
+      results.themeHref?.startsWith(
+        '/static/plugins/growilabs/growi-plugin-theme-vivid-internet/dist/assets/style-',
+      ),
+    ).toBeTruthy();
   });
-
 });

+ 190 - 123
apps/app/src/features/growi-plugin/server/services/growi-plugin/growi-plugin.ts

@@ -1,20 +1,24 @@
-import fs, { readFileSync } from 'fs';
-import path from 'path';
-import { pipeline } from 'stream/promises';
-
-import { GrowiPluginType } from '@growi/core';
 import type { GrowiThemeMetadata, ViteManifest } from '@growi/core';
+import { GrowiPluginType } from '@growi/core';
 import type { GrowiPluginPackageData } from '@growi/pluginkit';
-import { importPackageJson, validateGrowiDirective } from '@growi/pluginkit/dist/v4/server/index.cjs';
+import {
+  importPackageJson,
+  validateGrowiDirective,
+} from '@growi/pluginkit/dist/v4/server/index.cjs';
 // eslint-disable-next-line no-restricted-imports
 import axios from 'axios';
+import fs, { readFileSync } from 'fs';
 import type mongoose from 'mongoose';
+import path from 'path';
+import { pipeline } from 'stream/promises';
 import unzipStream from 'unzip-stream';
 
 import loggerFactory from '~/utils/logger';
 
 import type {
-  IGrowiPlugin, IGrowiPluginOrigin, IGrowiPluginMeta,
+  IGrowiPlugin,
+  IGrowiPluginMeta,
+  IGrowiPluginOrigin,
 } from '../../../interfaces';
 import { PLUGIN_EXPRESS_STATIC_DIR, PLUGIN_STORING_PATH } from '../../consts';
 import { GrowiPlugin } from '../../models';
@@ -25,12 +29,25 @@ import { generateThemePluginMeta } from './generate-theme-plugin-meta';
 
 const logger = loggerFactory('growi:plugins:plugin-utils');
 
-export type GrowiPluginResourceEntries = [installedPath: string, href: string][];
+export type GrowiPluginResourceEntries = [
+  installedPath: string,
+  href: string,
+][];
 
-function retrievePluginManifest(growiPlugin: IGrowiPlugin): ViteManifest | undefined {
+function retrievePluginManifest(
+  growiPlugin: IGrowiPlugin,
+): ViteManifest | undefined {
   // ref: https://vitejs.dev/guide/migration.html#manifest-files-are-now-generated-in-vite-directory-by-default
-  const manifestPathByVite4 = path.join(PLUGIN_STORING_PATH, growiPlugin.installedPath, 'dist/manifest.json');
-  const manifestPath = path.join(PLUGIN_STORING_PATH, growiPlugin.installedPath, 'dist/.vite/manifest.json');
+  const manifestPathByVite4 = path.join(
+    PLUGIN_STORING_PATH,
+    growiPlugin.installedPath,
+    'dist/manifest.json',
+  );
+  const manifestPath = path.join(
+    PLUGIN_STORING_PATH,
+    growiPlugin.installedPath,
+    'dist/.vite/manifest.json',
+  );
 
   const isManifestByVite4Exists = fs.existsSync(manifestPathByVite4);
   const isManifestExists = fs.existsSync(manifestPath);
@@ -46,25 +63,23 @@ function retrievePluginManifest(growiPlugin: IGrowiPlugin): ViteManifest | undef
   return JSON.parse(manifestStr);
 }
 
-
 type FindThemePluginResult = {
-  growiPlugin: IGrowiPlugin,
-  themeMetadata: GrowiThemeMetadata,
-  themeHref: string,
-}
+  growiPlugin: IGrowiPlugin;
+  themeMetadata: GrowiThemeMetadata;
+  themeHref: string | undefined;
+};
 
 export interface IGrowiPluginService {
-  install(origin: IGrowiPluginOrigin): Promise<string>
-  findThemePlugin(theme: string): Promise<FindThemePluginResult | null>
-  retrieveAllPluginResourceEntries(): Promise<GrowiPluginResourceEntries>
-  downloadNotExistPluginRepositories(): Promise<void>
+  install(origin: IGrowiPluginOrigin): Promise<string>;
+  findThemePlugin(theme: string): Promise<FindThemePluginResult | null>;
+  retrieveAllPluginResourceEntries(): Promise<GrowiPluginResourceEntries>;
+  downloadNotExistPluginRepositories(): Promise<void>;
 }
 
 export class GrowiPluginService implements IGrowiPluginService {
-
   /*
-  * Downloading a non-existent repository to the file system
-  */
+   * Downloading a non-existent repository to the file system
+   */
   async downloadNotExistPluginRepositories(): Promise<void> {
     try {
       // find all growi plugin documents
@@ -72,69 +87,93 @@ export class GrowiPluginService implements IGrowiPluginService {
 
       // if not exists repository in file system, download latest plugin repository
       for await (const growiPlugin of growiPlugins) {
-        let pluginPath :fs.PathLike|undefined;
-        let organizationName :fs.PathLike|undefined;
+        let pluginPath: fs.PathLike | undefined;
+        let organizationName: fs.PathLike | undefined;
         try {
-          pluginPath = this.joinAndValidatePath(PLUGIN_STORING_PATH, growiPlugin.installedPath);
-          organizationName = this.joinAndValidatePath(PLUGIN_STORING_PATH, growiPlugin.organizationName);
-        }
-        catch (err) {
+          pluginPath = this.joinAndValidatePath(
+            PLUGIN_STORING_PATH,
+            growiPlugin.installedPath,
+          );
+          organizationName = this.joinAndValidatePath(
+            PLUGIN_STORING_PATH,
+            growiPlugin.organizationName,
+          );
+        } catch (err) {
           logger.error(err);
           continue;
         }
         if (fs.existsSync(pluginPath)) {
-          continue;
-        }
-        else {
+        } else {
           if (!fs.existsSync(organizationName)) {
             fs.mkdirSync(organizationName);
           }
 
           // TODO: imprv Document version and repository version possibly different.
-          const ghUrl = new GitHubUrl(growiPlugin.origin.url, growiPlugin.origin.ghBranch);
+          const ghUrl = new GitHubUrl(
+            growiPlugin.origin.url,
+            growiPlugin.origin.ghBranch,
+          );
           const { reposName, archiveUrl, extractedArchiveDirName } = ghUrl;
 
-          const zipFilePath = path.join(PLUGIN_STORING_PATH, `${extractedArchiveDirName}.zip`);
+          const zipFilePath = path.join(
+            PLUGIN_STORING_PATH,
+            `${extractedArchiveDirName}.zip`,
+          );
           const unzippedPath = PLUGIN_STORING_PATH;
-          const unzippedReposPath = path.join(PLUGIN_STORING_PATH, `${reposName}-${extractedArchiveDirName}`);
+          const unzippedReposPath = path.join(
+            PLUGIN_STORING_PATH,
+            `${reposName}-${extractedArchiveDirName}`,
+          );
 
           try {
             // download github repository to local file system
             await this.download(archiveUrl, zipFilePath);
             await this.unzip(zipFilePath, unzippedPath);
             fs.renameSync(unzippedReposPath, pluginPath);
-          }
-          catch (err) {
+          } catch (err) {
             // clean up, documents are not operated
-            if (fs.existsSync(unzippedReposPath)) await fs.promises.rm(unzippedReposPath, { recursive: true });
-            if (fs.existsSync(pluginPath)) await fs.promises.rm(pluginPath, { recursive: true });
+            if (fs.existsSync(unzippedReposPath))
+              await fs.promises.rm(unzippedReposPath, { recursive: true });
+            if (fs.existsSync(pluginPath))
+              await fs.promises.rm(pluginPath, { recursive: true });
             logger.error(err);
           }
-
-          continue;
         }
       }
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
     }
   }
 
   /*
-  * Install a plugin from URL and save it in the DB and file system.
-  */
+   * Install a plugin from URL and save it in the DB and file system.
+   */
   async install(origin: IGrowiPluginOrigin): Promise<string> {
     const ghUrl = new GitHubUrl(origin.url, origin.ghBranch);
-    const {
-      organizationName, reposName, archiveUrl, extractedArchiveDirName,
-    } = ghUrl;
+    const { organizationName, reposName, archiveUrl, extractedArchiveDirName } =
+      ghUrl;
 
     const installedPath = `${organizationName}/${reposName}`;
 
-    const organizationPath = path.join(PLUGIN_STORING_PATH, organizationName);
-    const zipFilePath = path.join(organizationPath, `${reposName}-${extractedArchiveDirName}.zip`);
-    const temporaryReposPath = path.join(organizationPath, `${reposName}-${extractedArchiveDirName}`);
-    const reposPath = path.join(organizationPath, reposName);
+    const organizationPath = this.joinAndValidatePath(
+      PLUGIN_STORING_PATH,
+      organizationName,
+    );
+    const zipFilePath = this.joinAndValidatePath(
+      PLUGIN_STORING_PATH,
+      organizationName,
+      `${reposName}-${extractedArchiveDirName}.zip`,
+    );
+    const temporaryReposPath = this.joinAndValidatePath(
+      PLUGIN_STORING_PATH,
+      organizationName,
+      `${reposName}-${extractedArchiveDirName}`,
+    );
+    const reposPath = this.joinAndValidatePath(
+      PLUGIN_STORING_PATH,
+      organizationName,
+      reposName,
+    );
 
     if (!fs.existsSync(organizationPath)) fs.mkdirSync(organizationPath);
 
@@ -146,22 +185,27 @@ export class GrowiPluginService implements IGrowiPluginService {
       await this.unzip(zipFilePath, organizationPath);
 
       // detect plugins
-      plugins = await GrowiPluginService.detectPlugins(origin, organizationName, reposName, { packageRootPath: temporaryReposPath });
+      plugins = await GrowiPluginService.detectPlugins(
+        origin,
+        organizationName,
+        reposName,
+        { packageRootPath: temporaryReposPath },
+      );
 
       // remove the old repository from the storing path
-      if (fs.existsSync(reposPath)) await fs.promises.rm(reposPath, { recursive: true });
+      if (fs.existsSync(reposPath))
+        await fs.promises.rm(reposPath, { recursive: true });
 
       // move new repository from temporary path to storing path.
       fs.renameSync(temporaryReposPath, reposPath);
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       throw err;
-    }
-    finally {
+    } finally {
       // clean up
       if (fs.existsSync(zipFilePath)) await fs.promises.rm(zipFilePath);
-      if (fs.existsSync(temporaryReposPath)) await fs.promises.rm(temporaryReposPath, { recursive: true });
+      if (fs.existsSync(temporaryReposPath))
+        await fs.promises.rm(temporaryReposPath, { recursive: true });
     }
 
     try {
@@ -172,10 +216,10 @@ export class GrowiPluginService implements IGrowiPluginService {
       await this.savePluginMetaData(plugins);
 
       return plugins[0].meta.name;
-    }
-    catch (err) {
+    } catch (err) {
       // uninstall
-      if (fs.existsSync(reposPath)) await fs.promises.rm(reposPath, { recursive: true });
+      if (fs.existsSync(reposPath))
+        await fs.promises.rm(reposPath, { recursive: true });
       await this.deleteOldPluginDocument(installedPath);
 
       logger.error(err);
@@ -198,16 +242,17 @@ export class GrowiPluginService implements IGrowiPluginService {
         .then((res) => {
           if (res.status === 200) {
             const file = fs.createWriteStream(filePath);
-            res.data.pipe(file)
+            res.data
+              .pipe(file)
               .on('close', () => file.close())
               .on('finish', () => {
                 return resolve();
               });
-          }
-          else {
+          } else {
             rejects(res.status);
           }
-        }).catch((err) => {
+        })
+        .catch((err) => {
           logger.error(err);
           // eslint-disable-next-line prefer-promise-reject-errors
           rejects('Failed to download file.');
@@ -215,12 +260,17 @@ export class GrowiPluginService implements IGrowiPluginService {
     });
   }
 
-  private async unzip(zipFilePath: fs.PathLike, destPath: fs.PathLike): Promise<void> {
+  private async unzip(
+    zipFilePath: fs.PathLike,
+    destPath: fs.PathLike,
+  ): Promise<void> {
     try {
       const readZipStream = fs.createReadStream(zipFilePath);
-      await pipeline(readZipStream, unzipStream.Extract({ path: destPath.toString() }));
-    }
-    catch (err) {
+      await pipeline(
+        readZipStream,
+        unzipStream.Extract({ path: destPath.toString() }),
+      );
+    } catch (err) {
       logger.error(err);
       throw new Error('Failed to unzip.');
     }
@@ -232,32 +282,44 @@ export class GrowiPluginService implements IGrowiPluginService {
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, max-len
   private static async detectPlugins(
-      origin: IGrowiPluginOrigin, ghOrganizationName: string, ghReposName: string,
-      opts?: {
-        packageRootPath?: string,
-        parentPackageData?: GrowiPluginPackageData,
-      },
+    origin: IGrowiPluginOrigin,
+    ghOrganizationName: string,
+    ghReposName: string,
+    opts?: {
+      packageRootPath?: string;
+      parentPackageData?: GrowiPluginPackageData;
+    },
   ): Promise<IGrowiPlugin[]> {
-    const packageRootPath = opts?.packageRootPath ?? path.resolve(PLUGIN_STORING_PATH, ghOrganizationName, ghReposName);
+    const packageRootPath =
+      opts?.packageRootPath ??
+      path.resolve(PLUGIN_STORING_PATH, ghOrganizationName, ghReposName);
 
     // validate
     const validationData = await validateGrowiDirective(packageRootPath);
 
-    const packageData = opts?.parentPackageData ?? importPackageJson(packageRootPath);
+    const packageData =
+      opts?.parentPackageData ?? importPackageJson(packageRootPath);
 
     const { growiPlugin } = validationData;
     const {
-      name: packageName, description: packageDesc, author: packageAuthor,
+      name: packageName,
+      description: packageDesc,
+      author: packageAuthor,
     } = packageData;
 
     // detect sub plugins for monorepo
     if (growiPlugin.isMonorepo && growiPlugin.packages != null) {
       const plugins = await Promise.all(
-        growiPlugin.packages.map(async(subPackagePath) => {
-          return this.detectPlugins(origin, ghOrganizationName, ghReposName, {
-            packageRootPath: path.join(packageRootPath, subPackagePath),
-            parentPackageData: packageData,
-          });
+        growiPlugin.packages.map(async (subPackagePath) => {
+          return GrowiPluginService.detectPlugins(
+            origin,
+            ghOrganizationName,
+            ghReposName,
+            {
+              packageRootPath: path.join(packageRootPath, subPackagePath),
+              parentPackageData: packageData,
+            },
+          );
         }),
       );
       return plugins.flat();
@@ -310,31 +372,32 @@ export class GrowiPluginService implements IGrowiPluginService {
 
     try {
       await GrowiPlugin.deleteOne({ _id: pluginId });
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       throw new Error('Failed to delete plugin from GrowiPlugin documents.');
     }
 
     let growiPluginsPath: fs.PathLike | undefined;
     try {
-      growiPluginsPath = this.joinAndValidatePath(PLUGIN_STORING_PATH, growiPlugins.installedPath);
-    }
-    catch (err) {
+      growiPluginsPath = this.joinAndValidatePath(
+        PLUGIN_STORING_PATH,
+        growiPlugins.installedPath,
+      );
+    } catch (err) {
       logger.error(err);
-      throw new Error('The installedPath for the plugin is invalid, and the plugin has already been removed.');
+      throw new Error(
+        'The installedPath for the plugin is invalid, and the plugin has already been removed.',
+      );
     }
 
     if (growiPluginsPath && fs.existsSync(growiPluginsPath)) {
       try {
         await deleteFolder(growiPluginsPath);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         throw new Error('Failed to delete plugin repository.');
       }
-    }
-    else {
+    } else {
       logger.warn(`Plugin path does not exist : ${growiPluginsPath}`);
     }
     return growiPlugins.meta.name;
@@ -346,51 +409,56 @@ export class GrowiPluginService implements IGrowiPluginService {
 
     try {
       // retrieve plugin manifests
-      const growiPlugins = await GrowiPlugin.findEnabledPluginsByType(GrowiPluginType.Theme);
+      const growiPlugins = await GrowiPlugin.findEnabledPluginsByType(
+        GrowiPluginType.Theme,
+      );
 
-      growiPlugins
-        .forEach((growiPlugin) => {
-          const themeMetadatas = growiPlugin.meta.themes;
-          const themeMetadata = themeMetadatas.find(t => t.name === theme);
+      growiPlugins.forEach((growiPlugin) => {
+        const themeMetadatas = growiPlugin.meta.themes;
+        const themeMetadata = themeMetadatas.find((t) => t.name === theme);
 
-          // found
-          if (themeMetadata != null) {
-            matchedPlugin = growiPlugin;
-            matchedThemeMetadata = themeMetadata;
-          }
-        });
-    }
-    catch (e) {
-      logger.error(`Could not find the theme '${theme}' from GrowiPlugin documents.`, e);
+        // found
+        if (themeMetadata != null) {
+          matchedPlugin = growiPlugin;
+          matchedThemeMetadata = themeMetadata;
+        }
+      });
+    } catch (e) {
+      logger.error(
+        `Could not find the theme '${theme}' from GrowiPlugin documents.`,
+        e,
+      );
     }
 
     if (matchedPlugin == null || matchedThemeMetadata == null) {
       return null;
     }
 
-    let themeHref;
+    let themeHref: string | undefined;
     try {
       const manifest = retrievePluginManifest(matchedPlugin);
       if (manifest == null) {
         throw new Error('The manifest file does not exists');
       }
       themeHref = `${PLUGIN_EXPRESS_STATIC_DIR}/${matchedPlugin.installedPath}/dist/${manifest[matchedThemeMetadata.manifestKey].file}`;
-    }
-    catch (e) {
+    } catch (e) {
       logger.error(`Could not read manifest file for the theme '${theme}'`, e);
     }
 
-    return { growiPlugin: matchedPlugin, themeMetadata: matchedThemeMetadata, themeHref };
+    return {
+      growiPlugin: matchedPlugin,
+      themeMetadata: matchedThemeMetadata,
+      themeHref,
+    };
   }
 
   async retrieveAllPluginResourceEntries(): Promise<GrowiPluginResourceEntries> {
-
     const entries: GrowiPluginResourceEntries = [];
 
     try {
       const growiPlugins = await GrowiPlugin.findEnabledPlugins();
 
-      growiPlugins.forEach(async(growiPlugin) => {
+      growiPlugins.forEach(async (growiPlugin) => {
         try {
           const { types } = growiPlugin.meta;
           const manifest = await retrievePluginManifest(growiPlugin);
@@ -405,35 +473,34 @@ export class GrowiPluginService implements IGrowiPluginService {
             entries.push([growiPlugin.installedPath, href]);
           }
           // add link
-          if (types.includes(GrowiPluginType.Script) || types.includes(GrowiPluginType.Style)) {
+          if (
+            types.includes(GrowiPluginType.Script) ||
+            types.includes(GrowiPluginType.Style)
+          ) {
             const href = `${PLUGIN_EXPRESS_STATIC_DIR}/${growiPlugin.installedPath}/dist/${manifest['client-entry.tsx'].css}`;
             entries.push([growiPlugin.installedPath, href]);
           }
-        }
-        catch (e) {
+        } catch (e) {
           logger.warn(e);
         }
       });
-    }
-    catch (e) {
+    } catch (e) {
       logger.error('Could not retrieve GrowiPlugin documents.', e);
     }
 
     return entries;
   }
 
-  private joinAndValidatePath(baseDir: string, ...paths: string[]):fs.PathLike {
+  private joinAndValidatePath(baseDir: string, ...paths: string[]): string {
     const joinedPath = path.join(baseDir, ...paths);
     if (!joinedPath.startsWith(baseDir)) {
       throw new Error(
-        'Invalid plugin path detected! Access outside of the allowed directory is not permitted.'
-        + `\nAttempted Path: ${joinedPath}`,
+        'Invalid plugin path detected! Access outside of the allowed directory is not permitted.' +
+          `\nAttempted Path: ${joinedPath}`,
       );
     }
     return joinedPath;
   }
-
 }
 
-
 export const growiPluginService = new GrowiPluginService();

+ 1 - 0
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron.integ.ts

@@ -30,6 +30,7 @@ vi.mock('./page-bulk-export-job-cron', () => {
   return {
     pageBulkExportJobCronService: {
       cleanUpExportJobResources: vi.fn(() => Promise.resolve()),
+      notifyExportResultAndCleanUp: vi.fn(() => Promise.resolve()),
     },
   };
 });

+ 10 - 7
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron.ts

@@ -1,5 +1,5 @@
 import type { HydratedDocument } from 'mongoose';
-
+import { SupportedAction } from '~/interfaces/activity';
 import type Crowi from '~/server/crowi';
 import { configManager } from '~/server/service/config-manager';
 import CronService from '~/server/service/cron';
@@ -56,13 +56,16 @@ class PageBulkExportJobCleanUpCronService extends CronService {
       },
     });
 
-    if (pageBulkExportJobCronService != null) {
-      await this.cleanUpAndDeleteBulkExportJobs(
-        expiredExportJobs,
-        pageBulkExportJobCronService.cleanUpExportJobResources.bind(
-          pageBulkExportJobCronService,
-        ),
+    const cleanUp = async (job: PageBulkExportJobDocument) => {
+      await pageBulkExportJobCronService?.notifyExportResultAndCleanUp(
+        SupportedAction.ACTION_PAGE_BULK_EXPORT_JOB_EXPIRED,
+        job,
       );
+      logger.error(`Bulk export job has expired: ${job._id.toString()}`);
+    };
+
+    if (pageBulkExportJobCronService != null) {
+      await this.cleanUpAndDeleteBulkExportJobs(expiredExportJobs, cleanUp);
     }
   }
 

+ 6 - 4
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/errors.ts

@@ -1,11 +1,13 @@
+import type { PageBulkExportJobDocument } from '../../models/page-bulk-export-job';
+
 export class BulkExportJobExpiredError extends Error {
-  constructor() {
-    super('Bulk export job has expired');
+  constructor(pageBulkExportJob: PageBulkExportJobDocument) {
+    super(`Bulk export job has expired: ${pageBulkExportJob._id.toString()}`);
   }
 }
 
-export class BulkExportJobRestartedError extends Error {
+export class BulkExportJobStreamDestroyedByCleanupError extends Error {
   constructor() {
-    super('Bulk export job has restarted');
+    super('Bulk export job stream was destroyed by cleanup');
   }
 }

+ 38 - 24
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/index.ts

@@ -1,6 +1,6 @@
 import fs from 'node:fs';
 import path from 'node:path';
-import type { Readable } from 'node:stream';
+import type { Readable, Writable } from 'node:stream';
 import type { IUser } from '@growi/core';
 import { getIdForRef, isPopulated } from '@growi/core';
 import mongoose from 'mongoose';
@@ -25,7 +25,7 @@ import PageBulkExportJob from '../../models/page-bulk-export-job';
 import PageBulkExportPageSnapshot from '../../models/page-bulk-export-page-snapshot';
 import {
   BulkExportJobExpiredError,
-  BulkExportJobRestartedError,
+  BulkExportJobStreamDestroyedByCleanupError,
 } from './errors';
 import { requestPdfConverter } from './request-pdf-converter';
 import { compressAndUpload } from './steps/compress-and-upload';
@@ -39,7 +39,10 @@ export interface IPageBulkExportJobCronService {
   pageBatchSize: number;
   maxPartSize: number;
   compressExtension: string;
-  setStreamInExecution(jobId: ObjectIdLike, stream: Readable): void;
+  setStreamsInExecution(
+    jobId: ObjectIdLike,
+    ...streams: (Readable | Writable)[]
+  ): void;
   removeStreamInExecution(jobId: ObjectIdLike): void;
   handleError(
     err: Error | null,
@@ -77,10 +80,10 @@ class PageBulkExportJobCronService
   // temporal path of local fs to output page files before upload
   tmpOutputRootDir = '/tmp/page-bulk-export';
 
-  // Keep track of the stream executed for PageBulkExportJob to destroy it on job failure.
-  // The key is the id of a PageBulkExportJob.
+  // Keep track of all streams executed for PageBulkExportJob to destroy them on job failure.
+  // The key is the id of a PageBulkExportJob, value is array of streams.
   private streamInExecutionMemo: {
-    [key: string]: Readable;
+    [key: string]: (Readable | Writable)[];
   } = {};
 
   private parallelExecLimit: number;
@@ -132,22 +135,27 @@ class PageBulkExportJobCronService
   }
 
   /**
-   * Get the stream in execution for a job.
+   * Get all streams in execution for a job.
    * A getter method that includes "undefined" in the return type
    */
-  getStreamInExecution(jobId: ObjectIdLike): Readable | undefined {
+  getStreamsInExecution(
+    jobId: ObjectIdLike,
+  ): (Readable | Writable)[] | undefined {
     return this.streamInExecutionMemo[jobId.toString()];
   }
 
   /**
-   * Set the stream in execution for a job
+   * Set streams in execution for a job
    */
-  setStreamInExecution(jobId: ObjectIdLike, stream: Readable) {
-    this.streamInExecutionMemo[jobId.toString()] = stream;
+  setStreamsInExecution(
+    jobId: ObjectIdLike,
+    ...streams: (Readable | Writable)[]
+  ) {
+    this.streamInExecutionMemo[jobId.toString()] = streams;
   }
 
   /**
-   * Remove the stream in execution for a job
+   * Remove all streams in execution for a job
    */
   removeStreamInExecution(jobId: ObjectIdLike) {
     delete this.streamInExecutionMemo[jobId.toString()];
@@ -160,7 +168,7 @@ class PageBulkExportJobCronService
   async proceedBulkExportJob(pageBulkExportJob: PageBulkExportJobDocument) {
     try {
       if (pageBulkExportJob.restartFlag) {
-        await this.cleanUpExportJobResources(pageBulkExportJob, true);
+        await this.cleanUpExportJobResources(pageBulkExportJob);
         pageBulkExportJob.restartFlag = false;
         pageBulkExportJob.status = PageBulkExportJobStatus.initializing;
         pageBulkExportJob.statusOnPreviousCronExec = undefined;
@@ -225,9 +233,6 @@ class PageBulkExportJobCronService
         SupportedAction.ACTION_PAGE_BULK_EXPORT_JOB_EXPIRED,
         pageBulkExportJob,
       );
-    } else if (err instanceof BulkExportJobRestartedError) {
-      logger.info(err.message);
-      await this.cleanUpExportJobResources(pageBulkExportJob);
     } else {
       logger.error(err);
       await this.notifyExportResultAndCleanUp(
@@ -268,15 +273,24 @@ class PageBulkExportJobCronService
    */
   async cleanUpExportJobResources(
     pageBulkExportJob: PageBulkExportJobDocument,
-    restarted = false,
   ) {
-    const streamInExecution = this.getStreamInExecution(pageBulkExportJob._id);
-    if (streamInExecution != null) {
-      if (restarted) {
-        streamInExecution.destroy(new BulkExportJobRestartedError());
-      } else {
-        streamInExecution.destroy(new BulkExportJobExpiredError());
-      }
+    const streamsInExecution = this.getStreamsInExecution(
+      pageBulkExportJob._id,
+    );
+    if (streamsInExecution != null && streamsInExecution.length > 0) {
+      // Wait for all streams to be destroyed before proceeding with cleanup
+      await Promise.allSettled(
+        streamsInExecution.map((stream) => {
+          if (!stream.destroyed) {
+            return new Promise<void>((resolve) => {
+              stream.destroy(new BulkExportJobStreamDestroyedByCleanupError());
+              stream.once('close', () => resolve());
+            });
+          }
+          return Promise.resolve();
+        }),
+      );
+
       this.removeStreamInExecution(pageBulkExportJob._id);
     }
 

+ 1 - 1
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/request-pdf-converter.ts

@@ -48,7 +48,7 @@ export async function requestPdfConverter(
   }
 
   if (new Date() > bulkExportJobExpirationDate) {
-    throw new BulkExportJobExpiredError();
+    throw new BulkExportJobExpiredError(pageBulkExportJob);
   }
 
   try {

+ 1 - 1
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/compress-and-upload.ts

@@ -77,7 +77,7 @@ export async function compressAndUpload(
 
   pageArchiver.directory(this.getTmpOutputDir(pageBulkExportJob), false);
   pageArchiver.finalize();
-  this.setStreamInExecution(pageBulkExportJob._id, pageArchiver);
+  this.setStreamsInExecution(pageBulkExportJob._id, pageArchiver);
 
   try {
     await fileUploadService.uploadAttachment(pageArchiver, attachment);

+ 10 - 2
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/create-page-snapshots-async.ts

@@ -12,6 +12,7 @@ import type { PageBulkExportJobDocument } from '../../../models/page-bulk-export
 import PageBulkExportJob from '../../../models/page-bulk-export-job';
 import PageBulkExportPageSnapshot from '../../../models/page-bulk-export-page-snapshot';
 import type { IPageBulkExportJobCronService } from '..';
+import { BulkExportJobStreamDestroyedByCleanupError } from '../errors';
 
 async function reuseDuplicateExportIfExists(
   this: IPageBulkExportJobCronService,
@@ -101,9 +102,16 @@ export async function createPageSnapshotsAsync(
     },
   });
 
-  this.setStreamInExecution(pageBulkExportJob._id, pagesReadable);
+  this.setStreamsInExecution(
+    pageBulkExportJob._id,
+    pagesReadable,
+    pageSnapshotsWritable,
+  );
 
   pipeline(pagesReadable, pageSnapshotsWritable, (err) => {
-    this.handleError(err, pageBulkExportJob);
+    // prevent overlapping cleanup
+    if (!(err instanceof BulkExportJobStreamDestroyedByCleanupError)) {
+      this.handleError(err, pageBulkExportJob);
+    }
   });
 }

+ 10 - 2
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/export-pages-to-fs-async.ts

@@ -21,6 +21,7 @@ import type { PageBulkExportJobDocument } from '../../../models/page-bulk-export
 import type { PageBulkExportPageSnapshotDocument } from '../../../models/page-bulk-export-page-snapshot';
 import PageBulkExportPageSnapshot from '../../../models/page-bulk-export-page-snapshot';
 import type { IPageBulkExportJobCronService } from '..';
+import { BulkExportJobStreamDestroyedByCleanupError } from '../errors';
 
 async function convertMdToHtml(
   md: string,
@@ -133,9 +134,16 @@ export async function exportPagesToFsAsync(
 
   const pagesWritable = await getPageWritable.bind(this)(pageBulkExportJob);
 
-  this.setStreamInExecution(pageBulkExportJob._id, pageSnapshotsReadable);
+  this.setStreamsInExecution(
+    pageBulkExportJob._id,
+    pageSnapshotsReadable,
+    pagesWritable,
+  );
 
   pipeline(pageSnapshotsReadable, pagesWritable, (err) => {
-    this.handleError(err, pageBulkExportJob);
+    // prevent overlapping cleanup
+    if (!(err instanceof BulkExportJobStreamDestroyedByCleanupError)) {
+      this.handleError(err, pageBulkExportJob);
+    }
   });
 }

+ 14 - 12
apps/app/src/features/rate-limiter/config/index.ts

@@ -1,11 +1,11 @@
 export type IApiRateLimitConfig = {
-  method: string,
-  maxRequests: number,
-  usersPerIpProspection?: number,
-}
+  method: string;
+  maxRequests: number;
+  usersPerIpProspection?: number;
+};
 export type IApiRateLimitEndpointMap = {
-  [endpoint: string]: IApiRateLimitConfig
-}
+  [endpoint: string]: IApiRateLimitConfig;
+};
 
 export const DEFAULT_MAX_REQUESTS = 500;
 export const DEFAULT_DURATION_SEC = 60;
@@ -59,12 +59,14 @@ export const defaultConfig: IApiRateLimitEndpointMap = {
 };
 
 const isDev = process.env.NODE_ENV === 'development';
-const defaultConfigWithRegExpForDev: IApiRateLimitEndpointMap = isDev ? {
-  '/__nextjs_original-stack-frame': {
-    method: 'GET',
-    maxRequests: Infinity,
-  },
-} : {};
+const defaultConfigWithRegExpForDev: IApiRateLimitEndpointMap = isDev
+  ? {
+      '/__nextjs_original-stack-frame': {
+        method: 'GET',
+        maxRequests: Infinity,
+      },
+    }
+  : {};
 
 // default config with reg exp
 export const defaultConfigWithRegExp: IApiRateLimitEndpointMap = {

+ 10 - 8
apps/app/src/features/rate-limiter/middleware/consume-points.integ.ts

@@ -1,6 +1,10 @@
 import { faker } from '@faker-js/faker';
 
-const testRateLimitErrorWhenExceedingMaxRequests = async(method: string, key: string, maxRequests: number): Promise<void> => {
+const testRateLimitErrorWhenExceedingMaxRequests = async (
+  method: string,
+  key: string,
+  maxRequests: number,
+): Promise<void> => {
   // dynamic import is used because rateLimiterMongo needs to be initialized after connecting to DB
   // Issue: https://github.com/animir/node-rate-limiter-flexible/issues/216
   const { consumePoints } = await import('./consume-points');
@@ -20,8 +24,7 @@ const testRateLimitErrorWhenExceedingMaxRequests = async(method: string, key: st
         throw new Error('Exception occurred');
       }
     }
-  }
-  catch (err) {
+  } catch (err) {
     // Expect rate limit error to be called
     expect(err.message).not.toBe('Exception occurred');
     // Expect rate limit error at maxRequest + 1
@@ -29,9 +32,8 @@ const testRateLimitErrorWhenExceedingMaxRequests = async(method: string, key: st
   }
 };
 
-
-describe('consume-points.ts', async() => {
-  it('Should trigger a rate limit error when maxRequest is exceeded (maxRequest: 1)', async() => {
+describe('consume-points.ts', async () => {
+  it('Should trigger a rate limit error when maxRequest is exceeded (maxRequest: 1)', async () => {
     // setup
     const method = 'GET';
     const key = 'test-key-1';
@@ -40,7 +42,7 @@ describe('consume-points.ts', async() => {
     await testRateLimitErrorWhenExceedingMaxRequests(method, key, maxRequests);
   });
 
-  it('Should trigger a rate limit error when maxRequest is exceeded (maxRequest: 500)', async() => {
+  it('Should trigger a rate limit error when maxRequest is exceeded (maxRequest: 500)', async () => {
     // setup
     const method = 'GET';
     const key = 'test-key-2';
@@ -49,7 +51,7 @@ describe('consume-points.ts', async() => {
     await testRateLimitErrorWhenExceedingMaxRequests(method, key, maxRequests);
   });
 
-  it('Should trigger a rate limit error when maxRequest is exceeded (maxRequest: {random integer between 1 and 1000})', async() => {
+  it('Should trigger a rate limit error when maxRequest is exceeded (maxRequest: {random integer between 1 and 1000})', async () => {
     // setup
     const method = 'GET';
     const key = 'test-key-3';

+ 15 - 5
apps/app/src/features/rate-limiter/middleware/consume-points.ts

@@ -1,11 +1,14 @@
-import { type RateLimiterRes } from 'rate-limiter-flexible';
+import type { RateLimiterRes } from 'rate-limiter-flexible';
 
 import { DEFAULT_MAX_REQUESTS, type IApiRateLimitConfig } from '../config';
 
 import { rateLimiterFactory } from './rate-limiter-factory';
 
-export const consumePoints = async(
-    method: string, key: string | null, customizedConfig?: IApiRateLimitConfig, maxRequestsMultiplier?: number,
+export const consumePoints = async (
+  method: string,
+  key: string | null,
+  customizedConfig?: IApiRateLimitConfig,
+  maxRequestsMultiplier?: number,
 ): Promise<RateLimiterRes | undefined> => {
   if (key == null) {
     return;
@@ -14,7 +17,11 @@ export const consumePoints = async(
   let maxRequests = DEFAULT_MAX_REQUESTS;
 
   // use customizedConfig
-  if (customizedConfig != null && (customizedConfig.method.includes(method) || customizedConfig.method === 'ALL')) {
+  if (
+    customizedConfig != null &&
+    (customizedConfig.method.includes(method) ||
+      customizedConfig.method === 'ALL')
+  ) {
     maxRequests = customizedConfig.maxRequests;
   }
 
@@ -23,7 +30,10 @@ export const consumePoints = async(
     maxRequests *= maxRequestsMultiplier;
   }
 
-  const rateLimiter = rateLimiterFactory.getOrCreateRateLimiter(key, maxRequests);
+  const rateLimiter = rateLimiterFactory.getOrCreateRateLimiter(
+    key,
+    maxRequests,
+  );
 
   const pointsToConsume = 1;
   const rateLimiterRes = await rateLimiter.consume(key, pointsToConsume);

+ 26 - 22
apps/app/src/features/rate-limiter/middleware/factory.ts

@@ -1,11 +1,14 @@
 import type { IUserHasId } from '@growi/core';
 import type { Handler, Request } from 'express';
 import md5 from 'md5';
-import { type RateLimiterRes } from 'rate-limiter-flexible';
+import type { RateLimiterRes } from 'rate-limiter-flexible';
 
 import loggerFactory from '~/utils/logger';
 
-import { DEFAULT_USERS_PER_IP_PROSPECTION, type IApiRateLimitConfig } from '../config';
+import {
+  DEFAULT_USERS_PER_IP_PROSPECTION,
+  type IApiRateLimitConfig,
+} from '../config';
 import { generateApiRateLimitConfig } from '../utils/config-generator';
 
 import { consumePoints } from './consume-points';
@@ -22,10 +25,11 @@ const apiRateLimitConfig = generateApiRateLimitConfig();
 const configWithoutRegExp = apiRateLimitConfig.withoutRegExp;
 const configWithRegExp = apiRateLimitConfig.withRegExp;
 const allRegExp = new RegExp(Object.keys(configWithRegExp).join('|'));
-const keysWithRegExp = Object.keys(configWithRegExp).map(key => new RegExp(`^${key}`));
+const keysWithRegExp = Object.keys(configWithRegExp).map(
+  (key) => new RegExp(`^${key}`),
+);
 const valuesWithRegExp = Object.values(configWithRegExp);
 
-
 /**
  * consume per user per endpoint
  * @param method
@@ -33,8 +37,10 @@ const valuesWithRegExp = Object.values(configWithRegExp);
  * @param customizedConfig
  * @returns
  */
-const consumePointsByUser = async(
-    method: string, key: string | null, customizedConfig?: IApiRateLimitConfig,
+const consumePointsByUser = async (
+  method: string,
+  key: string | null,
+  customizedConfig?: IApiRateLimitConfig,
 ): Promise<RateLimiterRes | undefined> => {
   return consumePoints(method, key, customizedConfig);
 };
@@ -46,24 +52,25 @@ const consumePointsByUser = async(
  * @param customizedConfig
  * @returns
  */
-const consumePointsByIp = async(
-    method: string, key: string | null, customizedConfig?: IApiRateLimitConfig,
+const consumePointsByIp = async (
+  method: string,
+  key: string | null,
+  customizedConfig?: IApiRateLimitConfig,
 ): Promise<RateLimiterRes | undefined> => {
-  const maxRequestsMultiplier = customizedConfig?.usersPerIpProspection ?? DEFAULT_USERS_PER_IP_PROSPECTION;
+  const maxRequestsMultiplier =
+    customizedConfig?.usersPerIpProspection ?? DEFAULT_USERS_PER_IP_PROSPECTION;
   return consumePoints(method, key, customizedConfig, maxRequestsMultiplier);
 };
 
-
 export const middlewareFactory = (): Handler => {
-
-  return async(req: Request & { user?: IUserHasId }, res, next) => {
-
+  return async (req: Request & { user?: IUserHasId }, res, next) => {
     const endpoint = req.path;
 
     // determine keys
-    const keyForUser: string | null = req.user != null
-      ? md5(`${req.user._id}_${endpoint}_${req.method}`)
-      : null;
+    const keyForUser: string | null =
+      req.user != null
+        ? md5(`${req.user._id}_${endpoint}_${req.method}`)
+        : null;
     const keyForIp: string = md5(`${req.ip}_${endpoint}_${req.method}`);
 
     // determine customized config
@@ -71,8 +78,7 @@ export const middlewareFactory = (): Handler => {
     const configForEndpoint = configWithoutRegExp[endpoint];
     if (configForEndpoint) {
       customizedConfig = configForEndpoint;
-    }
-    else if (allRegExp.test(endpoint)) {
+    } else if (allRegExp.test(endpoint)) {
       keysWithRegExp.forEach((key, index) => {
         if (key.test(endpoint)) {
           customizedConfig = valuesWithRegExp[index];
@@ -84,8 +90,7 @@ export const middlewareFactory = (): Handler => {
     if (req.user != null) {
       try {
         await consumePointsByUser(req.method, keyForUser, customizedConfig);
-      }
-      catch {
+      } catch {
         logger.error(`${req.user._id}: too many request at ${endpoint}`);
         return res.sendStatus(429);
       }
@@ -94,8 +99,7 @@ export const middlewareFactory = (): Handler => {
     // check for ip
     try {
       await consumePointsByIp(req.method, keyForIp, customizedConfig);
-    }
-    catch {
+    } catch {
       logger.error(`${req.ip}: too many request at ${endpoint}`);
       return res.sendStatus(429);
     }

+ 4 - 3
apps/app/src/features/rate-limiter/middleware/rate-limiter-factory.ts

@@ -1,10 +1,12 @@
 import { connection } from 'mongoose';
-import { type IRateLimiterMongoOptions, RateLimiterMongo } from 'rate-limiter-flexible';
+import {
+  type IRateLimiterMongoOptions,
+  RateLimiterMongo,
+} from 'rate-limiter-flexible';
 
 import { DEFAULT_DURATION_SEC } from '../config';
 
 class RateLimiterFactory {
-
   private rateLimiters: Map<string, RateLimiterMongo> = new Map();
 
   getOrCreateRateLimiter(key: string, maxRequests: number): RateLimiterMongo {
@@ -24,7 +26,6 @@ class RateLimiterFactory {
 
     return rateLimiter;
   }
-
 }
 
 export const rateLimiterFactory = new RateLimiterFactory();

+ 30 - 17
apps/app/src/features/rate-limiter/utils/config-generator.ts

@@ -1,18 +1,21 @@
 import type { IApiRateLimitEndpointMap } from '../config';
-import {
-  defaultConfig, defaultConfigWithRegExp,
-} from '../config';
+import { defaultConfig, defaultConfigWithRegExp } from '../config';
 
 const envVar = process.env;
 
 // https://regex101.com/r/aNDjmI/1
 const regExp = /^API_RATE_LIMIT_(\w+)_ENDPOINT(_WITH_REGEXP)?$/;
 
-const generateApiRateLimitConfigFromEndpoint = (envVar: NodeJS.ProcessEnv, targets: string[], withRegExp: boolean): IApiRateLimitEndpointMap => {
+const generateApiRateLimitConfigFromEndpoint = (
+  envVar: NodeJS.ProcessEnv,
+  targets: string[],
+  withRegExp: boolean,
+): IApiRateLimitEndpointMap => {
   const apiRateLimitConfig: IApiRateLimitEndpointMap = {};
   targets.forEach((target) => {
-
-    const endpointKey = withRegExp ? `API_RATE_LIMIT_${target}_ENDPOINT_WITH_REGEXP` : `API_RATE_LIMIT_${target}_ENDPOINT`;
+    const endpointKey = withRegExp
+      ? `API_RATE_LIMIT_${target}_ENDPOINT_WITH_REGEXP`
+      : `API_RATE_LIMIT_${target}_ENDPOINT`;
 
     const endpoint = envVar[endpointKey];
 
@@ -43,26 +46,26 @@ const generateApiRateLimitConfigFromEndpoint = (envVar: NodeJS.ProcessEnv, targe
 };
 
 type ApiRateLimitConfigResult = {
-  'withoutRegExp': IApiRateLimitEndpointMap,
-  'withRegExp': IApiRateLimitEndpointMap
-}
+  withoutRegExp: IApiRateLimitEndpointMap;
+  withRegExp: IApiRateLimitEndpointMap;
+};
 
 export const generateApiRateLimitConfig = (): ApiRateLimitConfigResult => {
-
   const apiRateConfigTargets: string[] = [];
   const apiRateConfigTargetsWithRegExp: string[] = [];
   Object.keys(envVar).forEach((key) => {
     const result = key.match(regExp);
 
-    if (result == null) { return null }
+    if (result == null) {
+      return null;
+    }
 
     const target = result[1];
     const isWithRegExp = result[2] != null;
 
     if (isWithRegExp) {
       apiRateConfigTargetsWithRegExp.push(target);
-    }
-    else {
+    } else {
       apiRateConfigTargets.push(target);
     }
   });
@@ -72,17 +75,27 @@ export const generateApiRateLimitConfig = (): ApiRateLimitConfigResult => {
   apiRateConfigTargetsWithRegExp.sort();
 
   // get config
-  const apiRateLimitConfig = generateApiRateLimitConfigFromEndpoint(envVar, apiRateConfigTargets, false);
-  const apiRateLimitConfigWithRegExp = generateApiRateLimitConfigFromEndpoint(envVar, apiRateConfigTargetsWithRegExp, true);
+  const apiRateLimitConfig = generateApiRateLimitConfigFromEndpoint(
+    envVar,
+    apiRateConfigTargets,
+    false,
+  );
+  const apiRateLimitConfigWithRegExp = generateApiRateLimitConfigFromEndpoint(
+    envVar,
+    apiRateConfigTargetsWithRegExp,
+    true,
+  );
 
   const config = { ...defaultConfig, ...apiRateLimitConfig };
-  const configWithRegExp = { ...defaultConfigWithRegExp, ...apiRateLimitConfigWithRegExp };
+  const configWithRegExp = {
+    ...defaultConfigWithRegExp,
+    ...apiRateLimitConfigWithRegExp,
+  };
 
   const result: ApiRateLimitConfigResult = {
     withoutRegExp: config,
     withRegExp: configWithRegExp,
   };
 
-
   return result;
 };

+ 1 - 1
apps/app/src/server/service/customize.ts

@@ -36,7 +36,7 @@ class CustomizeService implements S2sMessageHandlable {
 
   theme: string;
 
-  themeHref: string;
+  themeHref: string | undefined;
 
   forcedColorScheme?: ColorScheme;
 

+ 1 - 1
apps/app/src/server/service/page-grant.ts

@@ -729,7 +729,7 @@ class PageGrantService implements IPageGrantService {
   /*
    * get all groups that user is related to
    */
-  async getUserRelatedGroups(user?: IUserHasId | null): Promise<PopulatedGrantedGroup[]> {
+  async getUserRelatedGroups(user?: IUserHasId | HydratedDocument<IUser> | null): Promise<PopulatedGrantedGroup[]> {
     if (user == null) {
       return [];
     }

+ 19 - 0
apps/app/src/server/service/page/page-service.ts

@@ -49,4 +49,23 @@ export interface IPageService {
     page: PageDocument, creatorId: ObjectIdLike | null, operator: any | null, userRelatedGroups: PopulatedGrantedGroup[]
   ): boolean,
   getYjsData(pageId: string, revisionBody?: string): Promise<CurrentPageYjsData>,
+  updateDescendantCountOfPagesWithPaths(paths: string[]): Promise<void>,
+  revertRecursivelyMainOperation(page, user, options, pageOpId: ObjectIdLike, activity?): Promise<void>,
+  revertDeletedPage(page, user, options, isRecursively: boolean, activityParameters?),
+  deleteCompletelyRecursivelyMainOperation(page, user, options, pageOpId: ObjectIdLike, activity?): Promise<void>,
+  deleteCompletely(page, user, options, isRecursively: boolean, preventEmitting: boolean, activityParameters),
+  deleteRecursivelyMainOperation(page, user, pageOpId: ObjectIdLike, activity?): Promise<void>,
+  deletePage(page, user, options, isRecursively: boolean, activityParameters),
+  duplicateRecursivelyMainOperation(
+    page: PageDocument,
+    newPagePath: string,
+    user,
+    pageOpId: ObjectIdLike,
+    onlyDuplicateUserRelatedResources: boolean,
+  ): Promise<void>,
+  duplicate(page: PageDocument, newPagePath: string, user, isRecursively: boolean, onlyDuplicateUserRelatedResources: boolean),
+  renameSubOperation(page, newPagePath: string, user, options, renamedPage, pageOpId: ObjectIdLike, activity?): Promise<void>,
+  renamePage(page: IPage, newPagePath, user, options, activityParameters): Promise<PageDocument | null>,
+  renameMainOperation(page, newPagePath: string, user, options, pageOpId: ObjectIdLike, activity?): Promise<PageDocument | null>,
+  createSubOperation(page, user, options: IOptionsForCreate, pageOpId: ObjectIdLike): Promise<void>,
 }

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

@@ -2,11 +2,15 @@ import type { IUserHasId } from '@growi/core';
 import mongoose, { Types } from 'mongoose';
 
 import {
-  ExternalGroupProviderType, ExternalUserGroupTreeNode, IExternalUserGroup, IExternalUserGroupHasId,
+  ExternalGroupProviderType,
+  type ExternalUserGroupTreeNode,
+  type IExternalUserGroup,
+  type IExternalUserGroupHasId,
 } from '../../../src/features/external-user-group/interfaces/external-user-group';
 import ExternalUserGroup from '../../../src/features/external-user-group/server/models/external-user-group';
 import ExternalUserGroupRelation from '../../../src/features/external-user-group/server/models/external-user-group-relation';
 import ExternalUserGroupSyncService from '../../../src/features/external-user-group/server/service/external-user-group-sync';
+import type Crowi from '../../../src/server/crowi';
 import ExternalAccount from '../../../src/server/models/external-account';
 import { configManager } from '../../../src/server/service/config-manager';
 import instanciateExternalAccountService from '../../../src/server/service/external-account';
@@ -15,7 +19,6 @@ import { getInstance } from '../setup-crowi';
 
 // dummy class to implement generateExternalUserGroupTrees which returns test data
 class TestExternalUserGroupSyncService extends ExternalUserGroupSyncService {
-
   constructor(s2sMessagingService, socketIoService) {
     super('ldap', s2sMessagingService, socketIoService);
     this.authProviderType = ExternalGroupProviderType.ldap;
@@ -24,12 +27,14 @@ class TestExternalUserGroupSyncService extends ExternalUserGroupSyncService {
   async generateExternalUserGroupTrees(): Promise<ExternalUserGroupTreeNode[]> {
     const childNode: ExternalUserGroupTreeNode = {
       id: 'cn=childGroup,ou=groups,dc=example,dc=org',
-      userInfos: [{
-        id: 'childGroupUser',
-        username: 'childGroupUser',
-        name: 'Child Group User',
-        email: 'user@childgroup.com',
-      }],
+      userInfos: [
+        {
+          id: 'childGroupUser',
+          username: 'childGroupUser',
+          name: 'Child Group User',
+          email: 'user@childgroup.com',
+        },
+      ],
       childGroupNodes: [],
       name: 'childGroup',
       description: 'this is a child group',
@@ -37,11 +42,13 @@ class TestExternalUserGroupSyncService extends ExternalUserGroupSyncService {
     const parentNode: ExternalUserGroupTreeNode = {
       id: 'cn=parentGroup,ou=groups,dc=example,dc=org',
       // name is undefined
-      userInfos: [{
-        id: 'parentGroupUser',
-        username: 'parentGroupUser',
-        email: 'user@parentgroup.com',
-      }],
+      userInfos: [
+        {
+          id: 'parentGroupUser',
+          username: 'parentGroupUser',
+          email: 'user@parentgroup.com',
+        },
+      ],
       childGroupNodes: [childNode],
       name: 'parentGroup',
       description: 'this is a parent group',
@@ -49,11 +56,13 @@ class TestExternalUserGroupSyncService extends ExternalUserGroupSyncService {
     const grandParentNode: ExternalUserGroupTreeNode = {
       id: 'cn=grandParentGroup,ou=groups,dc=example,dc=org',
       // email is undefined
-      userInfos: [{
-        id: 'grandParentGroupUser',
-        username: 'grandParentGroupUser',
-        name: 'Grand Parent Group User',
-      }],
+      userInfos: [
+        {
+          id: 'grandParentGroupUser',
+          username: 'grandParentGroupUser',
+          name: 'Grand Parent Group User',
+        },
+      ],
       childGroupNodes: [parentNode],
       name: 'grandParentGroup',
       description: 'this is a grand parent group',
@@ -61,12 +70,14 @@ class TestExternalUserGroupSyncService extends ExternalUserGroupSyncService {
 
     const previouslySyncedNode: ExternalUserGroupTreeNode = {
       id: 'cn=previouslySyncedGroup,ou=groups,dc=example,dc=org',
-      userInfos: [{
-        id: 'previouslySyncedGroupUser',
-        username: 'previouslySyncedGroupUser',
-        name: 'Root Group User',
-        email: 'user@previouslySyncedgroup.com',
-      }],
+      userInfos: [
+        {
+          id: 'previouslySyncedGroupUser',
+          username: 'previouslySyncedGroupUser',
+          name: 'Root Group User',
+          email: 'user@previouslySyncedgroup.com',
+        },
+      ],
       childGroupNodes: [],
       name: 'previouslySyncedGroup',
       description: 'this is a previouslySynced group',
@@ -74,12 +85,14 @@ class TestExternalUserGroupSyncService extends ExternalUserGroupSyncService {
 
     return [grandParentNode, previouslySyncedNode];
   }
-
 }
 
 const testService = new TestExternalUserGroupSyncService(null, null);
 
-const checkGroup = (group: IExternalUserGroupHasId, expected: Omit<IExternalUserGroup, 'createdAt'>) => {
+const checkGroup = (
+  group: IExternalUserGroupHasId,
+  expected: Omit<IExternalUserGroup, 'createdAt'>,
+) => {
   const actual = {
     name: group.name,
     parent: group.parent,
@@ -90,8 +103,10 @@ const checkGroup = (group: IExternalUserGroupHasId, expected: Omit<IExternalUser
   expect(actual).toStrictEqual(expected);
 };
 
-const checkSync = async(autoGenerateUserOnGroupSync = true) => {
-  const grandParentGroup = await ExternalUserGroup.findOne({ name: 'grandParentGroup' });
+const checkSync = async (autoGenerateUserOnGroupSync = true) => {
+  const grandParentGroup = await ExternalUserGroup.findOne({
+    name: 'grandParentGroup',
+  });
   checkGroup(grandParentGroup, {
     externalId: 'cn=grandParentGroup,ou=groups,dc=example,dc=org',
     name: 'grandParentGroup',
@@ -118,7 +133,9 @@ const checkSync = async(autoGenerateUserOnGroupSync = true) => {
     parent: parentGroup._id,
   });
 
-  const previouslySyncedGroup = await ExternalUserGroup.findOne({ name: 'previouslySyncedGroup' });
+  const previouslySyncedGroup = await ExternalUserGroup.findOne({
+    name: 'previouslySyncedGroup',
+  });
   checkGroup(previouslySyncedGroup, {
     externalId: 'cn=previouslySyncedGroup,ou=groups,dc=example,dc=org',
     name: 'previouslySyncedGroup',
@@ -127,49 +144,79 @@ const checkSync = async(autoGenerateUserOnGroupSync = true) => {
     parent: null,
   });
 
-  const grandParentGroupRelations = await ExternalUserGroupRelation
-    .find({ relatedGroup: grandParentGroup._id });
-  const parentGroupRelations = await ExternalUserGroupRelation
-    .find({ relatedGroup: parentGroup._id });
-  const childGroupRelations = await ExternalUserGroupRelation
-    .find({ relatedGroup: childGroup._id });
-  const previouslySyncedGroupRelations = await ExternalUserGroupRelation
-    .find({ relatedGroup: previouslySyncedGroup._id });
+  const grandParentGroupRelations = await ExternalUserGroupRelation.find({
+    relatedGroup: grandParentGroup._id,
+  });
+  const parentGroupRelations = await ExternalUserGroupRelation.find({
+    relatedGroup: parentGroup._id,
+  });
+  const childGroupRelations = await ExternalUserGroupRelation.find({
+    relatedGroup: childGroup._id,
+  });
+  const previouslySyncedGroupRelations = await ExternalUserGroupRelation.find({
+    relatedGroup: previouslySyncedGroup._id,
+  });
 
   if (autoGenerateUserOnGroupSync) {
     expect(grandParentGroupRelations.length).toBe(3);
-    const populatedGrandParentGroupRelations = await Promise.all(grandParentGroupRelations.map((relation) => {
-      return relation.populate<{relatedUser: IUserHasId}>('relatedUser');
-    }));
-    expect(populatedGrandParentGroupRelations[0].relatedUser.username).toBe('grandParentGroupUser');
-    expect(populatedGrandParentGroupRelations[1].relatedUser.username).toBe('parentGroupUser');
-    expect(populatedGrandParentGroupRelations[2].relatedUser.username).toBe('childGroupUser');
+    const populatedGrandParentGroupRelations = await Promise.all(
+      grandParentGroupRelations.map((relation) => {
+        return relation.populate<{ relatedUser: IUserHasId }>('relatedUser');
+      }),
+    );
+    expect(populatedGrandParentGroupRelations[0].relatedUser.username).toBe(
+      'grandParentGroupUser',
+    );
+    expect(populatedGrandParentGroupRelations[1].relatedUser.username).toBe(
+      'parentGroupUser',
+    );
+    expect(populatedGrandParentGroupRelations[2].relatedUser.username).toBe(
+      'childGroupUser',
+    );
 
     expect(parentGroupRelations.length).toBe(2);
-    const populatedParentGroupRelations = await Promise.all(parentGroupRelations.map((relation) => {
-      return relation.populate<{relatedUser: IUserHasId}>('relatedUser');
-    }));
-    expect(populatedParentGroupRelations[0].relatedUser.username).toBe('parentGroupUser');
-    expect(populatedParentGroupRelations[1].relatedUser.username).toBe('childGroupUser');
+    const populatedParentGroupRelations = await Promise.all(
+      parentGroupRelations.map((relation) => {
+        return relation.populate<{ relatedUser: IUserHasId }>('relatedUser');
+      }),
+    );
+    expect(populatedParentGroupRelations[0].relatedUser.username).toBe(
+      'parentGroupUser',
+    );
+    expect(populatedParentGroupRelations[1].relatedUser.username).toBe(
+      'childGroupUser',
+    );
 
     expect(childGroupRelations.length).toBe(1);
-    const childGroupUser = (await childGroupRelations[0].populate<{relatedUser: IUserHasId}>('relatedUser'))?.relatedUser;
+    const childGroupUser = (
+      await childGroupRelations[0].populate<{ relatedUser: IUserHasId }>(
+        'relatedUser',
+      )
+    )?.relatedUser;
     expect(childGroupUser?.username).toBe('childGroupUser');
 
     expect(previouslySyncedGroupRelations.length).toBe(1);
-    const previouslySyncedGroupUser = (await previouslySyncedGroupRelations[0].populate<{relatedUser: IUserHasId}>('relatedUser'))?.relatedUser;
-    expect(previouslySyncedGroupUser?.username).toBe('previouslySyncedGroupUser');
+    const previouslySyncedGroupUser = (
+      await previouslySyncedGroupRelations[0].populate<{
+        relatedUser: IUserHasId;
+      }>('relatedUser')
+    )?.relatedUser;
+    expect(previouslySyncedGroupUser?.username).toBe(
+      'previouslySyncedGroupUser',
+    );
 
     const userPages = await mongoose.model('Page').find({
       path: {
         $in: [
-          '/user/childGroupUser', '/user/parentGroupUser', '/user/grandParentGroupUser', '/user/previouslySyncedGroupUser',
+          '/user/childGroupUser',
+          '/user/parentGroupUser',
+          '/user/grandParentGroupUser',
+          '/user/previouslySyncedGroupUser',
         ],
       },
     });
     expect(userPages.length).toBe(4);
-  }
-  else {
+  } else {
     expect(grandParentGroupRelations.length).toBe(0);
     expect(parentGroupRelations.length).toBe(0);
     expect(childGroupRelations.length).toBe(0);
@@ -178,16 +225,16 @@ const checkSync = async(autoGenerateUserOnGroupSync = true) => {
 };
 
 describe('ExternalUserGroupSyncService.syncExternalUserGroups', () => {
-  let crowi;
+  let crowi: Crowi;
 
-  beforeAll(async() => {
+  beforeAll(async () => {
     crowi = await getInstance();
     await configManager.updateConfig('app:isV5Compatible', true);
     const passportService = new PassportService(crowi);
     instanciateExternalAccountService(passportService);
   });
 
-  beforeEach(async() => {
+  beforeEach(async () => {
     await ExternalUserGroup.create({
       name: 'nameBeforeEdit',
       description: 'this is a description before edit',
@@ -196,16 +243,36 @@ describe('ExternalUserGroupSyncService.syncExternalUserGroups', () => {
     });
   });
 
-  afterEach(async() => {
+  afterEach(async () => {
     await ExternalUserGroup.deleteMany();
     await ExternalUserGroupRelation.deleteMany();
-    await mongoose.model('User')
-      .deleteMany({ username: { $in: ['childGroupUser', 'parentGroupUser', 'grandParentGroupUser', 'previouslySyncedGroupUser'] } });
-    await ExternalAccount.deleteMany({ accountId: { $in: ['childGroupUser', 'parentGroupUser', 'grandParentGroupUser', 'previouslySyncedGroupUser'] } });
+    await mongoose.model('User').deleteMany({
+      username: {
+        $in: [
+          'childGroupUser',
+          'parentGroupUser',
+          'grandParentGroupUser',
+          'previouslySyncedGroupUser',
+        ],
+      },
+    });
+    await ExternalAccount.deleteMany({
+      accountId: {
+        $in: [
+          'childGroupUser',
+          'parentGroupUser',
+          'grandParentGroupUser',
+          'previouslySyncedGroupUser',
+        ],
+      },
+    });
     await mongoose.model('Page').deleteMany({
       path: {
         $in: [
-          '/user/childGroupUser', '/user/parentGroupUser', '/user/grandParentGroupUser', '/user/previouslySyncedGroupUser',
+          '/user/childGroupUser',
+          '/user/parentGroupUser',
+          '/user/grandParentGroupUser',
+          '/user/previouslySyncedGroupUser',
         ],
       },
     });
@@ -217,12 +284,12 @@ describe('ExternalUserGroupSyncService.syncExternalUserGroups', () => {
       'external-user-group:ldap:preserveDeletedGroups': false,
     };
 
-    beforeAll(async() => {
+    beforeAll(async () => {
       await configManager.updateConfigs(configParams);
     });
 
     // eslint-disable-next-line jest/expect-expect
-    it('syncs groups with new users', async() => {
+    it('syncs groups with new users', async () => {
       await testService.syncExternalUserGroups();
       await checkSync();
     });
@@ -234,12 +301,12 @@ describe('ExternalUserGroupSyncService.syncExternalUserGroups', () => {
       'external-user-group:ldap:preserveDeletedGroups': true,
     };
 
-    beforeAll(async() => {
+    beforeAll(async () => {
       await configManager.updateConfigs(configParams);
     });
 
     // eslint-disable-next-line jest/expect-expect
-    it('syncs groups without new users', async() => {
+    it('syncs groups without new users', async () => {
       await testService.syncExternalUserGroups();
       await checkSync(false);
     });
@@ -251,7 +318,7 @@ describe('ExternalUserGroupSyncService.syncExternalUserGroups', () => {
       'external-user-group:ldap:preserveDeletedGroups': false,
     };
 
-    beforeAll(async() => {
+    beforeAll(async () => {
       await configManager.updateConfigs(configParams);
 
       const groupId = new Types.ObjectId();
@@ -263,11 +330,16 @@ describe('ExternalUserGroupSyncService.syncExternalUserGroups', () => {
         externalId: 'cn=nonExistentGroup,ou=groups,dc=example,dc=org',
         provider: 'ldap',
       });
-      await mongoose.model('User').create({ _id: userId, username: 'nonExistentGroupUser' });
-      await ExternalUserGroupRelation.create({ relatedUser: userId, relatedGroup: groupId });
+      await mongoose
+        .model('User')
+        .create({ _id: userId, username: 'nonExistentGroupUser' });
+      await ExternalUserGroupRelation.create({
+        relatedUser: userId,
+        relatedGroup: groupId,
+      });
     });
 
-    it('syncs groups and deletes groups that do not exist externally', async() => {
+    it('syncs groups and deletes groups that do not exist externally', async () => {
       await testService.syncExternalUserGroups();
       await checkSync();
       expect(await ExternalUserGroup.countDocuments()).toBe(4);

+ 79 - 44
apps/app/test/integration/service/ldap-user-group-sync.test.ts

@@ -1,13 +1,14 @@
-import ldap, { Client } from 'ldapjs';
+import ldap, { type Client } from 'ldapjs';
 
 import { LdapUserGroupSyncService } from '../../../src/features/external-user-group/server/service/ldap-user-group-sync';
+import type Crowi from '../../../src/server/crowi';
 import { configManager } from '../../../src/server/service/config-manager';
 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 crowi: Crowi;
   let ldapUserGroupSyncService: LdapUserGroupSyncService;
 
   const configParams = {
@@ -18,7 +19,8 @@ describe('LdapUserGroupSyncService.generateExternalUserGroupTrees', () => {
     'external-user-group:ldap:groupDescriptionAttribute': 'description',
     'external-user-group:ldap:groupMembershipAttributeType': 'DN',
     'external-user-group:ldap:groupSearchBase': 'ou=groups,dc=example,dc=org',
-    'security:passport-ldap:serverUrl': 'ldap://openldap:1389/dc=example,dc=org',
+    'security:passport-ldap:serverUrl':
+      'ldap://openldap:1389/dc=example,dc=org',
   };
 
   jest.mock('../../../src/server/service/ldap');
@@ -26,25 +28,31 @@ describe('LdapUserGroupSyncService.generateExternalUserGroupTrees', () => {
   const mockLdapSearch = jest.spyOn(ldapService, 'search');
   const mockLdapCreateClient = jest.spyOn(ldap, 'createClient');
 
-  beforeAll(async() => {
+  beforeAll(async () => {
     crowi = await getInstance();
     await configManager.updateConfigs(configParams, { skipPubsub: true });
 
     mockBind.mockImplementation(() => {
       return Promise.resolve();
     });
-    mockLdapCreateClient.mockImplementation(() => { return {} as Client });
+    mockLdapCreateClient.mockImplementation(() => {
+      return {} as Client;
+    });
 
     const passportService = new PassportService(crowi);
-    ldapUserGroupSyncService = new LdapUserGroupSyncService(passportService, null, null);
+    ldapUserGroupSyncService = new LdapUserGroupSyncService(
+      passportService,
+      null,
+      null,
+    );
   });
 
   describe('When there is no circular reference in group tree', () => {
-    it('creates ExternalUserGroupTrees', async() => {
+    it('creates ExternalUserGroupTrees', async () => {
       // mock search on LDAP server
       mockLdapSearch.mockImplementation((filter, base) => {
         if (base === 'ou=groups,dc=example,dc=org') {
-        // search groups
+          // search groups
           return Promise.resolve([
             {
               objectName: 'cn=childGroup,ou=groups,dc=example,dc=org',
@@ -64,7 +72,10 @@ describe('LdapUserGroupSyncService.generateExternalUserGroupTrees', () => {
                 { type: 'description', values: ['this is a parent group'] },
                 {
                   type: 'member',
-                  values: ['cn=childGroup,ou=groups,dc=example,dc=org', 'cn=parentGroupUser,ou=users,dc=example,dc=org'],
+                  values: [
+                    'cn=childGroup,ou=groups,dc=example,dc=org',
+                    'cn=parentGroupUser,ou=users,dc=example,dc=org',
+                  ],
                 },
               ],
             },
@@ -73,10 +84,16 @@ describe('LdapUserGroupSyncService.generateExternalUserGroupTrees', () => {
               objectName: 'cn=grandParentGroup,ou=groups,dc=example,dc=org',
               attributes: [
                 { type: 'cn', values: ['grandParentGroup'] },
-                { type: 'description', values: ['this is a grand parent group'] },
+                {
+                  type: 'description',
+                  values: ['this is a grand parent group'],
+                },
                 {
                   type: 'member',
-                  values: ['cn=parentGroup,ou=groups,dc=example,dc=org', 'cn=grandParentGroupUser,ou=users,dc=example,dc=org'],
+                  values: [
+                    'cn=parentGroup,ou=groups,dc=example,dc=org',
+                    'cn=grandParentGroupUser,ou=users,dc=example,dc=org',
+                  ],
                 },
               ],
             },
@@ -95,7 +112,7 @@ describe('LdapUserGroupSyncService.generateExternalUserGroupTrees', () => {
           ]);
         }
         if (base === 'cn=childGroupUser,ou=users,dc=example,dc=org') {
-        // search childGroupUser
+          // search childGroupUser
           return Promise.resolve([
             {
               objectName: 'cn=childGroupUser,ou=users,dc=example,dc=org',
@@ -149,44 +166,53 @@ describe('LdapUserGroupSyncService.generateExternalUserGroupTrees', () => {
         return Promise.reject(new Error('not found'));
       });
 
-      const rootNodes = await ldapUserGroupSyncService?.generateExternalUserGroupTrees();
+      const rootNodes =
+        await ldapUserGroupSyncService?.generateExternalUserGroupTrees();
 
       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: [{
-          id: 'childGroupUser',
-          username: 'childGroupUser',
-          name: 'Child Group User',
-          email: 'user@childGroup.com',
-        }],
+        userInfos: [
+          {
+            id: 'childGroupUser',
+            username: 'childGroupUser',
+            name: 'Child Group User',
+            email: 'user@childGroup.com',
+          },
+        ],
         childGroupNodes: [],
         name: 'childGroup',
         description: 'this is a child group',
       };
       const expectedParentNode = {
         id: 'cn=parentGroup,ou=groups,dc=example,dc=org',
-        userInfos: [{
-          id: 'parentGroupUser',
-          username: 'parentGroupUser',
-          name: 'Parent Group User',
-          email: 'user@parentGroup.com',
-        }],
+        userInfos: [
+          {
+            id: 'parentGroupUser',
+            username: 'parentGroupUser',
+            name: 'Parent Group User',
+            email: 'user@parentGroup.com',
+          },
+        ],
         childGroupNodes: [expectedChildNode],
         name: 'parentGroup',
         description: 'this is a parent group',
       };
       const expectedGrandParentNode = {
         id: 'cn=grandParentGroup,ou=groups,dc=example,dc=org',
-        userInfos: [{
-          id: 'grandParentGroupUser',
-          username: 'grandParentGroupUser',
-          name: 'Grand Parent Group User',
-          email: 'user@grandParentGroup.com',
-        }],
+        userInfos: [
+          {
+            id: 'grandParentGroupUser',
+            username: 'grandParentGroupUser',
+            name: 'Grand Parent Group User',
+            email: 'user@grandParentGroup.com',
+          },
+        ],
         childGroupNodes: [expectedParentNode],
         name: 'grandParentGroup',
         description: 'this is a grand parent group',
@@ -194,15 +220,19 @@ 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: [{
-          id: 'rootGroupUser',
-          username: 'rootGroupUser',
-          name: 'Root Group User',
-          email: 'user@rootGroup.com',
-        }],
+        userInfos: [
+          {
+            id: 'rootGroupUser',
+            username: 'rootGroupUser',
+            name: 'Root Group User',
+            email: 'user@rootGroup.com',
+          },
+        ],
         childGroupNodes: [],
         name: 'rootGroup',
         description: 'this is a root group',
@@ -212,13 +242,13 @@ describe('LdapUserGroupSyncService.generateExternalUserGroupTrees', () => {
   });
 
   describe('When there is a circular reference in group tree', () => {
-    it('rejects creating ExternalUserGroupTrees', async() => {
+    it('rejects creating ExternalUserGroupTrees', async () => {
       // mock search on LDAP server
       mockLdapSearch.mockImplementation((filter, base) => {
         if (base === 'ou=groups,dc=example,dc=org') {
-        // search groups
+          // search groups
           return Promise.resolve([
-          // childGroup and parentGroup have circular reference
+            // childGroup and parentGroup have circular reference
             {
               objectName: 'cn=childGroup,ou=groups,dc=example,dc=org',
               attributes: [
@@ -245,7 +275,10 @@ describe('LdapUserGroupSyncService.generateExternalUserGroupTrees', () => {
               objectName: 'cn=grandParentGroup,ou=groups,dc=example,dc=org',
               attributes: [
                 { type: 'cn', values: ['grandParentGroup'] },
-                { type: 'description', values: ['this is a grand parent group'] },
+                {
+                  type: 'description',
+                  values: ['this is a grand parent group'],
+                },
                 {
                   type: 'member',
                   values: ['cn=parentGroup,ou=groups,dc=example,dc=org'],
@@ -257,7 +290,9 @@ describe('LdapUserGroupSyncService.generateExternalUserGroupTrees', () => {
         return Promise.reject(new Error('not found'));
       });
 
-      await expect(ldapUserGroupSyncService?.generateExternalUserGroupTrees()).rejects.toThrow('Circular reference inside LDAP group tree');
+      await expect(
+        ldapUserGroupSyncService?.generateExternalUserGroupTrees(),
+      ).rejects.toThrow('Circular reference inside LDAP group tree');
     });
   });
 });

Разница между файлами не показана из-за своего большого размера
+ 437 - 231
apps/app/test/integration/service/page-grant.test.ts


Разница между файлами не показана из-за своего большого размера
+ 488 - 188
apps/app/test/integration/service/page.test.js


+ 45 - 23
apps/app/test/integration/service/search/search-service.test.js

@@ -3,9 +3,8 @@
  */
 
 import mongoose from 'mongoose';
-
-import SearchService from '~/server/service/search';
 import NamedQuery from '~/server/models/named-query';
+import SearchService from '~/server/service/search';
 
 const { getInstance } = require('../../setup-crowi');
 
@@ -29,7 +28,7 @@ describe('SearchService test', () => {
     },
   };
 
-  beforeAll(async() => {
+  beforeAll(async () => {
     crowi = await getInstance();
     searchService = new SearchService(crowi);
     searchService.nqDelegators = {
@@ -37,7 +36,8 @@ describe('SearchService test', () => {
       [DEFAULT]: dummyFullTextSearchDelegator, // override with dummy full-text search delegator
     };
 
-    dummyAliasOf = 'match -notmatch "phrase" -"notphrase" prefix:/pre1 -prefix:/pre2 tag:Tag1 -tag:Tag2';
+    dummyAliasOf =
+      'match -notmatch "phrase" -"notphrase" prefix:/pre1 -prefix:/pre2 tag:Tag1 -tag:Tag2';
 
     await NamedQuery.insertMany([
       { name: 'named_query1', delegatorName: PRIVATE_LEGACY_PAGES },
@@ -48,13 +48,14 @@ describe('SearchService test', () => {
     namedQuery2 = await NamedQuery.findOne({ name: 'named_query2' });
   });
 
-
   describe('parseQueryString()', () => {
-    test('should parse queryString', async() => {
-      const queryString = 'match -notmatch "phrase" -"notphrase" prefix:/pre1 -prefix:/pre2 tag:Tag1 -tag:Tag2';
+    test('should parse queryString', async () => {
+      const queryString =
+        'match -notmatch "phrase" -"notphrase" prefix:/pre1 -prefix:/pre2 tag:Tag1 -tag:Tag2';
       const terms = await searchService.parseQueryString(queryString);
 
-      const expected = { // QueryTerms
+      const expected = {
+        // QueryTerms
         match: ['match'],
         not_match: ['notmatch'],
         phrase: ['"phrase"'],
@@ -70,11 +71,13 @@ describe('SearchService test', () => {
   });
 
   describe('parseSearchQuery()', () => {
-
-    test('should return result with delegatorName', async() => {
+    test('should return result with delegatorName', async () => {
       const queryString = '/';
       const nqName = 'named_query1';
-      const parsedQuery = await searchService.parseSearchQuery(queryString, nqName);
+      const parsedQuery = await searchService.parseSearchQuery(
+        queryString,
+        nqName,
+      );
 
       const expected = {
         queryString,
@@ -94,10 +97,13 @@ describe('SearchService test', () => {
       expect(parsedQuery).toStrictEqual(expected);
     });
 
-    test('should return result with expanded aliasOf value', async() => {
+    test('should return result with expanded aliasOf value', async () => {
       const queryString = '/';
       const nqName = 'named_query2';
-      const parsedQuery = await searchService.parseSearchQuery(queryString, nqName);
+      const parsedQuery = await searchService.parseSearchQuery(
+        queryString,
+        nqName,
+      );
       const expected = {
         queryString: dummyAliasOf,
         terms: {
@@ -117,7 +123,7 @@ describe('SearchService test', () => {
   });
 
   describe('resolve()', () => {
-    test('should resolve as full-text search delegator', async() => {
+    test('should resolve as full-text search delegator', async () => {
       const parsedQuery = {
         queryString: dummyAliasOf,
         terms: {
@@ -140,7 +146,7 @@ describe('SearchService test', () => {
       expect(typeof delegator.search).toBe('function');
     });
 
-    test('should resolve as custom search delegator', async() => {
+    test('should resolve as custom search delegator', async () => {
       const queryString = '/';
       const parsedQuery = {
         queryString,
@@ -170,12 +176,20 @@ describe('SearchService test', () => {
   });
 
   describe('searchKeyword()', () => {
-    test('should search with custom search delegator', async() => {
+    test('should search with custom search delegator', async () => {
       const Page = mongoose.model('Page');
       const User = mongoose.model('User');
       await User.insertMany([
-        { name: 'dummyuser1', username: 'dummyuser1', email: 'dummyuser1@example.com' },
-        { name: 'dummyuser2', username: 'dummyuser2', email: 'dummyuser2@example.com' },
+        {
+          name: 'dummyuser1',
+          username: 'dummyuser1',
+          email: 'dummyuser1@example.com',
+        },
+        {
+          name: 'dummyuser2',
+          username: 'dummyuser2',
+          email: 'dummyuser2@example.com',
+        },
       ]);
 
       const testUser1 = await User.findOne({ username: 'dummyuser1' });
@@ -218,14 +232,22 @@ describe('SearchService test', () => {
       const queryString = '/';
       const nqName = 'named_query1';
 
-      const [result, delegatorName] = await searchService.searchKeyword(queryString, nqName, testUser1, null, { offset: 0, limit: 100 });
-
-      const resultPaths = result.data.map(page => page.path);
-      const flag = resultPaths.includes('/user1') && resultPaths.includes('/user1_owner') && resultPaths.includes('/user2_public');
+      const [result, delegatorName] = await searchService.searchKeyword(
+        queryString,
+        nqName,
+        testUser1,
+        null,
+        { offset: 0, limit: 100 },
+      );
+
+      const resultPaths = result.data.map((page) => page.path);
+      const flag =
+        resultPaths.includes('/user1') &&
+        resultPaths.includes('/user1_owner') &&
+        resultPaths.includes('/user2_public');
 
       expect(flag).toBe(true);
       expect(delegatorName).toBe(PRIVATE_LEGACY_PAGES);
     });
   });
-
 });

+ 227 - 87
apps/app/test/integration/service/user-groups.test.ts

@@ -1,19 +1,17 @@
-
 import type { IGrantedGroup } from '@growi/core';
-import {
-  PageGrant, type IPage, GroupType, getIdForRef,
-} from '@growi/core';
+import { GroupType, getIdForRef, type IPage, PageGrant } from '@growi/core';
 import mongoose from 'mongoose';
-
+import { PageActionOnGroupDelete } from '../../../src/interfaces/user-group';
+import type Crowi from '../../../src/server/crowi';
 import type { PageDocument, PageModel } from '../../../src/server/models/page';
 import UserGroup from '../../../src/server/models/user-group';
 import UserGroupRelation from '../../../src/server/models/user-group-relation';
 import type { IUserGroupService } from '../../../src/server/service/user-group';
 import { getInstance } from '../setup-crowi';
-import { PageActionOnGroupDelete } from '../../../src/interfaces/user-group';
 
 describe('UserGroupService', () => {
-  let crowi;
+  let crowi: Crowi;
+  // biome-ignore lint/suspicious/noImplicitAnyLet: ignore
   let User;
   let Page: PageModel;
 
@@ -36,6 +34,7 @@ describe('UserGroupService', () => {
   const groupId15 = new mongoose.Types.ObjectId();
 
   const userId1 = new mongoose.Types.ObjectId();
+  // biome-ignore lint/suspicious/noImplicitAnyLet: ignore
   let user1;
 
   const pageId1 = new mongoose.Types.ObjectId();
@@ -44,30 +43,36 @@ describe('UserGroupService', () => {
   let rootPage: PageDocument | null;
 
   // normalize for result comparison
-  const normalizeGrantedGroups = (grantedGroups: IGrantedGroup[] | undefined) => {
-    if (grantedGroups == null) { return null }
+  const normalizeGrantedGroups = (
+    grantedGroups: IGrantedGroup[] | undefined,
+  ) => {
+    if (grantedGroups == null) {
+      return null;
+    }
     return grantedGroups.map((group) => {
       return { item: getIdForRef(group.item), type: group.type };
     });
   };
 
-  beforeAll(async() => {
+  beforeAll(async () => {
     crowi = await getInstance();
     User = mongoose.model('User');
     Page = mongoose.model<IPage, PageModel>('Page');
 
     rootPage = await Page.findOne({ path: '/' });
-    userGroupService = crowi.userGroupService;
+    userGroupService = crowi.userGroupService!;
 
     await User.insertMany([
       // ug -> User Group
       {
-        _id: userId1, name: 'ug_test_user1', username: 'ug_test_user1', email: 'ug_test_user1@example.com',
+        _id: userId1,
+        name: 'ug_test_user1',
+        username: 'ug_test_user1',
+        email: 'ug_test_user1@example.com',
       },
     ]);
     user1 = await User.findOne({ _id: userId1 });
 
-
     // Create Groups
     await UserGroup.insertMany([
       // No parent
@@ -219,29 +224,32 @@ describe('UserGroupService', () => {
         parent: rootPage?._id,
       },
     ]);
-
   });
 
   /*
-    * Update UserGroup
-    */
+   * Update UserGroup
+   */
   describe('updateGroup', () => {
-    test('Updated values should be reflected. (name, description, parent)', async() => {
+    test('Updated values should be reflected. (name, description, parent)', async () => {
       const userGroup2 = await UserGroup.findOne({ _id: groupId2 });
 
       const newGroupName = 'v5_group1_new';
       const newGroupDescription = 'description1_new';
       const newParentId = userGroup2?._id;
 
-      const updatedUserGroup = await userGroupService.updateGroup(groupId1, newGroupName, newGroupDescription, newParentId);
+      const updatedUserGroup = await userGroupService.updateGroup(
+        groupId1,
+        newGroupName,
+        newGroupDescription,
+        newParentId,
+      );
 
       expect(updatedUserGroup.name).toBe(newGroupName);
       expect(updatedUserGroup.description).toBe(newGroupDescription);
       expect(updatedUserGroup.parent).toStrictEqual(newParentId);
     });
 
-    test('Should throw an error when trying to set existing group name', async() => {
-
+    test('Should throw an error when trying to set existing group name', async () => {
       const userGroup2 = await UserGroup.findOne({ _id: groupId2 });
 
       const result = userGroupService.updateGroup(groupId1, userGroup2?.name);
@@ -249,58 +257,102 @@ describe('UserGroupService', () => {
       await expect(result).rejects.toThrow('The group name is already taken');
     });
 
-    test('Parent should be null when parent group is released', async() => {
+    test('Parent should be null when parent group is released', async () => {
       const userGroup = await UserGroup.findOne({ _id: groupId3 });
-      const updatedUserGroup = await userGroupService.updateGroup(userGroup?._id, userGroup?.name, userGroup?.description, null);
+      const updatedUserGroup = await userGroupService.updateGroup(
+        userGroup?._id,
+        userGroup?.name,
+        userGroup?.description,
+        null,
+      );
 
       expect(updatedUserGroup.parent).toBeNull();
     });
 
     /*
-    * forceUpdateParents: false
-    */
-    test('Should throw an error when users in child group do not exist in parent group', async() => {
-      const userGroup4 = await UserGroup.findOne({ _id: groupId4, parent: null });
-      const result = userGroupService.updateGroup(userGroup4?._id, userGroup4?.name, userGroup4?.description, groupId5);
+     * forceUpdateParents: false
+     */
+    test('Should throw an error when users in child group do not exist in parent group', async () => {
+      const userGroup4 = await UserGroup.findOne({
+        _id: groupId4,
+        parent: null,
+      });
+      const result = userGroupService.updateGroup(
+        userGroup4?._id,
+        userGroup4?.name,
+        userGroup4?.description,
+        groupId5,
+      );
 
-      await expect(result).rejects.toThrow('The parent group does not contain the users in this group.');
+      await expect(result).rejects.toThrow(
+        'The parent group does not contain the users in this group.',
+      );
     });
 
     /*
-    * forceUpdateParents: true
-    */
-    test('User should be included to parent group (2 groups ver)', async() => {
-      const userGroup4 = await UserGroup.findOne({ _id: groupId4, parent: null });
-      const userGroup5 = await UserGroup.findOne({ _id: groupId5, parent: null });
+     * forceUpdateParents: true
+     */
+    test('User should be included to parent group (2 groups ver)', async () => {
+      const userGroup4 = await UserGroup.findOne({
+        _id: groupId4,
+        parent: null,
+      });
+      const userGroup5 = await UserGroup.findOne({
+        _id: groupId5,
+        parent: null,
+      });
       // userGroup4 has userId1
-      const userGroupRelation4BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  userGroup4, relatedUser: userId1 });
+      const userGroupRelation4BeforeUpdate = await UserGroupRelation.findOne({
+        relatedGroup: userGroup4,
+        relatedUser: userId1,
+      });
       expect(userGroupRelation4BeforeUpdate).not.toBeNull();
 
       // userGroup5 has not userId1
-      const userGroupRelation5BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  userGroup5, relatedUser: userId1 });
+      const userGroupRelation5BeforeUpdate = await UserGroupRelation.findOne({
+        relatedGroup: userGroup5,
+        relatedUser: userId1,
+      });
       expect(userGroupRelation5BeforeUpdate).toBeNull();
 
       // update userGroup4's parent with userGroup5 (forceUpdate: true)
       const forceUpdateParents = true;
       const updatedUserGroup = await userGroupService.updateGroup(
-        userGroup4?._id, userGroup4?.name, userGroup4?.description, groupId5, forceUpdateParents,
+        userGroup4?._id,
+        userGroup4?.name,
+        userGroup4?.description,
+        groupId5,
+        forceUpdateParents,
       );
 
       expect(updatedUserGroup.parent).toStrictEqual(groupId5);
       // userGroup5 should have userId1
       const userGroupRelation5AfterUpdate = await UserGroupRelation.findOne({
-        relatedGroup: groupId5, relatedUser: userGroupRelation4BeforeUpdate?.relatedUser,
+        relatedGroup: groupId5,
+        relatedUser: userGroupRelation4BeforeUpdate?.relatedUser,
       });
       expect(userGroupRelation5AfterUpdate).not.toBeNull();
     });
 
-    test('User should be included to parent group (3 groups ver)', async() => {
-      const userGroup8 = await UserGroup.findOne({ _id: groupId8, parent: null });
+    test('User should be included to parent group (3 groups ver)', async () => {
+      const userGroup8 = await UserGroup.findOne({
+        _id: groupId8,
+        parent: null,
+      });
 
       // userGroup7 has not userId1
-      const userGroupRelation6BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  groupId6, relatedUser: userId1 });
-      const userGroupRelation7BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  groupId7, relatedUser: userId1 });
-      const userGroupRelation8BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  groupId8, relatedUser: userId1 });
+      const userGroupRelation6BeforeUpdate = await UserGroupRelation.findOne({
+        relatedGroup: groupId6,
+        relatedUser: userId1,
+      });
+      const userGroupRelation7BeforeUpdate = await UserGroupRelation.findOne({
+        relatedGroup: groupId7,
+        relatedUser: userId1,
+      });
+      const userGroupRelation8BeforeUpdate = await UserGroupRelation.findOne({
+        relatedGroup: groupId8,
+        relatedUser: userId1,
+      });
       expect(userGroupRelation6BeforeUpdate).not.toBeNull();
       // userGroup7 does not have userId1
       expect(userGroupRelation7BeforeUpdate).toBeNull();
@@ -309,53 +361,102 @@ describe('UserGroupService', () => {
       // update userGroup8's parent with userGroup7 (forceUpdate: true)
       const forceUpdateParents = true;
       await userGroupService.updateGroup(
-        userGroup8?._id, userGroup8?.name, userGroup8?.description, groupId7, forceUpdateParents,
+        userGroup8?._id,
+        userGroup8?.name,
+        userGroup8?.description,
+        groupId7,
+        forceUpdateParents,
       );
 
-      const userGroupRelation6AfterUpdate = await UserGroupRelation.findOne({ relatedGroup: groupId6, relatedUser: userId1 });
-      const userGroupRelation7AfterUpdate = await UserGroupRelation.findOne({ relatedGroup: groupId7, relatedUser: userId1 });
-      const userGroupRelation8AfterUpdate = await UserGroupRelation.findOne({ relatedGroup: groupId8, relatedUser: userId1 });
+      const userGroupRelation6AfterUpdate = await UserGroupRelation.findOne({
+        relatedGroup: groupId6,
+        relatedUser: userId1,
+      });
+      const userGroupRelation7AfterUpdate = await UserGroupRelation.findOne({
+        relatedGroup: groupId7,
+        relatedUser: userId1,
+      });
+      const userGroupRelation8AfterUpdate = await UserGroupRelation.findOne({
+        relatedGroup: groupId8,
+        relatedUser: userId1,
+      });
       expect(userGroupRelation6AfterUpdate).not.toBeNull();
       // userGroup7 should have userId1
       expect(userGroupRelation7AfterUpdate).not.toBeNull();
       expect(userGroupRelation8AfterUpdate).not.toBeNull();
     });
 
-    test('Should throw an error when trying to choose parent from descendant groups.', async() => {
-      const userGroup9 = await UserGroup.findOne({ _id: groupId9, parent: null });
-      const userGroup10 = await UserGroup.findOne({ _id: groupId10, parent: groupId9 });
+    test('Should throw an error when trying to choose parent from descendant groups.', async () => {
+      const userGroup9 = await UserGroup.findOne({
+        _id: groupId9,
+        parent: null,
+      });
+      const userGroup10 = await UserGroup.findOne({
+        _id: groupId10,
+        parent: groupId9,
+      });
 
-      const userGroupRelation9BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  userGroup9?._id, relatedUser: userId1 });
-      const userGroupRelation10BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  userGroup10?._id, relatedUser: userId1 });
+      const userGroupRelation9BeforeUpdate = await UserGroupRelation.findOne({
+        relatedGroup: userGroup9?._id,
+        relatedUser: userId1,
+      });
+      const userGroupRelation10BeforeUpdate = await UserGroupRelation.findOne({
+        relatedGroup: userGroup10?._id,
+        relatedUser: userId1,
+      });
       expect(userGroupRelation9BeforeUpdate).not.toBeNull();
       expect(userGroupRelation10BeforeUpdate).not.toBeNull();
 
       const result = userGroupService.updateGroup(
-        userGroup9?._id, userGroup9?.name, userGroup9?.description, userGroup10?._id,
+        userGroup9?._id,
+        userGroup9?.name,
+        userGroup9?.description,
+        userGroup10?._id,
+      );
+      await expect(result).rejects.toThrow(
+        'It is not allowed to choose parent from descendant groups.',
       );
-      await expect(result).rejects.toThrow('It is not allowed to choose parent from descendant groups.');
     });
   });
 
   describe('removeUserByUsername', () => {
-    test('User should be deleted from child groups when the user excluded from the parent group', async() => {
-      const userGroup11 = await UserGroup.findOne({ _id: groupId11, parent: null });
-      const userGroup12 = await UserGroup.findOne({ _id: groupId12, parent: groupId11 });
+    test('User should be deleted from child groups when the user excluded from the parent group', async () => {
+      const userGroup11 = await UserGroup.findOne({
+        _id: groupId11,
+        parent: null,
+      });
+      const userGroup12 = await UserGroup.findOne({
+        _id: groupId12,
+        parent: groupId11,
+      });
 
       // Both groups have user1
-      const userGroupRelation11BeforeRemove = await UserGroupRelation.findOne({ relatedGroup:  userGroup11?._id, relatedUser: userId1 });
-      const userGroupRelation12BeforeRemove = await UserGroupRelation.findOne({ relatedGroup:  userGroup12?._id, relatedUser: userId1 });
+      const userGroupRelation11BeforeRemove = await UserGroupRelation.findOne({
+        relatedGroup: userGroup11?._id,
+        relatedUser: userId1,
+      });
+      const userGroupRelation12BeforeRemove = await UserGroupRelation.findOne({
+        relatedGroup: userGroup12?._id,
+        relatedUser: userId1,
+      });
       expect(userGroupRelation11BeforeRemove).not.toBeNull();
       expect(userGroupRelation12BeforeRemove).not.toBeNull();
 
       // remove user1 from the parent group
       await userGroupService.removeUserByUsername(
-        userGroup11?._id, 'ug_test_user1',
+        userGroup11?._id,
+        'ug_test_user1',
       );
 
       // Both groups have not user1
-      const userGroupRelation11AfterRemove = await UserGroupRelation.findOne({ relatedGroup:  userGroup11?._id, relatedUser: userId1 });
-      const userGroupRelation12AfterRemove = await UserGroupRelation.findOne({ relatedGroup:  userGroup12?._id, relatedUser: userId1 });
+      const userGroupRelation11AfterRemove = await UserGroupRelation.findOne({
+        relatedGroup: userGroup11?._id,
+        relatedUser: userId1,
+      });
+      const userGroupRelation12AfterRemove = await UserGroupRelation.findOne({
+        relatedGroup: userGroup12?._id,
+        relatedUser: userId1,
+      });
       await expect(userGroupRelation11AfterRemove).toBeNull();
       await expect(userGroupRelation12AfterRemove).toBeNull();
     });
@@ -363,7 +464,7 @@ describe('UserGroupService', () => {
 
   describe('removeCompletelyByRootGroupId', () => {
     describe('when action is public', () => {
-      test('Should remove the group and its descendants and publicize pages that are only visible to the groups to be removed', async() => {
+      test('Should remove the group and its descendants and publicize pages that are only visible to the groups to be removed', async () => {
         const userGroup13 = await UserGroup.findOne({ _id: groupId13 });
         const userGroup14 = await UserGroup.findOne({ _id: groupId14 });
         expect(userGroup13).not.toBeNull();
@@ -372,36 +473,75 @@ describe('UserGroupService', () => {
         const canBePublicized = await Page.findOne({ _id: pageId1 });
         const cannotBePublicized = await Page.findOne({ _id: pageId2 });
         expect(canBePublicized?.grant).toBe(PageGrant.GRANT_USER_GROUP);
-        expect(normalizeGrantedGroups(canBePublicized?.grantedGroups)).toEqual(expect.arrayContaining([
-          { item: groupId13, type: GroupType.userGroup },
-          { item: groupId14, type: GroupType.userGroup },
-        ]));
-        expect(normalizeGrantedGroups(canBePublicized?.grantedGroups)?.length).toBe(2);
+        expect(normalizeGrantedGroups(canBePublicized?.grantedGroups)).toEqual(
+          expect.arrayContaining([
+            { item: groupId13, type: GroupType.userGroup },
+            { item: groupId14, type: GroupType.userGroup },
+          ]),
+        );
+        expect(
+          normalizeGrantedGroups(canBePublicized?.grantedGroups)?.length,
+        ).toBe(2);
         expect(cannotBePublicized?.grant).toBe(PageGrant.GRANT_USER_GROUP);
-        expect(normalizeGrantedGroups(cannotBePublicized?.grantedGroups)).toEqual(expect.arrayContaining([
-          { item: groupId13, type: GroupType.userGroup },
-          { item: groupId15, type: GroupType.userGroup },
-        ]));
-        expect(normalizeGrantedGroups(cannotBePublicized?.grantedGroups)?.length).toBe(2);
-
-        await userGroupService.removeCompletelyByRootGroupId(groupId13, PageActionOnGroupDelete.publicize, user1);
-
-        const userGroup13AfterDeleteProcess = await UserGroup.findOne({ _id: groupId13 });
-        const userGroup14AfterDeleteProcess = await UserGroup.findOne({ _id: groupId14 });
+        expect(
+          normalizeGrantedGroups(cannotBePublicized?.grantedGroups),
+        ).toEqual(
+          expect.arrayContaining([
+            { item: groupId13, type: GroupType.userGroup },
+            { item: groupId15, type: GroupType.userGroup },
+          ]),
+        );
+        expect(
+          normalizeGrantedGroups(cannotBePublicized?.grantedGroups)?.length,
+        ).toBe(2);
+
+        await userGroupService.removeCompletelyByRootGroupId(
+          groupId13,
+          PageActionOnGroupDelete.publicize,
+          user1,
+        );
+
+        const userGroup13AfterDeleteProcess = await UserGroup.findOne({
+          _id: groupId13,
+        });
+        const userGroup14AfterDeleteProcess = await UserGroup.findOne({
+          _id: groupId14,
+        });
         expect(userGroup13AfterDeleteProcess).toBeNull();
         expect(userGroup14AfterDeleteProcess).toBeNull();
 
-        const canBePublicizedAfterDeleteProcess = await Page.findOne({ _id: pageId1 });
-        const cannotBePublicizedAfterDeleteProcess = await Page.findOne({ _id: pageId2 });
-        expect(canBePublicizedAfterDeleteProcess?.grant).toBe(PageGrant.GRANT_PUBLIC);
-        expect(normalizeGrantedGroups(canBePublicizedAfterDeleteProcess?.grantedGroups)).toEqual([]);
-        expect(cannotBePublicizedAfterDeleteProcess?.grant).toBe(PageGrant.GRANT_USER_GROUP);
-        expect(normalizeGrantedGroups(cannotBePublicizedAfterDeleteProcess?.grantedGroups)).toEqual(expect.arrayContaining([
-          { item: groupId15, type: GroupType.userGroup },
-        ]));
-        expect(normalizeGrantedGroups(cannotBePublicizedAfterDeleteProcess?.grantedGroups)?.length).toBe(1);
+        const canBePublicizedAfterDeleteProcess = await Page.findOne({
+          _id: pageId1,
+        });
+        const cannotBePublicizedAfterDeleteProcess = await Page.findOne({
+          _id: pageId2,
+        });
+        expect(canBePublicizedAfterDeleteProcess?.grant).toBe(
+          PageGrant.GRANT_PUBLIC,
+        );
+        expect(
+          normalizeGrantedGroups(
+            canBePublicizedAfterDeleteProcess?.grantedGroups,
+          ),
+        ).toEqual([]);
+        expect(cannotBePublicizedAfterDeleteProcess?.grant).toBe(
+          PageGrant.GRANT_USER_GROUP,
+        );
+        expect(
+          normalizeGrantedGroups(
+            cannotBePublicizedAfterDeleteProcess?.grantedGroups,
+          ),
+        ).toEqual(
+          expect.arrayContaining([
+            { item: groupId15, type: GroupType.userGroup },
+          ]),
+        );
+        expect(
+          normalizeGrantedGroups(
+            cannotBePublicizedAfterDeleteProcess?.grantedGroups,
+          )?.length,
+        ).toBe(1);
       });
     });
   });
-
 });

+ 500 - 152
apps/app/test/integration/service/v5.migration.test.js

@@ -34,10 +34,14 @@ describe('V5 page migration', () => {
   const pageId10 = new mongoose.Types.ObjectId();
   const pageId11 = new mongoose.Types.ObjectId();
 
-  const onlyPublic = filter => ({ grant: Page.GRANT_PUBLIC, ...filter });
-  const ownedByTestUser1 = filter => ({ grant: Page.GRANT_OWNER, grantedUsers: [testUser1._id], ...filter });
-  const root = filter => ({ grantedUsers: [rootUser._id], ...filter });
-  const rootUserGroup = filter => ({
+  const onlyPublic = (filter) => ({ grant: Page.GRANT_PUBLIC, ...filter });
+  const ownedByTestUser1 = (filter) => ({
+    grant: Page.GRANT_OWNER,
+    grantedUsers: [testUser1._id],
+    ...filter,
+  });
+  const root = (filter) => ({ grantedUsers: [rootUser._id], ...filter });
+  const rootUserGroup = (filter) => ({
     grantedGroups: {
       $elemMatch: {
         item: rootUserGroupId,
@@ -45,7 +49,7 @@ describe('V5 page migration', () => {
     },
     ...filter,
   });
-  const testUser1Group = filter => ({
+  const testUser1Group = (filter) => ({
     grantedGroups: {
       $elemMatch: {
         item: testUser1GroupId,
@@ -58,7 +62,7 @@ describe('V5 page migration', () => {
   const notNormalized = { parent: null };
   const empty = { isEmpty: true };
 
-  beforeAll(async() => {
+  beforeAll(async () => {
     jest.restoreAllMocks();
 
     crowi = await getInstance();
@@ -71,7 +75,11 @@ describe('V5 page migration', () => {
 
     await User.insertMany([
       { name: 'rootUser', username: 'rootUser', email: 'rootUser@example.com' },
-      { name: 'testUser1', username: 'testUser1', email: 'testUser1@example.com' },
+      {
+        name: 'testUser1',
+        username: 'testUser1',
+        email: 'testUser1@example.com',
+      },
     ]);
     rootUser = await User.findOne({ username: 'rootUser' });
     testUser1 = await User.findOne({ username: 'testUser1' });
@@ -264,56 +272,82 @@ describe('V5 page migration', () => {
         parent: pageId7,
         descendantCount: 0,
       },
-
     ]);
-
   });
 
-  const normalizeParentRecursivelyByPages = async(pages, user) => {
+  const normalizeParentRecursivelyByPages = async (pages, user) => {
     return crowi.pageService.normalizeParentRecursivelyByPages(pages, user);
   };
 
-  const normalizeParentByPage = async(page, user) => {
+  const normalizeParentByPage = async (page, user) => {
     return crowi.pageService.normalizeParentByPage(page, user);
   };
 
   describe('normalizeParentRecursivelyByPages()', () => {
-
-    test('should migrate all pages specified by pageIds', async() => {
+    test('should migrate all pages specified by pageIds', async () => {
       jest.restoreAllMocks();
 
-      const pagesToRun = await Page.find({ path: { $in: ['/private1', '/dummyParent/private1'] } });
+      const pagesToRun = await Page.find({
+        path: { $in: ['/private1', '/dummyParent/private1'] },
+      });
 
       // migrate
       await normalizeParentRecursivelyByPages(pagesToRun, testUser1);
       const migratedPages = await Page.find({
         path: {
-          $in: ['/private1', '/dummyParent', '/dummyParent/private1', '/dummyParent/private1/private2', '/dummyParent/private1/private3'],
+          $in: [
+            '/private1',
+            '/dummyParent',
+            '/dummyParent/private1',
+            '/dummyParent/private1/private2',
+            '/dummyParent/private1/private3',
+          ],
         },
       });
-      const migratedPagePaths = migratedPages.filter(doc => doc.parent != null).map(doc => doc.path);
-
-      const expected = ['/private1', '/dummyParent', '/dummyParent/private1', '/dummyParent/private1/private2', '/dummyParent/private1/private3'];
+      const migratedPagePaths = migratedPages
+        .filter((doc) => doc.parent != null)
+        .map((doc) => doc.path);
+
+      const expected = [
+        '/private1',
+        '/dummyParent',
+        '/dummyParent/private1',
+        '/dummyParent/private1/private2',
+        '/dummyParent/private1/private3',
+      ];
 
       expect(migratedPagePaths.sort()).toStrictEqual(expected.sort());
     });
 
-    test('should change all v4 pages with usergroup to v5 compatible and create new parent page', async() => {
+    test('should change all v4 pages with usergroup to v5 compatible and create new parent page', async () => {
       const page8 = await Page.findOne({ path: '/normalize_7/normalize_8_gA' });
-      const page9 = await Page.findOne({ path: '/normalize_7/normalize_8_gA/normalize_9_gB' });
-      const page10 = await Page.findOne({ path: '/normalize_7/normalize_8_gC' });
+      const page9 = await Page.findOne({
+        path: '/normalize_7/normalize_8_gA/normalize_9_gB',
+      });
+      const page10 = await Page.findOne({
+        path: '/normalize_7/normalize_8_gC',
+      });
       const page11 = await Page.findOne({ path: '/normalize_7' });
       expect(page8).toBeTruthy();
       expect(page9).toBeTruthy();
       expect(page10).toBeTruthy();
       expect(page11).toBeNull();
-      await normalizeParentRecursivelyByPages([page8, page9, page10], testUser1);
+      await normalizeParentRecursivelyByPages(
+        [page8, page9, page10],
+        testUser1,
+      );
 
       // AM => After Migration
       const page7 = await Page.findOne({ path: '/normalize_7' });
-      const page8AM = await Page.findOne({ path: '/normalize_7/normalize_8_gA' });
-      const page9AM = await Page.findOne({ path: '/normalize_7/normalize_8_gA/normalize_9_gB' });
-      const page10AM = await Page.findOne({ path: '/normalize_7/normalize_8_gC' });
+      const page8AM = await Page.findOne({
+        path: '/normalize_7/normalize_8_gA',
+      });
+      const page9AM = await Page.findOne({
+        path: '/normalize_7/normalize_8_gA/normalize_9_gB',
+      });
+      const page10AM = await Page.findOne({
+        path: '/normalize_7/normalize_8_gC',
+      });
       expect(page7).toBeTruthy();
       expect(page8AM).toBeTruthy();
       expect(page9AM).toBeTruthy();
@@ -327,14 +361,31 @@ describe('V5 page migration', () => {
       expect(page10AM.parent).toStrictEqual(page7._id);
     });
 
-    test('should replace empty page with same path with new non-empty page and update all related children\'s parent', async() => {
-      const page1 = await Page.findOne({ path: '/normalize_10', isEmpty: true, parent: { $ne: null } });
+    test("should replace empty page with same path with new non-empty page and update all related children's parent", async () => {
+      const page1 = await Page.findOne({
+        path: '/normalize_10',
+        isEmpty: true,
+        parent: { $ne: null },
+      });
       const page2 = await Page.findOne({
-        path: '/normalize_10/normalize_11_gA', _id: pageId8, isEmpty: true, parent: { $ne: null },
+        path: '/normalize_10/normalize_11_gA',
+        _id: pageId8,
+        isEmpty: true,
+        parent: { $ne: null },
+      });
+      const page3 = await Page.findOne({
+        path: '/normalize_10/normalize_11_gA',
+        _id: pageId9,
+        parent: null,
+      }); // not v5
+      const page4 = await Page.findOne({
+        path: '/normalize_10/normalize_11_gA/normalize_11_gB',
+        parent: { $ne: null },
+      });
+      const page5 = await Page.findOne({
+        path: '/normalize_10/normalize_12_gC',
+        parent: { $ne: null },
       });
-      const page3 = await Page.findOne({ path: '/normalize_10/normalize_11_gA', _id: pageId9, parent: null }); // not v5
-      const page4 = await Page.findOne({ path: '/normalize_10/normalize_11_gA/normalize_11_gB', parent: { $ne: null } });
-      const page5 = await Page.findOne({ path: '/normalize_10/normalize_12_gC', parent: { $ne: null } });
       expect(page1).toBeTruthy();
       expect(page2).toBeTruthy();
       expect(page3).toBeTruthy();
@@ -344,10 +395,20 @@ describe('V5 page migration', () => {
 
       // AM => After Migration
       const page1AM = await Page.findOne({ path: '/normalize_10' });
-      const page2AM = await Page.findOne({ path: '/normalize_10/normalize_11_gA', _id: pageId8 });
-      const page3AM = await Page.findOne({ path: '/normalize_10/normalize_11_gA', _id: pageId9 });
-      const page4AM = await Page.findOne({ path: '/normalize_10/normalize_11_gA/normalize_11_gB' });
-      const page5AM = await Page.findOne({ path: '/normalize_10/normalize_12_gC' });
+      const page2AM = await Page.findOne({
+        path: '/normalize_10/normalize_11_gA',
+        _id: pageId8,
+      });
+      const page3AM = await Page.findOne({
+        path: '/normalize_10/normalize_11_gA',
+        _id: pageId9,
+      });
+      const page4AM = await Page.findOne({
+        path: '/normalize_10/normalize_11_gA/normalize_11_gB',
+      });
+      const page5AM = await Page.findOne({
+        path: '/normalize_10/normalize_12_gC',
+      });
       expect(page1AM).toBeTruthy();
       expect(page3AM).toBeTruthy();
       expect(page4AM).toBeTruthy();
@@ -361,7 +422,6 @@ describe('V5 page migration', () => {
 
       expect(page3AM.isEmpty).toBe(false);
     });
-
   });
 
   describe('should normalize only selected pages recursively (while observing the page permission rule)', () => {
@@ -416,7 +476,7 @@ describe('V5 page migration', () => {
      *     - /normalize_g/normalize_i/normalize_k (only me) is normalized
      */
 
-    beforeAll(async() => {
+    beforeAll(async () => {
       // Prepare data
       const id1 = new mongoose.Types.ObjectId();
       const id2 = new mongoose.Types.ObjectId();
@@ -514,12 +574,25 @@ describe('V5 page migration', () => {
       ]);
     });
 
-    test('should not run normalization when the target page is GRANT_USER_GROUP surrounded by public pages', async() => {
-      const mockMainOperation = jest.spyOn(crowi.pageService, 'normalizeParentRecursivelyMainOperation').mockImplementation(v => v);
-      const _page1 = await Page.findOne(onlyPublic({ path: '/deep_path/normalize_a', ...empty }));
-      const _page2 = await Page.findOne(onlyPublic({ path: '/deep_path/normalize_a/normalize_b', ...normalized }));
-      const _page3 = await Page.findOne(testUser1Group({ path: '/deep_path/normalize_a', ...notNormalized }));
-      const _page4 = await Page.findOne(testUser1Group({ path: '/deep_path/normalize_c', ...notNormalized }));
+    test('should not run normalization when the target page is GRANT_USER_GROUP surrounded by public pages', async () => {
+      const mockMainOperation = jest
+        .spyOn(crowi.pageService, 'normalizeParentRecursivelyMainOperation')
+        .mockImplementation((v) => v);
+      const _page1 = await Page.findOne(
+        onlyPublic({ path: '/deep_path/normalize_a', ...empty }),
+      );
+      const _page2 = await Page.findOne(
+        onlyPublic({
+          path: '/deep_path/normalize_a/normalize_b',
+          ...normalized,
+        }),
+      );
+      const _page3 = await Page.findOne(
+        testUser1Group({ path: '/deep_path/normalize_a', ...notNormalized }),
+      );
+      const _page4 = await Page.findOne(
+        testUser1Group({ path: '/deep_path/normalize_c', ...notNormalized }),
+      );
 
       expect(_page1).not.toBeNull();
       expect(_page2).not.toBeNull();
@@ -534,11 +607,19 @@ describe('V5 page migration', () => {
       mockMainOperation.mockRestore();
     });
 
-    test('should not include siblings', async() => {
-      const _page1 = await Page.findOne(onlyPublic({ path: '/normalize_d', ...empty }));
-      const _page2 = await Page.findOne(testUser1Group({ path: '/normalize_d/normalize_e', ...normalized }));
-      const _page3 = await Page.findOne(testUser1Group({ path: '/normalize_d', ...notNormalized }));
-      const _page4 = await Page.findOne(testUser1Group({ path: '/normalize_f', ...notNormalized }));
+    test('should not include siblings', async () => {
+      const _page1 = await Page.findOne(
+        onlyPublic({ path: '/normalize_d', ...empty }),
+      );
+      const _page2 = await Page.findOne(
+        testUser1Group({ path: '/normalize_d/normalize_e', ...normalized }),
+      );
+      const _page3 = await Page.findOne(
+        testUser1Group({ path: '/normalize_d', ...notNormalized }),
+      );
+      const _page4 = await Page.findOne(
+        testUser1Group({ path: '/normalize_f', ...notNormalized }),
+      );
 
       expect(_page1).not.toBeNull();
       expect(_page2).not.toBeNull();
@@ -548,10 +629,18 @@ describe('V5 page migration', () => {
       // Normalize
       await normalizeParentRecursivelyByPages([_page3], testUser1);
 
-      const page1 = await Page.findOne(testUser1Group({ path: '/normalize_d/normalize_e' }));
-      const page2 = await Page.findOne(testUser1Group({ path: '/normalize_d' }));
-      const page3 = await Page.findOne(testUser1Group({ path: '/normalize_f' }));
-      const empty4 = await Page.findOne(onlyPublic({ path: '/normalize_d', ...empty }));
+      const page1 = await Page.findOne(
+        testUser1Group({ path: '/normalize_d/normalize_e' }),
+      );
+      const page2 = await Page.findOne(
+        testUser1Group({ path: '/normalize_d' }),
+      );
+      const page3 = await Page.findOne(
+        testUser1Group({ path: '/normalize_f' }),
+      );
+      const empty4 = await Page.findOne(
+        onlyPublic({ path: '/normalize_d', ...empty }),
+      );
 
       expect(page1).not.toBeNull();
       expect(page2).not.toBeNull();
@@ -569,12 +658,34 @@ describe('V5 page migration', () => {
       expect(page3.descendantCount).toBe(0); // should not be normalized
     });
 
-    test('should replace all unnecessary empty pages and normalization succeeds', async() => {
-      const _pageG = await Page.findOne(onlyPublic({ path: '/normalize_g', ...normalized }));
-      const _pageGH = await Page.findOne(ownedByTestUser1({ path: '/normalize_g/normalize_h', ...notNormalized }));
-      const _pageGI = await Page.findOne(ownedByTestUser1({ path: '/normalize_g/normalize_i', ...notNormalized }));
-      const _pageGHJ = await Page.findOne(ownedByTestUser1({ path: '/normalize_g/normalize_h/normalize_j', ...notNormalized }));
-      const _pageGIK = await Page.findOne(ownedByTestUser1({ path: '/normalize_g/normalize_i/normalize_k', ...notNormalized }));
+    test('should replace all unnecessary empty pages and normalization succeeds', async () => {
+      const _pageG = await Page.findOne(
+        onlyPublic({ path: '/normalize_g', ...normalized }),
+      );
+      const _pageGH = await Page.findOne(
+        ownedByTestUser1({
+          path: '/normalize_g/normalize_h',
+          ...notNormalized,
+        }),
+      );
+      const _pageGI = await Page.findOne(
+        ownedByTestUser1({
+          path: '/normalize_g/normalize_i',
+          ...notNormalized,
+        }),
+      );
+      const _pageGHJ = await Page.findOne(
+        ownedByTestUser1({
+          path: '/normalize_g/normalize_h/normalize_j',
+          ...notNormalized,
+        }),
+      );
+      const _pageGIK = await Page.findOne(
+        ownedByTestUser1({
+          path: '/normalize_g/normalize_i/normalize_k',
+          ...notNormalized,
+        }),
+      );
 
       expect(_pageG).not.toBeNull();
       expect(_pageGH).not.toBeNull();
@@ -588,8 +699,12 @@ describe('V5 page migration', () => {
       const countG = await Page.count({ path: '/normalize_g' });
       const countGH = await Page.count({ path: '/normalize_g/normalize_h' });
       const countGI = await Page.count({ path: '/normalize_g/normalize_i' });
-      const countGHJ = await Page.count({ path: '/normalize_g/normalize_h/normalize_j' });
-      const countGIK = await Page.count({ path: '/normalize_g/normalize_i/normalize_k' });
+      const countGHJ = await Page.count({
+        path: '/normalize_g/normalize_h/normalize_j',
+      });
+      const countGIK = await Page.count({
+        path: '/normalize_g/normalize_i/normalize_k',
+      });
 
       expect(countG).toBe(1);
       expect(countGH).toBe(2);
@@ -599,10 +714,20 @@ describe('V5 page migration', () => {
 
       // -- normalized pages
       const pageG = await Page.findOne(onlyPublic({ path: '/normalize_g' }));
-      const emptyGH = await Page.findOne({ path: '/normalize_g/normalize_h', ...empty });
-      const emptyGI = await Page.findOne({ path: '/normalize_g/normalize_i', ...empty });
-      const pageGHJ = await Page.findOne({ path: '/normalize_g/normalize_h/normalize_j' });
-      const pageGIK = await Page.findOne({ path: '/normalize_g/normalize_i/normalize_k' });
+      const emptyGH = await Page.findOne({
+        path: '/normalize_g/normalize_h',
+        ...empty,
+      });
+      const emptyGI = await Page.findOne({
+        path: '/normalize_g/normalize_i',
+        ...empty,
+      });
+      const pageGHJ = await Page.findOne({
+        path: '/normalize_g/normalize_h/normalize_j',
+      });
+      const pageGIK = await Page.findOne({
+        path: '/normalize_g/normalize_i/normalize_k',
+      });
 
       // Check existence
       expect(pageG).not.toBeNull();
@@ -624,8 +749,12 @@ describe('V5 page migration', () => {
       expect(pageGIK.descendantCount).toStrictEqual(0);
 
       // -- not normalized pages
-      const pageGH = await Page.findOne(ownedByTestUser1({ path: '/normalize_g/normalize_h' }));
-      const pageGI = await Page.findOne(ownedByTestUser1({ path: '/normalize_g/normalize_i' }));
+      const pageGH = await Page.findOne(
+        ownedByTestUser1({ path: '/normalize_g/normalize_h' }),
+      );
+      const pageGI = await Page.findOne(
+        ownedByTestUser1({ path: '/normalize_g/normalize_i' }),
+      );
       // Check existence
       expect(pageGH).not.toBeNull();
       expect(pageGI).not.toBeNull();
@@ -671,7 +800,7 @@ describe('V5 page migration', () => {
      *     - E and F are NOT normalized
      */
 
-    beforeAll(async() => {
+    beforeAll(async () => {
       // Prepare data
       const id17 = new mongoose.Types.ObjectId();
       const id21 = new mongoose.Types.ObjectId();
@@ -792,14 +921,40 @@ describe('V5 page migration', () => {
       ]);
     });
 
-
-    test('Should normalize a single page without including other pages', async() => {
-      const _owned13 = await Page.findOne(ownedByTestUser1({ path: '/normalize_13_owned', ...notNormalized }));
-      const _owned14 = await Page.findOne(ownedByTestUser1({ path: '/normalize_13_owned/normalize_14_owned', ...notNormalized }));
-      const _owned15 = await Page.findOne(ownedByTestUser1({ path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned', ...notNormalized }));
-      const _owned16 = await Page.findOne(ownedByTestUser1({ path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned/normalize_16_owned', ...notNormalized }));
-      const _root16 = await Page.findOne(root({ path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned/normalize_16_root', ...notNormalized }));
-      const _group16 = await Page.findOne(testUser1Group({ path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned/normalize_16_group', ...notNormalized }));
+    test('Should normalize a single page without including other pages', async () => {
+      const _owned13 = await Page.findOne(
+        ownedByTestUser1({ path: '/normalize_13_owned', ...notNormalized }),
+      );
+      const _owned14 = await Page.findOne(
+        ownedByTestUser1({
+          path: '/normalize_13_owned/normalize_14_owned',
+          ...notNormalized,
+        }),
+      );
+      const _owned15 = await Page.findOne(
+        ownedByTestUser1({
+          path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned',
+          ...notNormalized,
+        }),
+      );
+      const _owned16 = await Page.findOne(
+        ownedByTestUser1({
+          path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned/normalize_16_owned',
+          ...notNormalized,
+        }),
+      );
+      const _root16 = await Page.findOne(
+        root({
+          path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned/normalize_16_root',
+          ...notNormalized,
+        }),
+      );
+      const _group16 = await Page.findOne(
+        testUser1Group({
+          path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned/normalize_16_group',
+          ...notNormalized,
+        }),
+      );
 
       expect(_owned13).not.toBeNull();
       expect(_owned14).not.toBeNull();
@@ -812,12 +967,29 @@ describe('V5 page migration', () => {
       await normalizeParentByPage(_owned14, testUser1);
 
       const owned13 = await Page.findOne({ path: '/normalize_13_owned' });
-      const empty13 = await Page.findOne({ path: '/normalize_13_owned', ...empty });
-      const owned14 = await Page.findOne({ path: '/normalize_13_owned/normalize_14_owned' });
-      const owned15 = await Page.findOne({ path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned' });
-      const owned16 = await Page.findOne({ path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned/normalize_16_owned' });
-      const root16 = await Page.findOne(root({ path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned/normalize_16_root' }));
-      const group16 = await Page.findOne(testUser1Group({ path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned/normalize_16_group' }));
+      const empty13 = await Page.findOne({
+        path: '/normalize_13_owned',
+        ...empty,
+      });
+      const owned14 = await Page.findOne({
+        path: '/normalize_13_owned/normalize_14_owned',
+      });
+      const owned15 = await Page.findOne({
+        path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned',
+      });
+      const owned16 = await Page.findOne({
+        path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned/normalize_16_owned',
+      });
+      const root16 = await Page.findOne(
+        root({
+          path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned/normalize_16_root',
+        }),
+      );
+      const group16 = await Page.findOne(
+        testUser1Group({
+          path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned/normalize_16_group',
+        }),
+      );
 
       expect(owned13).not.toBeNull();
       expect(empty13).not.toBeNull();
@@ -842,13 +1014,40 @@ describe('V5 page migration', () => {
       expect(owned14.descendantCount).toBe(0);
     });
 
-    test('Should normalize pages recursively excluding the pages not selected', async() => {
-      const _owned17 = await Page.findOne(ownedByTestUser1({ path: '/normalize_17_owned', ...normalized }));
-      const _owned18 = await Page.findOne(ownedByTestUser1({ path: '/normalize_17_owned/normalize_18_owned', ...normalized }));
-      const _owned19 = await Page.findOne(ownedByTestUser1({ path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned', ...notNormalized }));
-      const _owned20 = await Page.findOne(ownedByTestUser1({ path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned/normalize_20_owned', ...notNormalized }));
-      const _root20 = await Page.findOne(root({ path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned/normalize_20_root', ...notNormalized }));
-      const _group20 = await Page.findOne(rootUserGroup({ path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned/normalize_20_group', ...notNormalized }));
+    test('Should normalize pages recursively excluding the pages not selected', async () => {
+      const _owned17 = await Page.findOne(
+        ownedByTestUser1({ path: '/normalize_17_owned', ...normalized }),
+      );
+      const _owned18 = await Page.findOne(
+        ownedByTestUser1({
+          path: '/normalize_17_owned/normalize_18_owned',
+          ...normalized,
+        }),
+      );
+      const _owned19 = await Page.findOne(
+        ownedByTestUser1({
+          path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned',
+          ...notNormalized,
+        }),
+      );
+      const _owned20 = await Page.findOne(
+        ownedByTestUser1({
+          path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned/normalize_20_owned',
+          ...notNormalized,
+        }),
+      );
+      const _root20 = await Page.findOne(
+        root({
+          path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned/normalize_20_root',
+          ...notNormalized,
+        }),
+      );
+      const _group20 = await Page.findOne(
+        rootUserGroup({
+          path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned/normalize_20_group',
+          ...notNormalized,
+        }),
+      );
 
       expect(_owned17).not.toBeNull();
       expect(_owned18).not.toBeNull();
@@ -861,12 +1060,29 @@ describe('V5 page migration', () => {
       await normalizeParentRecursivelyByPages([_owned20], testUser1);
 
       const owned17 = await Page.findOne({ path: '/normalize_17_owned' });
-      const owned18 = await Page.findOne({ path: '/normalize_17_owned/normalize_18_owned' });
-      const owned19 = await Page.findOne({ path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned' });
-      const empty19 = await Page.findOne({ path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned', ...empty });
-      const owned20 = await Page.findOne({ path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned/normalize_20_owned' });
-      const root20 = await Page.findOne(root({ path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned/normalize_20_root' }));
-      const group20 = await Page.findOne(rootUserGroup({ path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned/normalize_20_group' }));
+      const owned18 = await Page.findOne({
+        path: '/normalize_17_owned/normalize_18_owned',
+      });
+      const owned19 = await Page.findOne({
+        path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned',
+      });
+      const empty19 = await Page.findOne({
+        path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned',
+        ...empty,
+      });
+      const owned20 = await Page.findOne({
+        path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned/normalize_20_owned',
+      });
+      const root20 = await Page.findOne(
+        root({
+          path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned/normalize_20_root',
+        }),
+      );
+      const group20 = await Page.findOne(
+        rootUserGroup({
+          path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned/normalize_20_group',
+        }),
+      );
 
       expect(owned17).not.toBeNull();
       expect(owned18).not.toBeNull();
@@ -890,14 +1106,45 @@ describe('V5 page migration', () => {
       expect(owned18.isEmpty).toBe(false);
     });
 
-    test('Should normalize pages recursively excluding the pages of not user\'s & Should delete unnecessary empty pages', async() => {
-      const _owned21 = await Page.findOne(ownedByTestUser1({ path: '/normalize_21_owned', ...normalized }));
-      const _owned22 = await Page.findOne(ownedByTestUser1({ path: '/normalize_21_owned/normalize_22_owned', ...normalized }));
-      const _owned23 = await Page.findOne(ownedByTestUser1({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned', ...notNormalized }));
-      const _empty23 = await Page.findOne({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned', ...normalized, ...empty });
-      const _owned24 = await Page.findOne(ownedByTestUser1({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned/normalize_24_owned', ...normalized }));
-      const _root24 = await Page.findOne(root({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned/normalize_24_root', ...notNormalized }));
-      const _rootGroup24 = await Page.findOne(rootUserGroup({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned/normalize_24_rootGroup', ...notNormalized }));
+    test("Should normalize pages recursively excluding the pages of not user's & Should delete unnecessary empty pages", async () => {
+      const _owned21 = await Page.findOne(
+        ownedByTestUser1({ path: '/normalize_21_owned', ...normalized }),
+      );
+      const _owned22 = await Page.findOne(
+        ownedByTestUser1({
+          path: '/normalize_21_owned/normalize_22_owned',
+          ...normalized,
+        }),
+      );
+      const _owned23 = await Page.findOne(
+        ownedByTestUser1({
+          path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned',
+          ...notNormalized,
+        }),
+      );
+      const _empty23 = await Page.findOne({
+        path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned',
+        ...normalized,
+        ...empty,
+      });
+      const _owned24 = await Page.findOne(
+        ownedByTestUser1({
+          path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned/normalize_24_owned',
+          ...normalized,
+        }),
+      );
+      const _root24 = await Page.findOne(
+        root({
+          path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned/normalize_24_root',
+          ...notNormalized,
+        }),
+      );
+      const _rootGroup24 = await Page.findOne(
+        rootUserGroup({
+          path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned/normalize_24_rootGroup',
+          ...notNormalized,
+        }),
+      );
 
       expect(_owned21).not.toBeNull();
       expect(_owned22).not.toBeNull();
@@ -911,12 +1158,29 @@ describe('V5 page migration', () => {
       await normalizeParentRecursivelyByPages([_owned23], testUser1);
 
       const owned21 = await Page.findOne({ path: '/normalize_21_owned' });
-      const owned22 = await Page.findOne({ path: '/normalize_21_owned/normalize_22_owned' });
-      const owned23 = await Page.findOne({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned' });
-      const empty23 = await Page.findOne({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned', ...empty });
-      const owned24 = await Page.findOne({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned/normalize_24_owned' });
-      const root24 = await Page.findOne(root({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned/normalize_24_root' }));
-      const rootGroup24 = await Page.findOne(rootUserGroup({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned/normalize_24_rootGroup' }));
+      const owned22 = await Page.findOne({
+        path: '/normalize_21_owned/normalize_22_owned',
+      });
+      const owned23 = await Page.findOne({
+        path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned',
+      });
+      const empty23 = await Page.findOne({
+        path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned',
+        ...empty,
+      });
+      const owned24 = await Page.findOne({
+        path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned/normalize_24_owned',
+      });
+      const root24 = await Page.findOne(
+        root({
+          path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned/normalize_24_root',
+        }),
+      );
+      const rootGroup24 = await Page.findOne(
+        rootUserGroup({
+          path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned/normalize_24_rootGroup',
+        }),
+      );
 
       expect(owned21).not.toBeNull();
       expect(owned22).not.toBeNull();
@@ -939,16 +1203,20 @@ describe('V5 page migration', () => {
       expect(owned22.isEmpty).toBe(false);
       expect(owned23.isEmpty).toBe(false);
     });
-
   });
 
   describe('normalizeAllPublicPages()', () => {
     jest.setTimeout(60000);
     let createPagePaths;
     let allPossiblePagePaths;
-    beforeAll(async() => {
+    beforeAll(async () => {
       createPagePaths = [
-        '/publicA', '/publicA/privateB', '/publicA/privateB/publicC', '/parenthesis/(a)[b]{c}d', '/parenthesis/(a)[b]{c}d/public', '/migratedD',
+        '/publicA',
+        '/publicA/privateB',
+        '/publicA/privateB/publicC',
+        '/parenthesis/(a)[b]{c}d',
+        '/parenthesis/(a)[b]{c}d/public',
+        '/migratedD',
       ];
       allPossiblePagePaths = [...createPagePaths, '/parenthesis', '/'];
 
@@ -1003,7 +1271,7 @@ describe('V5 page migration', () => {
       jest.setTimeout(30000);
     });
 
-    test('should migrate all public pages', async() => {
+    test('should migrate all public pages', async () => {
       const migratedPages = await Page.find({
         path: {
           $in: allPossiblePagePaths,
@@ -1024,12 +1292,19 @@ describe('V5 page migration', () => {
         parent: null,
       });
 
-      const migratedPaths = migratedPages.map(page => page.path).sort();
-      const migratedEmptyPaths = migratedEmptyPages.map(page => page.path).sort();
-      const nonMigratedPaths = nonMigratedPages.map(page => page.path).sort();
-
-      const expectedMigratedPaths = allPossiblePagePaths.filter(path => path !== '/').sort();
-      const expectedMigratedEmptyPaths = ['/publicA/privateB', '/parenthesis'].sort();
+      const migratedPaths = migratedPages.map((page) => page.path).sort();
+      const migratedEmptyPaths = migratedEmptyPages
+        .map((page) => page.path)
+        .sort();
+      const nonMigratedPaths = nonMigratedPages.map((page) => page.path).sort();
+
+      const expectedMigratedPaths = allPossiblePagePaths
+        .filter((path) => path !== '/')
+        .sort();
+      const expectedMigratedEmptyPaths = [
+        '/publicA/privateB',
+        '/parenthesis',
+      ].sort();
       const expectedNonMigratedPaths = ['/publicA/privateB', '/'].sort();
 
       expect(migratedPaths).toStrictEqual(expectedMigratedPaths);
@@ -1039,9 +1314,17 @@ describe('V5 page migration', () => {
   });
 
   describe('normalizeParentByPage()', () => {
-    test('it should normalize not v5 page with usergroup that has parent group', async() => {
-      const page1 = await Page.findOne({ _id: pageId1, path: '/normalize_1', isEmpty: true });
-      const page2 = await Page.findOne({ _id: pageId2, path: '/normalize_1/normalize_2', parent: page1._id });
+    test('it should normalize not v5 page with usergroup that has parent group', async () => {
+      const page1 = await Page.findOne({
+        _id: pageId1,
+        path: '/normalize_1',
+        isEmpty: true,
+      });
+      const page2 = await Page.findOne({
+        _id: pageId2,
+        path: '/normalize_1/normalize_2',
+        parent: page1._id,
+      });
       const page3 = await Page.findOne({ _id: pageId3, path: '/normalize_1' }); // NOT v5
       expect(page1).toBeTruthy();
       expect(page2).toBeTruthy();
@@ -1050,9 +1333,19 @@ describe('V5 page migration', () => {
       await normalizeParentByPage(page3, testUser1);
 
       // AM => After Migration
-      const page1AM = await Page.findOne({ _id: pageId1, path: '/normalize_1', isEmpty: true });
-      const page2AM = await Page.findOne({ _id: pageId2, path: '/normalize_1/normalize_2' });
-      const page3AM = await Page.findOne({ _id: pageId3, path: '/normalize_1' }); // v5 compatible
+      const page1AM = await Page.findOne({
+        _id: pageId1,
+        path: '/normalize_1',
+        isEmpty: true,
+      });
+      const page2AM = await Page.findOne({
+        _id: pageId2,
+        path: '/normalize_1/normalize_2',
+      });
+      const page3AM = await Page.findOne({
+        _id: pageId3,
+        path: '/normalize_1',
+      }); // v5 compatible
       expect(page2AM).toBeTruthy();
       expect(page3AM).toBeTruthy();
       expect(page1AM).toBeNull();
@@ -1061,9 +1354,17 @@ describe('V5 page migration', () => {
       expect(page3AM.parent).toStrictEqual(rootPage._id);
     });
 
-    test('should throw error if a page with isolated group becomes the parent of other page with different group after normalizing', async() => {
-      const page4 = await Page.findOne({ _id: pageId4, path: '/normalize_4', isEmpty: true });
-      const page5 = await Page.findOne({ _id: pageId5, path: '/normalize_4/normalize_5', parent: page4._id });
+    test('should throw error if a page with isolated group becomes the parent of other page with different group after normalizing', async () => {
+      const page4 = await Page.findOne({
+        _id: pageId4,
+        path: '/normalize_4',
+        isEmpty: true,
+      });
+      const page5 = await Page.findOne({
+        _id: pageId5,
+        path: '/normalize_4/normalize_5',
+        parent: page4._id,
+      });
       const page6 = await Page.findOne({ _id: pageId6, path: '/normalize_4' }); // NOT v5
       expect(page4).toBeTruthy();
       expect(page5).toBeTruthy();
@@ -1072,15 +1373,25 @@ describe('V5 page migration', () => {
       let isThrown;
       try {
         await normalizeParentByPage(page6, testUser1);
-      }
-      catch (err) {
+      } catch (err) {
         isThrown = true;
       }
 
       // AM => After Migration
-      const page4AM = await Page.findOne({ _id: pageId4, path: '/normalize_4', isEmpty: true });
-      const page5AM = await Page.findOne({ _id: pageId5, path: '/normalize_4/normalize_5', parent: page4._id });
-      const page6AM = await Page.findOne({ _id: pageId6, path: '/normalize_4' }); // NOT v5
+      const page4AM = await Page.findOne({
+        _id: pageId4,
+        path: '/normalize_4',
+        isEmpty: true,
+      });
+      const page5AM = await Page.findOne({
+        _id: pageId5,
+        path: '/normalize_4/normalize_5',
+        parent: page4._id,
+      });
+      const page6AM = await Page.findOne({
+        _id: pageId6,
+        path: '/normalize_4',
+      }); // NOT v5
       expect(isThrown).toBe(true);
       expect(page4AM).toBeTruthy();
       expect(page5AM).toBeTruthy();
@@ -1091,10 +1402,12 @@ describe('V5 page migration', () => {
     });
   });
 
-  test('replace private parents with empty pages', async() => {
+  test('replace private parents with empty pages', async () => {
     const replacedPathPages = await Page.find({ path: '/publicA/privateB' }); // ex-private page
 
-    const _newEmptyPage = replacedPathPages.filter(page => page.parent != null)[0];
+    const _newEmptyPage = replacedPathPages.filter(
+      (page) => page.parent != null,
+    )[0];
     const newEmptyPage = {
       path: _newEmptyPage.path,
       grant: _newEmptyPage.grant,
@@ -1106,7 +1419,9 @@ describe('V5 page migration', () => {
       isEmpty: true,
     };
 
-    const _privatePage = replacedPathPages.filter(page => page.parent == null)[0];
+    const _privatePage = replacedPathPages.filter(
+      (page) => page.parent == null,
+    )[0];
     const privatePage = {
       path: _privatePage.path,
       grant: _privatePage.grant,
@@ -1124,8 +1439,10 @@ describe('V5 page migration', () => {
   });
 
   describe('normalizeParentByPath', () => {
-    const normalizeParentByPath = async(path, user) => {
-      const mock = jest.spyOn(crowi.pageService, 'normalizeParentRecursivelyMainOperation').mockReturnValue(null);
+    const normalizeParentByPath = async (path, user) => {
+      const mock = jest
+        .spyOn(crowi.pageService, 'normalizeParentRecursivelyMainOperation')
+        .mockReturnValue(null);
       const result = await crowi.pageService.normalizeParentByPath(path, user);
       const args = mock.mock.calls[0];
 
@@ -1136,7 +1453,7 @@ describe('V5 page migration', () => {
       return result;
     };
 
-    beforeAll(async() => {
+    beforeAll(async () => {
       const pageIdD = new mongoose.Types.ObjectId();
       const pageIdG = new mongoose.Types.ObjectId();
 
@@ -1203,17 +1520,23 @@ describe('V5 page migration', () => {
       ]);
     });
 
-    test('should fail when the user is not allowed to edit the target page found by path', async() => {
-      const pageTestUser1 = await Page.findOne(ownedByTestUser1({ path: '/norm_parent_by_path_A' }));
+    test('should fail when the user is not allowed to edit the target page found by path', async () => {
+      const pageTestUser1 = await Page.findOne(
+        ownedByTestUser1({ path: '/norm_parent_by_path_A' }),
+      );
 
       expect(pageTestUser1).not.toBeNull();
 
-      await expect(normalizeParentByPath('/norm_parent_by_path_A', rootUser)).rejects.toThrowError();
+      await expect(
+        normalizeParentByPath('/norm_parent_by_path_A', rootUser),
+      ).rejects.toThrowError();
     });
 
-    test('should normalize all granted pages under the path when no page exists at the path', async() => {
+    test('should normalize all granted pages under the path when no page exists at the path', async () => {
       const _pageB = await Page.findOne({ path: '/norm_parent_by_path_B' });
-      const _pageBC = await Page.findOne(root({ path: '/norm_parent_by_path_B/norm_parent_by_path_C' }));
+      const _pageBC = await Page.findOne(
+        root({ path: '/norm_parent_by_path_B/norm_parent_by_path_C' }),
+      );
 
       expect(_pageB).toBeNull();
       expect(_pageBC).not.toBeNull();
@@ -1241,10 +1564,24 @@ describe('V5 page migration', () => {
       expect(pageBC.descendantCount).toBe(0);
     });
 
-    test('should normalize all granted pages under the path when an empty page exists at the path', async() => {
-      const _emptyD = await Page.findOne({ path: '/norm_parent_by_path_D', ...empty, ...normalized });
-      const _pageDE = await Page.findOne(onlyPublic({ path: '/norm_parent_by_path_D/norm_parent_by_path_E', ...normalized }));
-      const _pageDF = await Page.findOne(root({ path: '/norm_parent_by_path_D/norm_parent_by_path_F', ...notNormalized }));
+    test('should normalize all granted pages under the path when an empty page exists at the path', async () => {
+      const _emptyD = await Page.findOne({
+        path: '/norm_parent_by_path_D',
+        ...empty,
+        ...normalized,
+      });
+      const _pageDE = await Page.findOne(
+        onlyPublic({
+          path: '/norm_parent_by_path_D/norm_parent_by_path_E',
+          ...normalized,
+        }),
+      );
+      const _pageDF = await Page.findOne(
+        root({
+          path: '/norm_parent_by_path_D/norm_parent_by_path_F',
+          ...notNormalized,
+        }),
+      );
 
       expect(_emptyD).not.toBeNull();
       expect(_pageDE).not.toBeNull();
@@ -1281,10 +1618,22 @@ describe('V5 page migration', () => {
       expect(pageDF.descendantCount).toBe(0);
     });
 
-    test('should normalize all granted pages under the path when a non-empty page exists at the path', async() => {
-      const _pageG = await Page.findOne(onlyPublic({ path: '/norm_parent_by_path_G', ...normalized }));
-      const _pageGH = await Page.findOne(onlyPublic({ path: '/norm_parent_by_path_G/norm_parent_by_path_H', ...normalized }));
-      const _pageGI = await Page.findOne(root({ path: '/norm_parent_by_path_G/norm_parent_by_path_I', ...notNormalized }));
+    test('should normalize all granted pages under the path when a non-empty page exists at the path', async () => {
+      const _pageG = await Page.findOne(
+        onlyPublic({ path: '/norm_parent_by_path_G', ...normalized }),
+      );
+      const _pageGH = await Page.findOne(
+        onlyPublic({
+          path: '/norm_parent_by_path_G/norm_parent_by_path_H',
+          ...normalized,
+        }),
+      );
+      const _pageGI = await Page.findOne(
+        root({
+          path: '/norm_parent_by_path_G/norm_parent_by_path_I',
+          ...notNormalized,
+        }),
+      );
 
       expect(_pageG).not.toBeNull();
       expect(_pageGH).not.toBeNull();
@@ -1317,5 +1666,4 @@ describe('V5 page migration', () => {
       expect(pageGI.descendantCount).toBe(0);
     });
   });
-
 });

Разница между файлами не показана из-за своего большого размера
+ 454 - 164
apps/app/test/integration/service/v5.non-public-page.test.ts


+ 172 - 132
apps/app/test/integration/service/v5.page.test.ts

@@ -1,50 +1,44 @@
+import type { IPage } from '@growi/core';
 import { addSeconds } from 'date-fns/addSeconds';
 import mongoose from 'mongoose';
-
-import { PageActionStage, PageActionType } from '../../../src/interfaces/page-operation';
+import {
+  PageActionStage,
+  PageActionType,
+} from '../../../src/interfaces/page-operation';
+import type Crowi from '../../../src/server/crowi';
+import type { PageDocument, PageModel } from '../../../src/server/models/page';
+import type {
+  IPageOperation,
+  PageOperationModel,
+} from '../../../src/server/models/page-operation';
 import { getInstance } from '../setup-crowi';
 
-
 describe('Test page service methods', () => {
-  let crowi;
-  let Page;
-  let Revision;
+  let crowi: Crowi;
+  let Page: PageModel;
+  // biome-ignore lint/suspicious/noImplicitAnyLet: ignore
   let User;
-  let Tag;
-  let Bookmark;
-  let Comment;
-  let ShareLink;
-  let PageRedirect;
-  let PageOperation;
+  let PageOperation: PageOperationModel;
 
-  let rootPage;
+  let rootPage: PageDocument;
 
+  // biome-ignore lint/suspicious/noImplicitAnyLet: ignore
   let dummyUser1;
-  let dummyUser2;
-  let globalGroupUser1;
-  let globalGroupUser2;
-  let globalGroupUser3;
-
-  let pageOpId1;
-  let pageOpId2;
-  let pageOpId3;
-  let pageOpId4;
-  let pageOpId5;
-  let pageOpId6;
-
-  beforeAll(async() => {
+
+  let pageOpId1: mongoose.Types.ObjectId;
+  let pageOpId2: mongoose.Types.ObjectId;
+  let pageOpId3: mongoose.Types.ObjectId;
+  let pageOpId4: mongoose.Types.ObjectId;
+
+  beforeAll(async () => {
     crowi = await getInstance();
     await crowi.configManager.updateConfig('app:isV5Compatible', true);
 
     User = mongoose.model('User');
-    Page = mongoose.model('Page');
-    Revision = mongoose.model('Revision');
-    Tag = mongoose.model('Tag');
-    Bookmark = mongoose.model('Bookmark');
-    Comment = mongoose.model('Comment');
-    ShareLink = mongoose.model('ShareLink');
-    PageRedirect = mongoose.model('PageRedirect');
-    PageOperation = mongoose.model('PageOperation');
+    Page = mongoose.model<IPage, PageModel>('Page');
+    PageOperation = mongoose.model<IPageOperation, PageOperationModel>(
+      'PageOperation',
+    );
 
     /*
      * Common
@@ -55,13 +49,8 @@ describe('Test page service methods', () => {
     // ***********************************************************************************************************
     // users
     dummyUser1 = await User.findOne({ username: 'v5DummyUser1' });
-    dummyUser2 = await User.findOne({ username: 'v5DummyUser2' });
-    globalGroupUser1 = await User.findOne({ username: 'gGroupUser1' });
-    globalGroupUser2 = await User.findOne({ username: 'gGroupUser2' });
-    globalGroupUser3 = await User.findOne({ username: 'gGroupUser3' });
     // page
-    rootPage = await Page.findOne({ path: '/' });
-
+    rootPage = (await Page.findOne({ path: '/' }))!;
 
     /**
      * pages
@@ -470,17 +459,26 @@ describe('Test page service methods', () => {
   });
 
   describe('restart renameOperation', () => {
-    const resumeRenameSubOperation = async(renamePage, pageOp, activity?) => {
-      const mockedPathsAndDescendantCountOfAncestors = jest.spyOn(crowi.pageService, 'fixPathsAndDescendantCountOfAncestors').mockReturnValue(null);
-      await crowi.pageService.resumeRenameSubOperation(renamePage, pageOp, activity);
-
-      const argsForRenameSubOperation = mockedPathsAndDescendantCountOfAncestors.mock.calls[0];
+    const resumeRenameSubOperation = async (renamePage, pageOp, activity?) => {
+      const mockedPathsAndDescendantCountOfAncestors = jest
+        .spyOn(crowi.pageService, 'fixPathsAndDescendantCountOfAncestors')
+        .mockReturnValue(null);
+      await crowi.pageService.resumeRenameSubOperation(
+        renamePage,
+        pageOp,
+        activity,
+      );
+
+      const argsForRenameSubOperation =
+        mockedPathsAndDescendantCountOfAncestors.mock.calls[0];
 
       mockedPathsAndDescendantCountOfAncestors.mockRestore();
-      await crowi.pageService.fixPathsAndDescendantCountOfAncestors(...argsForRenameSubOperation);
+      await crowi.pageService.fixPathsAndDescendantCountOfAncestors(
+        ...argsForRenameSubOperation,
+      );
     };
 
-    test('it should successfully restart rename operation', async() => {
+    test('it should successfully restart rename operation', async () => {
       // paths before renaming
       const _path0 = '/resume_rename_0'; // out of renaming scope
       const _path1 = '/resume_rename_0/resume_rename_1'; // renamed already
@@ -491,7 +489,8 @@ describe('Test page service methods', () => {
       const path0 = '/resume_rename_0';
       const path1 = '/resume_rename_0/resume_rename_1';
       const path2 = '/resume_rename_0/resume_rename_1/resume_rename_2';
-      const path3 = '/resume_rename_0/resume_rename_1/resume_rename_2/resume_rename_3';
+      const path3 =
+        '/resume_rename_0/resume_rename_1/resume_rename_2/resume_rename_3';
 
       // activity options
       const activity = 'randomActivityId';
@@ -506,16 +505,21 @@ describe('Test page service methods', () => {
       expect(_page2).toBeTruthy();
       expect(_page3).toBeTruthy();
 
-      expect(_page0.descendantCount).toBe(1);
-      expect(_page1.descendantCount).toBe(2);
-      expect(_page2.descendantCount).toBe(1);
-      expect(_page3.descendantCount).toBe(0);
+      expect(_page0?.descendantCount).toBe(1);
+      expect(_page1?.descendantCount).toBe(2);
+      expect(_page2?.descendantCount).toBe(1);
+      expect(_page3?.descendantCount).toBe(0);
 
       // page operation
       const fromPath = '/resume_rename_1';
       const toPath = '/resume_rename_0/resume_rename_1';
       const _pageOperation = await PageOperation.findOne({
-        _id: pageOpId1, fromPath, toPath, 'page._id': _page1._id, actionType: PageActionType.Rename, actionStage: PageActionStage.Sub,
+        _id: pageOpId1,
+        fromPath,
+        toPath,
+        'page._id': _page1?._id,
+        actionType: PageActionType.Rename,
+        actionStage: PageActionStage.Sub,
       });
       expect(_pageOperation).toBeTruthy();
 
@@ -523,30 +527,30 @@ describe('Test page service methods', () => {
       await resumeRenameSubOperation(_page1, _pageOperation, activity);
 
       // page
-      const page0 = await Page.findById(_page0._id);
-      const page1 = await Page.findById(_page1._id);
-      const page2 = await Page.findById(_page2._id);
-      const page3 = await Page.findById(_page3._id);
+      const page0 = await Page.findById(_page0?._id);
+      const page1 = await Page.findById(_page1?._id);
+      const page2 = await Page.findById(_page2?._id);
+      const page3 = await Page.findById(_page3?._id);
       expect(page0).toBeTruthy();
       expect(page1).toBeTruthy();
       expect(page2).toBeTruthy();
       expect(page3).toBeTruthy();
       // check paths after renaming
-      expect(page0.path).toBe(path0);
-      expect(page1.path).toBe(path1);
-      expect(page2.path).toBe(path2);
-      expect(page3.path).toBe(path3);
+      expect(page0?.path).toBe(path0);
+      expect(page1?.path).toBe(path1);
+      expect(page2?.path).toBe(path2);
+      expect(page3?.path).toBe(path3);
 
       // page operation
-      const pageOperation = await PageOperation.findById(_pageOperation._id);
+      const pageOperation = await PageOperation.findById(_pageOperation?._id);
       expect(pageOperation).toBeNull(); // should not exist
 
-      expect(page0.descendantCount).toBe(3);
-      expect(page1.descendantCount).toBe(2);
-      expect(page2.descendantCount).toBe(1);
-      expect(page3.descendantCount).toBe(0);
+      expect(page0?.descendantCount).toBe(3);
+      expect(page1?.descendantCount).toBe(2);
+      expect(page2?.descendantCount).toBe(1);
+      expect(page3?.descendantCount).toBe(0);
     });
-    test('it should successfully restart rename operation when unprocessableExpiryDate is null', async() => {
+    test('it should successfully restart rename operation when unprocessableExpiryDate is null', async () => {
       // paths before renaming
       const _path0 = '/resume_rename_8'; // out of renaming scope
       const _path1 = '/resume_rename_8/resume_rename_9'; // renamed already
@@ -572,45 +576,54 @@ describe('Test page service methods', () => {
       expect(_page1).toBeTruthy();
       expect(_page2).toBeTruthy();
 
-      expect(_page0.descendantCount).toBe(1);
-      expect(_page1.descendantCount).toBe(1);
-      expect(_page2.descendantCount).toBe(0);
+      expect(_page0?.descendantCount).toBe(1);
+      expect(_page1?.descendantCount).toBe(1);
+      expect(_page2?.descendantCount).toBe(0);
 
       // page operation
       const fromPath = '/resume_rename_9';
       const toPath = '/resume_rename_8/resume_rename_9';
       const _pageOperation = await PageOperation.findOne({
-        _id: pageOpId4, fromPath, toPath, 'page._id': _page1._id, actionType: PageActionType.Rename, actionStage: PageActionStage.Sub,
+        _id: pageOpId4,
+        fromPath,
+        toPath,
+        'page._id': _page1?._id,
+        actionType: PageActionType.Rename,
+        actionStage: PageActionStage.Sub,
       });
       expect(_pageOperation).toBeTruthy();
 
       // rename
-      await resumeRenameSubOperation(_page1, _pageOperation, activityParameters);
+      await resumeRenameSubOperation(
+        _page1,
+        _pageOperation,
+        activityParameters,
+      );
 
       // page
-      const page0 = await Page.findById(_page0._id);
-      const page1 = await Page.findById(_page1._id);
-      const page2 = await Page.findById(_page2._id);
+      const page0 = await Page.findById(_page0?._id);
+      const page1 = await Page.findById(_page1?._id);
+      const page2 = await Page.findById(_page2?._id);
       expect(page0).toBeTruthy();
       expect(page1).toBeTruthy();
       expect(page2).toBeTruthy();
       // check paths after renaming
-      expect(page0.path).toBe(path0);
-      expect(page1.path).toBe(path1);
-      expect(page2.path).toBe(path2);
+      expect(page0?.path).toBe(path0);
+      expect(page1?.path).toBe(path1);
+      expect(page2?.path).toBe(path2);
 
       // page operation
-      const pageOperation = await PageOperation.findById(_pageOperation._id);
+      const pageOperation = await PageOperation.findById(_pageOperation?._id);
       expect(pageOperation).toBeNull(); // should not exist
 
       // others
-      expect(page1.parent).toStrictEqual(page0._id);
-      expect(page2.parent).toStrictEqual(page1._id);
-      expect(page0.descendantCount).toBe(2);
-      expect(page1.descendantCount).toBe(1);
-      expect(page2.descendantCount).toBe(0);
+      expect(page1?.parent).toStrictEqual(page0?._id);
+      expect(page2?.parent).toStrictEqual(page1?._id);
+      expect(page0?.descendantCount).toBe(2);
+      expect(page1?.descendantCount).toBe(1);
+      expect(page2?.descendantCount).toBe(0);
     });
-    test('it should fail and throw error if the current time is behind unprocessableExpiryDate', async() => {
+    test('it should fail and throw error if the current time is behind unprocessableExpiryDate', async () => {
       // path before renaming
       const _path0 = '/resume_rename_4'; // out of renaming scope
       const _path1 = '/resume_rename_4/resume_rename_5'; // renamed already
@@ -627,21 +640,34 @@ describe('Test page service methods', () => {
       const fromPath = '/resume_rename_5';
       const toPath = '/resume_rename_4/resume_rename_5';
       const _pageOperation = await PageOperation.findOne({
-        _id: pageOpId2, fromPath, toPath, 'page._id': _page1._id, actionType: PageActionType.Rename, actionStage: PageActionStage.Sub,
+        _id: pageOpId2,
+        fromPath,
+        toPath,
+        'page._id': _page1?._id,
+        actionType: PageActionType.Rename,
+        actionStage: PageActionStage.Sub,
       });
       expect(_pageOperation).toBeTruthy();
 
       // Make `unprocessableExpiryDate` 15 seconds ahead of current time.
       // The number 15 seconds has no meaning other than placing time in the furue.
-      const pageOperation = await PageOperation.findByIdAndUpdate(_pageOperation._id, { unprocessableExpiryDate: addSeconds(new Date(), 15) }, { new: true });
+      const pageOperation = await PageOperation.findByIdAndUpdate(
+        _pageOperation?._id,
+        { unprocessableExpiryDate: addSeconds(new Date(), 15) },
+        { new: true },
+      );
       expect(pageOperation).toBeTruthy();
 
-      await expect(resumeRenameSubOperation(_page1, pageOperation)).rejects.toThrow(new Error('This page operation is currently being processed'));
+      await expect(
+        resumeRenameSubOperation(_page1, pageOperation),
+      ).rejects.toThrow(
+        new Error('This page operation is currently being processed'),
+      );
 
       // cleanup
-      await PageOperation.findByIdAndDelete(pageOperation._id);
+      await PageOperation.findByIdAndDelete(pageOperation?._id);
     });
-    test('Missing property(toPath) for PageOperation should throw error', async() => {
+    test('Missing property(toPath) for PageOperation should throw error', async () => {
       // page
       const _path1 = '/resume_rename_7';
       const _page1 = await Page.findOne({ path: _path1 });
@@ -649,23 +675,31 @@ describe('Test page service methods', () => {
 
       // page operation
       const pageOperation = await PageOperation.findOne({
-        _id: pageOpId3, 'page._id': _page1._id, actionType: PageActionType.Rename, actionStage: PageActionStage.Sub,
+        _id: pageOpId3,
+        'page._id': _page1?._id,
+        actionType: PageActionType.Rename,
+        actionStage: PageActionStage.Sub,
       });
       expect(pageOperation).toBeTruthy();
 
       const promise = resumeRenameSubOperation(_page1, pageOperation);
-      await expect(promise).rejects.toThrow(new Error(`Property toPath is missing which is needed to resume rename operation(${pageOperation._id})`));
+      await expect(promise).rejects.toThrow(
+        new Error(
+          `Property toPath is missing which is needed to resume rename operation(${pageOperation?._id})`,
+        ),
+      );
 
       // cleanup
-      await PageOperation.findByIdAndDelete(pageOperation._id);
+      await PageOperation.findByIdAndDelete(pageOperation?._id);
     });
   });
   describe('updateDescendantCountOfPagesWithPaths', () => {
-    test('should fix descendantCount of pages with one of the given paths', async() => {
+    test('should fix descendantCount of pages with one of the given paths', async () => {
       // path
       const _path1 = '/fix_descendantCount_1';
       const _path2 = '/fix_descendantCount_1/fix_descendantCount_2'; // empty
-      const _path3 = '/fix_descendantCount_1/fix_descendantCount_2/fix_descendantCount_3';
+      const _path3 =
+        '/fix_descendantCount_1/fix_descendantCount_2/fix_descendantCount_3';
       const _path4 = '/fix_descendantCount_4';
       const _path5 = '/fix_descendantCount_4/fix_descendantCount_5';
       // page
@@ -681,32 +715,38 @@ describe('Test page service methods', () => {
       expect(_page4).toBeTruthy();
       expect(_page5).toBeTruthy();
       // check descendantCount (all broken)
-      expect(_page1.descendantCount).toBe(100);
-      expect(_page2.descendantCount).toBe(100);
-      expect(_page3.descendantCount).toBe(100);
-      expect(_page4.descendantCount).toBe(100);
-      expect(_page5.descendantCount).toBe(100);
+      expect(_page1?.descendantCount).toBe(100);
+      expect(_page2?.descendantCount).toBe(100);
+      expect(_page3?.descendantCount).toBe(100);
+      expect(_page4?.descendantCount).toBe(100);
+      expect(_page5?.descendantCount).toBe(100);
       // check isEmpty
-      expect(_page1.isEmpty).toBe(false);
-      expect(_page2.isEmpty).toBe(true);
-      expect(_page3.isEmpty).toBe(false);
-      expect(_page4.isEmpty).toBe(false);
-      expect(_page5.isEmpty).toBe(false);
+      expect(_page1?.isEmpty).toBe(false);
+      expect(_page2?.isEmpty).toBe(true);
+      expect(_page3?.isEmpty).toBe(false);
+      expect(_page4?.isEmpty).toBe(false);
+      expect(_page5?.isEmpty).toBe(false);
       // check parent
-      expect(_page1.parent).toStrictEqual(rootPage._id);
-      expect(_page2.parent).toStrictEqual(_page1._id);
-      expect(_page3.parent).toStrictEqual(_page2._id);
-      expect(_page4.parent).toStrictEqual(rootPage._id);
-      expect(_page5.parent).toStrictEqual(_page4._id);
-
-      await crowi.pageService.updateDescendantCountOfPagesWithPaths([_path1, _path2, _path3, _path4, _path5]);
+      expect(_page1?.parent).toStrictEqual(rootPage._id);
+      expect(_page2?.parent).toStrictEqual(_page1?._id);
+      expect(_page3?.parent).toStrictEqual(_page2?._id);
+      expect(_page4?.parent).toStrictEqual(rootPage._id);
+      expect(_page5?.parent).toStrictEqual(_page4?._id);
+
+      await crowi.pageService.updateDescendantCountOfPagesWithPaths([
+        _path1,
+        _path2,
+        _path3,
+        _path4,
+        _path5,
+      ]);
 
       // page
-      const page1 = await Page.findById(_page1._id);
-      const page2 = await Page.findById(_page2._id);
-      const page3 = await Page.findById(_page3._id);
-      const page4 = await Page.findById(_page4._id);
-      const page5 = await Page.findById(_page5._id);
+      const page1 = await Page.findById(_page1?._id);
+      const page2 = await Page.findById(_page2?._id);
+      const page3 = await Page.findById(_page3?._id);
+      const page4 = await Page.findById(_page4?._id);
+      const page5 = await Page.findById(_page5?._id);
 
       // check existance
       expect(page1).toBeTruthy();
@@ -715,23 +755,23 @@ describe('Test page service methods', () => {
       expect(page4).toBeTruthy();
       expect(page5).toBeTruthy();
       // check descendantCount (all fixed)
-      expect(page1.descendantCount).toBe(1);
-      expect(page2.descendantCount).toBe(1);
-      expect(page3.descendantCount).toBe(0);
-      expect(page4.descendantCount).toBe(1);
-      expect(page5.descendantCount).toBe(0);
+      expect(page1?.descendantCount).toBe(1);
+      expect(page2?.descendantCount).toBe(1);
+      expect(page3?.descendantCount).toBe(0);
+      expect(page4?.descendantCount).toBe(1);
+      expect(page5?.descendantCount).toBe(0);
       // check isEmpty
-      expect(page1.isEmpty).toBe(false);
-      expect(page2.isEmpty).toBe(true);
-      expect(page3.isEmpty).toBe(false);
-      expect(page4.isEmpty).toBe(false);
-      expect(page5.isEmpty).toBe(false);
+      expect(page1?.isEmpty).toBe(false);
+      expect(page2?.isEmpty).toBe(true);
+      expect(page3?.isEmpty).toBe(false);
+      expect(page4?.isEmpty).toBe(false);
+      expect(page5?.isEmpty).toBe(false);
       // check parent
-      expect(page1.parent).toStrictEqual(rootPage._id);
-      expect(page2.parent).toStrictEqual(page1._id);
-      expect(page3.parent).toStrictEqual(page2._id);
-      expect(page4.parent).toStrictEqual(rootPage._id);
-      expect(page5.parent).toStrictEqual(page4._id);
+      expect(page1?.parent).toStrictEqual(rootPage._id);
+      expect(page2?.parent).toStrictEqual(page1?._id);
+      expect(page3?.parent).toStrictEqual(page2?._id);
+      expect(page4?.parent).toStrictEqual(rootPage._id);
+      expect(page5?.parent).toStrictEqual(page4?._id);
     });
   });
 });

Разница между файлами не показана из-за своего большого размера
+ 492 - 212
apps/app/test/integration/service/v5.public-page.test.ts


+ 2 - 1
apps/app/test/integration/tsconfig.json

@@ -1,6 +1,7 @@
 {
   "extends": "../../tsconfig.json",
   "compilerOptions": {
-    "module": "CommonJS"
+    "module": "CommonJS",
+    "types": ["@types/jest"]
   }
 }

+ 3 - 7
biome.json

@@ -5,6 +5,7 @@
       "**",
       "!**/dist/**",
       "!**/node_modules/**",
+      "!**/.pnpm-store/**",
       "!**/coverage/**",
       "!**/vite.config.ts.timestamp-*",
       "!**/vite.server.config.ts.timestamp-*",
@@ -24,20 +25,18 @@
       "!apps/slackbot-proxy/src/public/bootstrap/**",
       "!packages/editor/**",
       "!packages/pdf-converter-client/src/index.ts",
+      "!packages/pdf-converter-client/specs/**",
       "!apps/app/playwright/**",
       "!apps/app/public/**",
       "!apps/app/src/client/**",
       "!apps/app/src/components/**",
-      "!apps/app/src/features/growi-plugin/**",
       "!apps/app/src/features/openai/**",
-      "!apps/app/src/features/rate-limiter/**",
       "!apps/app/src/models/**",
       "!apps/app/src/pages/**",
       "!apps/app/src/server/**",
       "!apps/app/src/services/**",
       "!apps/app/src/stores/**",
       "!apps/app/src/styles/**",
-      "!apps/app/test/integration/service/**",
       "!apps/app/test-with-vite/**",
       "!apps/app/tmp/**"
     ]
@@ -105,10 +104,7 @@
   },
   "overrides": [
     {
-      "includes": [
-        "apps/pdf-converter/**",
-        "./apps/slackbot-proxy/**"
-      ],
+      "includes": ["apps/pdf-converter/**", "./apps/slackbot-proxy/**"],
       "linter": {
         "rules": {
           "style": {

Некоторые файлы не были показаны из-за большого количества измененных файлов