Przeglądaj źródła

Merge pull request #10734 from growilabs/support/omit-jest

support: Omit jest and migrate to Vitest
Yuki Takei 2 miesięcy temu
rodzic
commit
35f14a4268
100 zmienionych plików z 1656 dodań i 1484 usunięć
  1. 2 7
      .serena/memories/coding_conventions.md
  2. 6 12
      .serena/memories/project_structure.md
  3. 2 7
      .serena/memories/task_completion_checklist.md
  4. 4 5
      .serena/memories/tech_stack.md
  5. 0 18
      .serena/memories/vitest-testing-tips-and-best-practices.md
  6. 1 2
      AGENTS.md
  7. 1 1
      apps/app/AGENTS.md
  8. 0 86
      apps/app/jest.config.js
  9. 0 1
      apps/app/nodemon.json
  10. 1 9
      apps/app/package.json
  11. 2 0
      apps/app/src/client/components/Me/AccessTokenScopeList.tsx
  12. 4 5
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group-relation.ts
  13. 4 4
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts
  14. 112 38
      apps/app/src/features/external-user-group/server/service/external-user-group-sync.integ.ts
  15. 105 107
      apps/app/src/features/external-user-group/server/service/ldap-user-group-sync.integ.ts
  16. 6 4
      apps/app/src/features/growi-plugin/server/routes/apiv3/admin/index.ts
  17. 16 12
      apps/app/src/features/openai/server/routes/ai-assistant.ts
  18. 13 9
      apps/app/src/features/openai/server/routes/ai-assistants.ts
  19. 13 11
      apps/app/src/features/openai/server/routes/delete-ai-assistant.ts
  20. 13 11
      apps/app/src/features/openai/server/routes/delete-thread.ts
  21. 19 20
      apps/app/src/features/openai/server/routes/edit/index.ts
  22. 23 12
      apps/app/src/features/openai/server/routes/get-recent-threads.ts
  23. 22 16
      apps/app/src/features/openai/server/routes/get-threads.ts
  24. 24 15
      apps/app/src/features/openai/server/routes/message/get-messages.ts
  25. 19 16
      apps/app/src/features/openai/server/routes/message/post-message.ts
  26. 4 3
      apps/app/src/features/openai/server/routes/middlewares/certify-ai-service.ts
  27. 11 13
      apps/app/src/features/openai/server/routes/set-default-ai-assistant.ts
  28. 19 12
      apps/app/src/features/openai/server/routes/thread.ts
  29. 13 11
      apps/app/src/features/openai/server/routes/update-ai-assistant.ts
  30. 2 3
      apps/app/src/features/page-bulk-export/server/routes/apiv3/page-bulk-export.ts
  31. 2 3
      apps/app/src/features/templates/server/routes/apiv3/index.ts
  32. 8 3
      apps/app/src/migrations/20210913153942-migrate-slack-app-integration-schema.integ.ts
  33. 29 25
      apps/app/src/server/crowi/index.ts
  34. 3 7
      apps/app/src/server/events/activity.ts
  35. 0 12
      apps/app/src/server/events/admin.js
  36. 14 0
      apps/app/src/server/events/admin.ts
  37. 0 15
      apps/app/src/server/events/bookmark.js
  38. 22 0
      apps/app/src/server/events/bookmark.ts
  39. 0 28
      apps/app/src/server/events/page.js
  40. 35 0
      apps/app/src/server/events/page.ts
  41. 0 14
      apps/app/src/server/events/tag.js
  42. 19 0
      apps/app/src/server/events/tag.ts
  43. 20 3
      apps/app/src/server/middlewares/admin-required.ts
  44. 6 3
      apps/app/src/server/middlewares/http-error-handler.ts
  45. 100 89
      apps/app/src/server/middlewares/login-required.spec.ts
  46. 31 6
      apps/app/src/server/middlewares/login-required.ts
  47. 13 25
      apps/app/src/server/models/page-redirect.integ.ts
  48. 75 32
      apps/app/src/server/models/page.integ.ts
  49. 1 1
      apps/app/src/server/models/update-post.spec.ts
  50. 59 30
      apps/app/src/server/models/user/user.integ.ts
  51. 305 225
      apps/app/src/server/models/v5.page.integ.ts
  52. 4 4
      apps/app/src/server/routes/apiv3/activity.ts
  53. 4 4
      apps/app/src/server/routes/apiv3/admin-home.ts
  54. 41 33
      apps/app/src/server/routes/apiv3/app-settings/file-upload-setting.integ.ts
  55. 4 4
      apps/app/src/server/routes/apiv3/app-settings/file-upload-setting.ts
  56. 4 4
      apps/app/src/server/routes/apiv3/app-settings/index.ts
  57. 3 7
      apps/app/src/server/routes/apiv3/attachment.js
  58. 2 3
      apps/app/src/server/routes/apiv3/bookmark-folder.ts
  59. 3 7
      apps/app/src/server/routes/apiv3/bookmarks.ts
  60. 4 4
      apps/app/src/server/routes/apiv3/customize-setting.js
  61. 5 3
      apps/app/src/server/routes/apiv3/export.js
  62. 4 4
      apps/app/src/server/routes/apiv3/g2g-transfer.ts
  63. 4 2
      apps/app/src/server/routes/apiv3/import.ts
  64. 2 3
      apps/app/src/server/routes/apiv3/in-app-notification.ts
  65. 4 4
      apps/app/src/server/routes/apiv3/markdown-setting.js
  66. 4 4
      apps/app/src/server/routes/apiv3/mongo.js
  67. 13 11
      apps/app/src/server/routes/apiv3/notification-setting.js
  68. 2 4
      apps/app/src/server/routes/apiv3/page-listing.ts
  69. 8 13
      apps/app/src/server/routes/apiv3/page/check-page-existence.ts
  70. 5 8
      apps/app/src/server/routes/apiv3/page/create-page.ts
  71. 76 68
      apps/app/src/server/routes/apiv3/page/get-page-paths-with-descendant-count.ts
  72. 6 10
      apps/app/src/server/routes/apiv3/page/get-yjs-data.ts
  73. 3 7
      apps/app/src/server/routes/apiv3/page/index.ts
  74. 6 12
      apps/app/src/server/routes/apiv3/page/publish-page.ts
  75. 48 51
      apps/app/src/server/routes/apiv3/page/sync-latest-revision-body-to-yjs-draft.ts
  76. 8 12
      apps/app/src/server/routes/apiv3/page/unpublish-page.ts
  77. 6 8
      apps/app/src/server/routes/apiv3/page/update-page.ts
  78. 5 8
      apps/app/src/server/routes/apiv3/pages/index.js
  79. 34 35
      apps/app/src/server/routes/apiv3/personal-setting/delete-access-token.ts
  80. 35 36
      apps/app/src/server/routes/apiv3/personal-setting/delete-all-access-tokens.ts
  81. 42 43
      apps/app/src/server/routes/apiv3/personal-setting/generate-access-token.ts
  82. 6 9
      apps/app/src/server/routes/apiv3/personal-setting/get-access-tokens.ts
  83. 2 3
      apps/app/src/server/routes/apiv3/personal-setting/index.js
  84. 2 4
      apps/app/src/server/routes/apiv3/revisions.js
  85. 4 2
      apps/app/src/server/routes/apiv3/search.js
  86. 4 4
      apps/app/src/server/routes/apiv3/security-settings/index.js
  87. 4 2
      apps/app/src/server/routes/apiv3/share-links.js
  88. 4 4
      apps/app/src/server/routes/apiv3/slack-integration-legacy-settings.js
  89. 4 4
      apps/app/src/server/routes/apiv3/slack-integration-settings.js
  90. 2 3
      apps/app/src/server/routes/apiv3/user-activities.ts
  91. 4 4
      apps/app/src/server/routes/apiv3/user-group-relation.js
  92. 4 4
      apps/app/src/server/routes/apiv3/user-group.js
  93. 5 8
      apps/app/src/server/routes/apiv3/user/get-related-groups.ts
  94. 6 9
      apps/app/src/server/routes/apiv3/users.js
  95. 2 4
      apps/app/src/server/routes/attachment/download.ts
  96. 2 4
      apps/app/src/server/routes/attachment/get-brand-logo.ts
  97. 2 4
      apps/app/src/server/routes/attachment/get.ts
  98. 5 3
      apps/app/src/server/routes/index.js
  99. 1 1
      apps/app/src/server/service/activity.ts
  100. 1 1
      apps/app/src/server/service/comment.ts

+ 2 - 7
.serena/memories/coding_conventions.md

@@ -49,13 +49,8 @@ vitest.workspace.mts の設定に基づく:
 
 ## ディレクトリ構造の規則
 - `src/`: ソースコード
-- `test/`: Jest用の古いテストファイル(廃止予定)
-- `test-with-vite/`: Vitest用の新しいテストファイル
-- `playwright/`: E2Eテストファイル
+- `test/`: Vitest用のファイル
+- `playwright/`: Playwright を用いた E2E テストファイル
 - `config/`: 設定ファイル
 - `public/`: 静的ファイル
 - `dist/`: ビルド出力
-
-## 移行ガイドライン
-- 新規開発: Biome + Vitest を使用
-- 既存コード: 段階的に Jest → Vitest に移行

+ 6 - 12
.serena/memories/project_structure.md

@@ -25,8 +25,7 @@ growi/
 ```
 apps/app/
 ├── src/                   # ソースコード
-├── test/                  # 古いJestテストファイル(廃止予定)
-├── test-with-vite/        # 新しいVitestテストファイル
+├── test/                  # Vitest用ファイル
 ├── playwright/            # E2Eテスト(Playwright)
 ├── config/                # 設定ファイル
 ├── public/                # 静的ファイル
@@ -37,24 +36,19 @@ apps/app/
 
 ## テストディレクトリの詳細
 
-### test/ (廃止予定)
-- Jest用の古いテストファイル
-- 段階的にtest-with-vite/に移行予定
-- 新規テストは作成しない
-
-### test-with-vite/
-- Vitest用の新しいテストファイル
-- 新規テストはここに作成
+### test/
+- Vitest用のファイル
+- 新規テスト用のユーティリティはここに作成
 - セットアップファイル: `setup/mongoms.ts` (MongoDB用)
 
 ### playwright/
-- E2Eテスト用ディレクトリ
+- Playwright による E2E テスト用ディレクトリ
 - ブラウザ操作を含むテスト
 
 ## テストファイルの配置ルール
 
 ### Vitestテストファイル
-以下のパターンでソースコードと同じディレクトリまたはtest-with-vite/配下に配置:
+以下のパターンでソースコードと同じディレクトリに配置:
 
 - **単体テスト**: `*.spec.{ts,js}`
 - **統合テスト**: `*.integ.ts` 

+ 2 - 7
.serena/memories/task_completion_checklist.md

@@ -74,16 +74,11 @@ pnpm run dev:migrate         # マイグレーション実行
 - **単体テスト**: `*.spec.{ts,js}` (Node.js環境)
 - **統合テスト**: `*.integ.ts` (Node.js + MongoDB環境)  
 - **コンポーネントテスト**: `*.spec.{tsx,jsx}` (happy-dom環境)
-- test-with-vite/ または対象ファイルと同じディレクトリに配置
-
-### 既存テストの修正
-- test/ 配下のJestテストは段階的に移行
-- 可能であればtest-with-vite/にVitestテストとして書き直し
+- 対象ファイルと同じディレクトリに配置
 
 ## コミット前の最終チェック
 1. Biome エラーが解消されているか
-2. Vitestテスト(または過渡期はJest)がパスしているか
-3. 重要な変更はPlaywright E2Eテストも実行
+2. Vitestテストがパスしているか
 4. ビルドが成功するか
 5. 変更による既存機能への影響がないか
 6. 適切なコミットメッセージを作成したか

+ 4 - 5
.serena/memories/tech_stack.md

@@ -9,11 +9,10 @@
 - **Jotai**: アトミック状態管理(推奨)
 - **SWR** ^2.3.2: データフェッチ・キャッシュ
 
-## 開発ツール移行状況
-| 従来 | 移行先 | 状況 |
-|------|--------|------|
-| ESLint | **Biome** | 新規推奨 |
-| Jest | **Vitest** + **Playwright** | 新規推奨 |
+## 開発ツール
+- **Biome**: フォーマッター、リンター
+- **Vitest**: テストフレームワーク
+- **Playwright**: e2eテストフレームワーク
 
 ## 主要コマンド
 ```bash

+ 0 - 18
.serena/memories/vitest-testing-tips-and-best-practices.md

@@ -75,21 +75,3 @@ pnpm run test:vitest:coverage
 # 特定ファイルのみ実行(coverageあり)
 pnpm run test:vitest src/path/to/test.spec.tsx
 ```
-
-### package.jsonスクリプト参照
-```json
-{
-  "scripts": {
-    "test": "run-p test:*",
-    "test:jest": "cross-env NODE_ENV=test TS_NODE_PROJECT=test/integration/tsconfig.json jest",
-    "test:vitest": "vitest run --coverage"
-  }
-}
-```
-
-## Jest→Vitest移行要点
-- `jest.config.js` → `vitest.config.ts`
-- `@types/jest` → `vitest/globals`
-- ESModulesネイティブサポート → 高速起動・実行
-
-この設定により型安全性と保守性を両立した高品質テストが可能。

+ 1 - 2
AGENTS.md

@@ -37,11 +37,10 @@ GROWI is a team collaboration software using markdown - a wiki platform with hie
 - `cd apps/app && pnpm run dev:migrate:down` - Rollback last migration
 
 ### Testing and Quality
-- `turbo run test @apps/app` - Run Jest and Vitest test suites with coverage
+- `turbo run test @apps/app` - Run Vitest test suites with coverage
 - `turbo run lint @apps/app` - Run all linters (TypeScript, Biome, Stylelint, OpenAPI)
 - `cd apps/app && pnpm run lint:typecheck` - TypeScript type checking only
 - `cd apps/app && pnpm run test:vitest` - Run Vitest unit tests
-- `cd apps/app && pnpm run test:jest` - Run Jest integration tests
 
 ### Development Utilities  
 - `cd apps/app && pnpm run repl` - Start Node.js REPL with application context loaded

+ 1 - 1
apps/app/AGENTS.md

@@ -42,7 +42,7 @@ This guide provides comprehensive documentation for AI coding agents working on
   - **Unstated**: Legacy (being phased out, replaced by Jotai)
 - **Testing**: 
   - Vitest for unit tests (`*.spec.ts`, `*.spec.tsx`)
-  - Jest for integration tests (`*.integ.ts`)
+  - Vitest for integration tests (`*.integ.ts`)
   - React Testing Library for component testing
   - Playwright for E2E testing
 - **i18n**: next-i18next for internationalization

+ 0 - 86
apps/app/jest.config.js

@@ -1,86 +0,0 @@
-// For a detailed explanation regarding each configuration property, visit:
-// https://jestjs.io/docs/en/configuration.html
-
-const MODULE_NAME_MAPPING = {
-  '^\\^/(.+)$': '<rootDir>/$1',
-  '^~/(.+)$': '<rootDir>/src/$1',
-};
-
-module.exports = {
-  // Indicates whether each individual test should be reported during the run
-  verbose: true,
-
-  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
-
-  projects: [
-    {
-      displayName: 'server',
-
-      transform: {
-        '^.+\\.(t|j)sx?$': '@swc-node/jest',
-      },
-
-      rootDir: '.',
-      roots: ['<rootDir>'],
-      testMatch: [
-        '<rootDir>/test/integration/**/*.test.ts',
-        '<rootDir>/test/integration/**/*.test.js',
-      ],
-      // https://regex101.com/r/jTaxYS/1
-      modulePathIgnorePatterns: [
-        '<rootDir>/test/integration/*.*/v5(..*)*.[t|j]s',
-      ],
-      testEnvironment: 'node',
-      globalSetup: '<rootDir>/test/integration/global-setup.js',
-      globalTeardown: '<rootDir>/test/integration/global-teardown.js',
-      setupFilesAfterEnv: ['<rootDir>/test/integration/setup.js'],
-
-      // Automatically clear mock calls and instances between every test
-      clearMocks: true,
-      moduleNameMapper: MODULE_NAME_MAPPING,
-    },
-    {
-      displayName: 'server-v5',
-
-      transform: {
-        '^.+\\.(t|j)sx?$': '@swc-node/jest',
-      },
-
-      rootDir: '.',
-      roots: ['<rootDir>'],
-      testMatch: [
-        '<rootDir>/test/integration/**/v5.*.test.ts',
-        '<rootDir>/test/integration/**/v5.*.test.js',
-      ],
-
-      testEnvironment: 'node',
-      globalSetup: '<rootDir>/test/integration/global-setup.js',
-      globalTeardown: '<rootDir>/test/integration/global-teardown.js',
-      setupFilesAfterEnv: ['<rootDir>/test/integration/setup.js'],
-
-      // Automatically clear mock calls and instances between every test
-      clearMocks: true,
-      moduleNameMapper: MODULE_NAME_MAPPING,
-    },
-  ],
-
-  // Automatically clear mock calls and instances between every test
-  clearMocks: true,
-
-  // Indicates whether the coverage information should be collected while executing the test
-  collectCoverage: true,
-
-  // An array of glob patterns indicating a set of files for which coverage information should be collected
-  // collectCoverageFrom: undefined,
-
-  // The directory where Jest should output its coverage files
-  coverageDirectory: 'coverage',
-
-  // An array of regexp pattern strings used to skip coverage collection
-  coveragePathIgnorePatterns: [
-    'index.ts',
-    '/config/',
-    '/resource/',
-    '/node_modules/',
-  ],
-};

+ 0 - 1
apps/app/nodemon.json

@@ -9,7 +9,6 @@
     "src/client",
     "src/**/client",
     "test",
-    "test-with-vite",
     "tmp",
     "*.mongodb.js"
   ]

+ 1 - 9
apps/app/package.json

@@ -34,11 +34,9 @@
     "lint": "run-p lint:**",
     "prelint:openapi:apiv3": "pnpm run openapi:generate-spec:apiv3",
     "prelint:openapi:apiv1": "pnpm run openapi:generate-spec:apiv1",
-    "test": "run-p test:jest test:vitest:coverage",
-    "test:jest": "cross-env NODE_ENV=test TS_NODE_PROJECT=test/integration/tsconfig.json jest",
+    "test": "pnpm run test:vitest:coverage",
     "test:vitest": "vitest run",
     "test:vitest:coverage": "COLUMNS=200 vitest run --coverage",
-    "jest:run": "cross-env NODE_ENV=test TS_NODE_PROJECT=test/integration/tsconfig.json jest --passWithNoTests -- ",
     "reg:run": "reg-suit run",
     "//// misc": "",
     "console": "npm run repl",
@@ -274,8 +272,6 @@
     "@headless-tree/react": "^1.5.1",
     "@next/bundle-analyzer": "^14.1.3",
     "@popperjs/core": "^2.11.8",
-    "@swc-node/jest": "^1.8.1",
-    "@swc/jest": "^0.2.36",
     "@tanstack/react-virtual": "^3.13.12",
     "@testing-library/jest-dom": "^6.5.0",
     "@testing-library/user-event": "^14.5.2",
@@ -283,7 +279,6 @@
     "@types/bunyan": "^1.8.11",
     "@types/express": "^4.17.21",
     "@types/hast": "^3.0.4",
-    "@types/jest": "^29.5.2",
     "@types/js-cookie": "^3.0.6",
     "@types/ldapjs": "^2.2.5",
     "@types/mdast": "^4.0.4",
@@ -316,9 +311,6 @@
     "i18next-hmr": "^3.1.3",
     "i18next-http-backend": "^2.6.2",
     "i18next-localstorage-backend": "^4.2.0",
-    "jest": "^29.5.0",
-    "jest-date-mock": "^1.0.8",
-    "jest-localstorage-mock": "^2.4.14",
     "jotai-devtools": "^0.11.0",
     "load-css-file": "^1.0.0",
     "material-icons": "^1.11.3",

+ 2 - 0
apps/app/src/client/components/Me/AccessTokenScopeList.tsx

@@ -9,6 +9,8 @@ import styles from './AccessTokenScopeList.module.scss';
 
 const moduleClass = styles['access-token-scope-list'] ?? '';
 
+// biome-ignore lint/suspicious/noTsIgnore: Suppress auto fix by lefthook
+// @ts-ignore - Scope type causes "Type instantiation is excessively deep" with tsgo
 interface scopeObject {
   [key: string]: Scope | scopeObject;
 }

+ 4 - 5
apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group-relation.ts

@@ -6,6 +6,8 @@ import type { IExternalUserGroupRelationHasId } from '~/features/external-user-g
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import adminRequiredFactory from '~/server/middlewares/admin-required';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import { serializeUserGroupRelationSecurely } from '~/server/models/serializers/user-group-relation-serializer';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
@@ -14,14 +16,11 @@ const logger = loggerFactory('growi:routes:apiv3:user-group-relation');
 
 const express = require('express');
 const { query } = require('express-validator');
-
 const router = express.Router();
 
 module.exports = (crowi: Crowi): Router => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
-  const adminRequired = require('~/server/middlewares/admin-required')(crowi);
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
+  const adminRequired = adminRequiredFactory(crowi);
 
   const validators = {
     list: [

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

@@ -12,7 +12,9 @@ import type { PageActionOnGroupDelete } from '~/interfaces/user-group';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import adminRequiredFactory from '~/server/middlewares/admin-required';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import { serializeUserGroupRelationSecurely } from '~/server/models/serializers/user-group-relation-serializer';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import { configManager } from '~/server/service/config-manager';
@@ -43,10 +45,8 @@ interface AuthorizedRequest extends Request {
  *            type: number
  */
 module.exports = (crowi: Crowi): Router => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
-  const adminRequired = require('~/server/middlewares/admin-required')(crowi);
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
+  const adminRequired = adminRequiredFactory(crowi);
   const addActivity = generateAddActivityMiddleware();
 
   const activityEvent = crowi.events.activity;

+ 112 - 38
apps/app/test/integration/service/external-user-group-sync.test.ts → apps/app/src/features/external-user-group/server/service/external-user-group-sync.integ.ts

@@ -1,21 +1,35 @@
-import type { IUserHasId } from '@growi/core';
+import type { IPage, IUserHasId } from '@growi/core';
 import mongoose, { Types } from 'mongoose';
-
 import {
-  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';
-import PassportService from '../../../src/server/service/passport';
-import { getInstance } from '../setup-crowi';
+  afterEach,
+  beforeAll,
+  beforeEach,
+  describe,
+  expect,
+  it,
+  vi,
+} from 'vitest';
+import { mock } from 'vitest-mock-extended';
+
+import { getInstance } from '^/test/setup/crowi';
+
+import type Crowi from '~/server/crowi';
+import ExternalAccount from '~/server/models/external-account';
+import type { PageModel } from '~/server/models/page';
+import { configManager } from '~/server/service/config-manager';
+import instanciateExternalAccountService from '~/server/service/external-account';
+import type PassportService from '~/server/service/passport';
+import type { S2sMessagingService } from '~/server/service/s2s-messaging/base';
+
+import type {
+  ExternalUserGroupTreeNode,
+  IExternalUserGroup,
+  IExternalUserGroupHasId,
+} from '../../interfaces/external-user-group';
+import { ExternalGroupProviderType } from '../../interfaces/external-user-group';
+import ExternalUserGroup from '../models/external-user-group';
+import ExternalUserGroupRelation from '../models/external-user-group-relation';
+import ExternalUserGroupSyncService from './external-user-group-sync';
 
 // dummy class to implement generateExternalUserGroupTrees which returns test data
 class TestExternalUserGroupSyncService extends ExternalUserGroupSyncService {
@@ -41,7 +55,6 @@ class TestExternalUserGroupSyncService extends ExternalUserGroupSyncService {
     };
     const parentNode: ExternalUserGroupTreeNode = {
       id: 'cn=parentGroup,ou=groups,dc=example,dc=org',
-      // name is undefined
       userInfos: [
         {
           id: 'parentGroupUser',
@@ -55,7 +68,6 @@ class TestExternalUserGroupSyncService extends ExternalUserGroupSyncService {
     };
     const grandParentNode: ExternalUserGroupTreeNode = {
       id: 'cn=grandParentGroup,ou=groups,dc=example,dc=org',
-      // email is undefined
       userInfos: [
         {
           id: 'grandParentGroupUser',
@@ -87,8 +99,6 @@ class TestExternalUserGroupSyncService extends ExternalUserGroupSyncService {
   }
 }
 
-const testService = new TestExternalUserGroupSyncService(null, null);
-
 const checkGroup = (
   group: IExternalUserGroupHasId,
   expected: Omit<IExternalUserGroup, 'createdAt'>,
@@ -107,7 +117,8 @@ const checkSync = async (autoGenerateUserOnGroupSync = true) => {
   const grandParentGroup = await ExternalUserGroup.findOne({
     name: 'grandParentGroup',
   });
-  checkGroup(grandParentGroup, {
+  expect(grandParentGroup).not.toBeNull();
+  checkGroup(grandParentGroup!, {
     externalId: 'cn=grandParentGroup,ou=groups,dc=example,dc=org',
     name: 'grandParentGroup',
     description: 'this is a grand parent group',
@@ -116,27 +127,30 @@ const checkSync = async (autoGenerateUserOnGroupSync = true) => {
   });
 
   const parentGroup = await ExternalUserGroup.findOne({ name: 'parentGroup' });
-  checkGroup(parentGroup, {
+  expect(parentGroup).not.toBeNull();
+  checkGroup(parentGroup!, {
     externalId: 'cn=parentGroup,ou=groups,dc=example,dc=org',
     name: 'parentGroup',
     description: 'this is a parent group',
     provider: 'ldap',
-    parent: grandParentGroup._id,
+    parent: grandParentGroup!._id,
   });
 
   const childGroup = await ExternalUserGroup.findOne({ name: 'childGroup' });
-  checkGroup(childGroup, {
+  expect(childGroup).not.toBeNull();
+  checkGroup(childGroup!, {
     externalId: 'cn=childGroup,ou=groups,dc=example,dc=org',
     name: 'childGroup',
     description: 'this is a child group',
     provider: 'ldap',
-    parent: parentGroup._id,
+    parent: parentGroup!._id,
   });
 
   const previouslySyncedGroup = await ExternalUserGroup.findOne({
     name: 'previouslySyncedGroup',
   });
-  checkGroup(previouslySyncedGroup, {
+  expect(previouslySyncedGroup).not.toBeNull();
+  checkGroup(previouslySyncedGroup!, {
     externalId: 'cn=previouslySyncedGroup,ou=groups,dc=example,dc=org',
     name: 'previouslySyncedGroup',
     description: 'this is a previouslySynced group',
@@ -145,16 +159,16 @@ const checkSync = async (autoGenerateUserOnGroupSync = true) => {
   });
 
   const grandParentGroupRelations = await ExternalUserGroupRelation.find({
-    relatedGroup: grandParentGroup._id,
+    relatedGroup: grandParentGroup!._id,
   });
   const parentGroupRelations = await ExternalUserGroupRelation.find({
-    relatedGroup: parentGroup._id,
+    relatedGroup: parentGroup!._id,
   });
   const childGroupRelations = await ExternalUserGroupRelation.find({
-    relatedGroup: childGroup._id,
+    relatedGroup: childGroup!._id,
   });
   const previouslySyncedGroupRelations = await ExternalUserGroupRelation.find({
-    relatedGroup: previouslySyncedGroup._id,
+    relatedGroup: previouslySyncedGroup!._id,
   });
 
   if (autoGenerateUserOnGroupSync) {
@@ -205,7 +219,7 @@ const checkSync = async (autoGenerateUserOnGroupSync = true) => {
       'previouslySyncedGroupUser',
     );
 
-    const userPages = await mongoose.model('Page').find({
+    const userPages = await mongoose.model<IPage>('Page').find({
       path: {
         $in: [
           '/user/childGroupUser',
@@ -225,16 +239,76 @@ const checkSync = async (autoGenerateUserOnGroupSync = true) => {
 };
 
 describe('ExternalUserGroupSyncService.syncExternalUserGroups', () => {
-  let crowi: Crowi;
+  let testService: TestExternalUserGroupSyncService;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  let Page: PageModel;
+  let rootPageId: Types.ObjectId;
+  let userPageId: Types.ObjectId;
 
   beforeAll(async () => {
-    crowi = await getInstance();
+    // Initialize configManager
+    const s2sMessagingServiceMock = mock<S2sMessagingService>();
+    configManager.setS2sMessagingService(s2sMessagingServiceMock);
+    await configManager.loadConfigs();
+
+    const crowi: Crowi = await getInstance();
+
+    // Initialize models with crowi mock
+    const pageModule = await import('~/server/models/page');
+    Page = pageModule.default(crowi);
+
+    const userModule = await import('~/server/models/user/index');
+    userModule.default(crowi);
+
+    // Initialize services with mocked PassportService
     await configManager.updateConfig('app:isV5Compatible', true);
-    const passportService = new PassportService(crowi);
-    instanciateExternalAccountService(passportService);
+
+    // Create PassportService mock with required methods for externalAccountService
+    const passportServiceMock = mock<PassportService>({
+      isSameUsernameTreatedAsIdenticalUser: vi.fn().mockReturnValue(false),
+      isSameEmailTreatedAsIdenticalUser: vi.fn().mockReturnValue(false),
+    });
+    instanciateExternalAccountService(passportServiceMock);
+
+    // Create root page and /user page for UserEvent.onActivated to work
+    rootPageId = new Types.ObjectId();
+    userPageId = new Types.ObjectId();
+
+    // Check if root page already exists
+    const existingRootPage = await Page.findOne({ path: '/' });
+    if (existingRootPage == null) {
+      await Page.insertMany([
+        {
+          _id: rootPageId,
+          path: '/',
+          grant: Page.GRANT_PUBLIC,
+        },
+      ]);
+    } else {
+      rootPageId = existingRootPage._id;
+    }
+
+    // Check if /user page already exists
+    const existingUserPage = await Page.findOne({ path: '/user' });
+    if (existingUserPage == null) {
+      await Page.insertMany([
+        {
+          _id: userPageId,
+          path: '/user',
+          grant: Page.GRANT_PUBLIC,
+          parent: rootPageId,
+          isEmpty: true,
+        },
+      ]);
+    } else {
+      userPageId = existingUserPage._id;
+    }
   });
 
   beforeEach(async () => {
+    // Create new testService instance for each test to reset syncStatus
+    testService = new TestExternalUserGroupSyncService(null, null);
+
     await ExternalUserGroup.create({
       name: 'nameBeforeEdit',
       description: 'this is a description before edit',
@@ -284,7 +358,7 @@ describe('ExternalUserGroupSyncService.syncExternalUserGroups', () => {
       'external-user-group:ldap:preserveDeletedGroups': false,
     };
 
-    beforeAll(async () => {
+    beforeEach(async () => {
       await configManager.updateConfigs(configParams);
     });
 
@@ -300,7 +374,7 @@ describe('ExternalUserGroupSyncService.syncExternalUserGroups', () => {
       'external-user-group:ldap:preserveDeletedGroups': true,
     };
 
-    beforeAll(async () => {
+    beforeEach(async () => {
       await configManager.updateConfigs(configParams);
     });
 
@@ -316,7 +390,7 @@ describe('ExternalUserGroupSyncService.syncExternalUserGroups', () => {
       'external-user-group:ldap:preserveDeletedGroups': false,
     };
 
-    beforeAll(async () => {
+    beforeEach(async () => {
       await configManager.updateConfigs(configParams);
 
       const groupId = new Types.ObjectId();

+ 105 - 107
apps/app/test/integration/service/ldap-user-group-sync.test.ts → apps/app/src/features/external-user-group/server/service/ldap-user-group-sync.integ.ts

@@ -1,11 +1,16 @@
 import ldap, { type Client } from 'ldapjs';
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { mock } from 'vitest-mock-extended';
 
-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';
+import { getInstance } from '^/test/setup/crowi';
+
+import type Crowi from '~/server/crowi';
+import { configManager } from '~/server/service/config-manager';
+import { ldapService } from '~/server/service/ldap';
+import PassportService from '~/server/service/passport';
+import type { S2sMessagingService } from '~/server/service/s2s-messaging/base';
+
+import { LdapUserGroupSyncService } from './ldap-user-group-sync';
 
 describe('LdapUserGroupSyncService.generateExternalUserGroupTrees', () => {
   let crowi: Crowi;
@@ -23,10 +28,10 @@ describe('LdapUserGroupSyncService.generateExternalUserGroupTrees', () => {
       'ldap://openldap:1389/dc=example,dc=org',
   };
 
-  jest.mock('../../../src/server/service/ldap');
-  const mockBind = jest.spyOn(ldapService, 'bind');
-  const mockLdapSearch = jest.spyOn(ldapService, 'search');
-  const mockLdapCreateClient = jest.spyOn(ldap, 'createClient');
+  const mockBind = vi.spyOn(ldapService, 'bind');
+  const mockSearchGroupDir = vi.spyOn(ldapService, 'searchGroupDir');
+  const mockSearch = vi.spyOn(ldapService, 'search');
+  const mockLdapCreateClient = vi.spyOn(ldap, 'createClient');
 
   beforeAll(async () => {
     crowi = await getInstance();
@@ -42,77 +47,79 @@ describe('LdapUserGroupSyncService.generateExternalUserGroupTrees', () => {
     const passportService = new PassportService(crowi);
     ldapUserGroupSyncService = new LdapUserGroupSyncService(
       passportService,
-      null,
+      mock<S2sMessagingService>(),
       null,
     );
   });
 
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
   describe('When there is no circular reference in group tree', () => {
     it('creates ExternalUserGroupTrees', async () => {
-      // mock search on LDAP server
-      mockLdapSearch.mockImplementation((filter, base) => {
-        if (base === 'ou=groups,dc=example,dc=org') {
-          // search groups
-          return Promise.resolve([
+      // mock searchGroupDir for group entries
+      mockSearchGroupDir.mockResolvedValue([
+        {
+          objectName: 'cn=childGroup,ou=groups,dc=example,dc=org',
+          attributes: [
+            { type: 'cn', values: ['childGroup'] },
+            { type: 'description', values: ['this is a child group'] },
             {
-              objectName: 'cn=childGroup,ou=groups,dc=example,dc=org',
-              attributes: [
-                { type: 'cn', values: ['childGroup'] },
-                { type: 'description', values: ['this is a child group'] },
-                {
-                  type: 'member',
-                  values: ['cn=childGroupUser,ou=users,dc=example,dc=org'],
-                },
-              ],
+              type: 'member',
+              values: ['cn=childGroupUser,ou=users,dc=example,dc=org'],
             },
+          ],
+        },
+        {
+          objectName: 'cn=parentGroup,ou=groups,dc=example,dc=org',
+          attributes: [
+            { type: 'cn', values: ['parentGroup'] },
+            { type: 'description', values: ['this is a parent group'] },
             {
-              objectName: 'cn=parentGroup,ou=groups,dc=example,dc=org',
-              attributes: [
-                { type: 'cn', values: ['parentGroup'] },
-                { 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',
-                  ],
-                },
+              type: 'member',
+              values: [
+                'cn=childGroup,ou=groups,dc=example,dc=org',
+                'cn=parentGroupUser,ou=users,dc=example,dc=org',
               ],
             },
-            // root node
+          ],
+        },
+        // root node
+        {
+          objectName: 'cn=grandParentGroup,ou=groups,dc=example,dc=org',
+          attributes: [
+            { type: 'cn', values: ['grandParentGroup'] },
             {
-              objectName: 'cn=grandParentGroup,ou=groups,dc=example,dc=org',
-              attributes: [
-                { type: 'cn', values: ['grandParentGroup'] },
-                {
-                  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',
-                  ],
-                },
-              ],
+              type: 'description',
+              values: ['this is a grand parent group'],
             },
-            // another root node
             {
-              objectName: 'cn=rootGroup,ou=groups,dc=example,dc=org',
-              attributes: [
-                { type: 'cn', values: ['rootGroup'] },
-                { type: 'description', values: ['this is a root group'] },
-                {
-                  type: 'member',
-                  values: ['cn=rootGroupUser,ou=users,dc=example,dc=org'],
-                },
+              type: 'member',
+              values: [
+                'cn=parentGroup,ou=groups,dc=example,dc=org',
+                'cn=grandParentGroupUser,ou=users,dc=example,dc=org',
               ],
             },
-          ]);
-        }
+          ],
+        },
+        // another root node
+        {
+          objectName: 'cn=rootGroup,ou=groups,dc=example,dc=org',
+          attributes: [
+            { type: 'cn', values: ['rootGroup'] },
+            { type: 'description', values: ['this is a root group'] },
+            {
+              type: 'member',
+              values: ['cn=rootGroupUser,ou=users,dc=example,dc=org'],
+            },
+          ],
+        },
+      ]);
+
+      // mock search for user lookups
+      mockSearch.mockImplementation((_filter, base) => {
         if (base === 'cn=childGroupUser,ou=users,dc=example,dc=org') {
-          // search childGroupUser
           return Promise.resolve([
             {
               objectName: 'cn=childGroupUser,ou=users,dc=example,dc=org',
@@ -124,7 +131,6 @@ describe('LdapUserGroupSyncService.generateExternalUserGroupTrees', () => {
             },
           ]);
         }
-        // search parentGroupUser
         if (base === 'cn=parentGroupUser,ou=users,dc=example,dc=org') {
           return Promise.resolve([
             {
@@ -137,7 +143,6 @@ describe('LdapUserGroupSyncService.generateExternalUserGroupTrees', () => {
             },
           ]);
         }
-        // search grandParentGroupUser
         if (base === 'cn=grandParentGroupUser,ou=users,dc=example,dc=org') {
           return Promise.resolve([
             {
@@ -150,7 +155,6 @@ describe('LdapUserGroupSyncService.generateExternalUserGroupTrees', () => {
             },
           ]);
         }
-        // search rootGroupUser
         if (base === 'cn=rootGroupUser,ou=users,dc=example,dc=org') {
           return Promise.resolve([
             {
@@ -243,52 +247,46 @@ describe('LdapUserGroupSyncService.generateExternalUserGroupTrees', () => {
 
   describe('When there is a circular reference in group tree', () => {
     it('rejects creating ExternalUserGroupTrees', async () => {
-      // mock search on LDAP server
-      mockLdapSearch.mockImplementation((filter, base) => {
-        if (base === 'ou=groups,dc=example,dc=org') {
-          // search groups
-          return Promise.resolve([
-            // childGroup and parentGroup have circular reference
+      // mock searchGroupDir for group entries with circular reference
+      mockSearchGroupDir.mockResolvedValue([
+        // childGroup and parentGroup have circular reference
+        {
+          objectName: 'cn=childGroup,ou=groups,dc=example,dc=org',
+          attributes: [
+            { type: 'cn', values: ['childGroup'] },
+            { type: 'description', values: ['this is a child group'] },
             {
-              objectName: 'cn=childGroup,ou=groups,dc=example,dc=org',
-              attributes: [
-                { type: 'cn', values: ['childGroup'] },
-                { type: 'description', values: ['this is a child group'] },
-                {
-                  type: 'member',
-                  values: ['cn=parentGroup,ou=groups,dc=example,dc=org'],
-                },
-              ],
+              type: 'member',
+              values: ['cn=parentGroup,ou=groups,dc=example,dc=org'],
             },
+          ],
+        },
+        {
+          objectName: 'cn=parentGroup,ou=groups,dc=example,dc=org',
+          attributes: [
+            { type: 'cn', values: ['parentGroup'] },
+            { type: 'description', values: ['this is a parent group'] },
             {
-              objectName: 'cn=parentGroup,ou=groups,dc=example,dc=org',
-              attributes: [
-                { type: 'cn', values: ['parentGroup'] },
-                { type: 'description', values: ['this is a parent group'] },
-                {
-                  type: 'member',
-                  values: ['cn=childGroup,ou=groups,dc=example,dc=org'],
-                },
-              ],
+              type: 'member',
+              values: ['cn=childGroup,ou=groups,dc=example,dc=org'],
             },
+          ],
+        },
+        {
+          objectName: 'cn=grandParentGroup,ou=groups,dc=example,dc=org',
+          attributes: [
+            { type: 'cn', values: ['grandParentGroup'] },
             {
-              objectName: 'cn=grandParentGroup,ou=groups,dc=example,dc=org',
-              attributes: [
-                { type: 'cn', values: ['grandParentGroup'] },
-                {
-                  type: 'description',
-                  values: ['this is a grand parent group'],
-                },
-                {
-                  type: 'member',
-                  values: ['cn=parentGroup,ou=groups,dc=example,dc=org'],
-                },
-              ],
+              type: 'description',
+              values: ['this is a grand parent group'],
             },
-          ]);
-        }
-        return Promise.reject(new Error('not found'));
-      });
+            {
+              type: 'member',
+              values: ['cn=parentGroup,ou=groups,dc=example,dc=org'],
+            },
+          ],
+        },
+      ]);
 
       await expect(
         ldapUserGroupSyncService?.generateExternalUserGroupTrees(),

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

@@ -6,6 +6,8 @@ import mongoose from 'mongoose';
 
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import adminRequiredFactory from '~/server/middlewares/admin-required';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 
 import { GrowiPlugin } from '../../../models';
@@ -28,15 +30,15 @@ const validator = {
 };
 
 module.exports = (crowi: Crowi): Router => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
-  const adminRequired = require('~/server/middlewares/admin-required')(crowi);
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
+  const adminRequired = adminRequiredFactory(crowi);
 
   const router = express.Router();
 
   router.get(
     '/',
+    // biome-ignore lint/suspicious/noTsIgnore: Suppress auto fix by lefthook
+    // @ts-ignore - Scope type causes "Type instantiation is excessively deep" with tsgo
     accessTokenParser([SCOPE.READ.ADMIN.PLUGIN]),
     loginRequiredStrictly,
     adminRequired,

+ 16 - 12
apps/app/src/features/openai/server/routes/ai-assistant.ts

@@ -1,3 +1,4 @@
+import assert from 'node:assert';
 import type { IUserHasId } from '@growi/core';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
@@ -6,6 +7,7 @@ import type { Request, RequestHandler } from 'express';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
@@ -16,18 +18,14 @@ import { upsertAiAssistantValidator } from './middlewares/upsert-ai-assistant-va
 
 const logger = loggerFactory('growi:routes:apiv3:openai:create-ai-assistant');
 
-type CreateAssistantFactory = (crowi: Crowi) => RequestHandler[];
-
 type ReqBody = UpsertAiAssistantData;
 
-type Req = Request<undefined, Response, ReqBody> & {
-  user: IUserHasId;
+type Req = Request<Record<string, string>, ApiV3Response, ReqBody> & {
+  user?: IUserHasId;
 };
 
-export const createAiAssistantFactory: CreateAssistantFactory = (crowi) => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
+export const createAiAssistantFactory = (crowi: Crowi): RequestHandler[] => {
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
   return [
     accessTokenParser([SCOPE.WRITE.FEATURES.AI_ASSISTANT], {
@@ -35,20 +33,26 @@ export const createAiAssistantFactory: CreateAssistantFactory = (crowi) => {
     }),
     loginRequiredStrictly,
     certifyAiService,
-    upsertAiAssistantValidator,
+    ...upsertAiAssistantValidator,
     apiV3FormValidator,
     async (req: Req, res: ApiV3Response) => {
+      const { user } = req;
+      assert(
+        user != null,
+        'user is required (ensured by loginRequiredStrictly middleware)',
+      );
+
       const openaiService = getOpenaiService();
       if (openaiService == null) {
         return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501);
       }
 
       try {
-        const aiAssistantData = { ...req.body, owner: req.user._id };
+        const aiAssistantData = { ...req.body, owner: user._id };
 
         const isLearnablePageLimitExceeded =
           await openaiService.isLearnablePageLimitExceeded(
-            req.user,
+            user,
             aiAssistantData.pagePathPatterns,
           );
         if (isLearnablePageLimitExceeded) {
@@ -60,7 +64,7 @@ export const createAiAssistantFactory: CreateAssistantFactory = (crowi) => {
 
         const aiAssistant = await openaiService.createAiAssistant(
           req.body,
-          req.user,
+          user,
         );
 
         return res.apiv3({ aiAssistant });

+ 13 - 9
apps/app/src/features/openai/server/routes/ai-assistants.ts

@@ -1,3 +1,4 @@
+import assert from 'node:assert';
 import type { IUserHasId } from '@growi/core';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
@@ -5,6 +6,7 @@ import type { Request, RequestHandler } from 'express';
 
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
@@ -13,16 +15,12 @@ import { certifyAiService } from './middlewares/certify-ai-service';
 
 const logger = loggerFactory('growi:routes:apiv3:openai:get-ai-assistants');
 
-type GetAiAssistantsFactory = (crowi: Crowi) => RequestHandler[];
-
-type Req = Request<undefined, Response, undefined> & {
-  user: IUserHasId;
+type Req = Request<Record<string, string>, ApiV3Response, undefined> & {
+  user?: IUserHasId;
 };
 
-export const getAiAssistantsFactory: GetAiAssistantsFactory = (crowi) => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
+export const getAiAssistantsFactory = (crowi: Crowi): RequestHandler[] => {
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
   return [
     accessTokenParser([SCOPE.READ.FEATURES.AI_ASSISTANT], {
@@ -31,6 +29,12 @@ export const getAiAssistantsFactory: GetAiAssistantsFactory = (crowi) => {
     loginRequiredStrictly,
     certifyAiService,
     async (req: Req, res: ApiV3Response) => {
+      const { user } = req;
+      assert(
+        user != null,
+        'user is required (ensured by loginRequiredStrictly middleware)',
+      );
+
       const openaiService = getOpenaiService();
       if (openaiService == null) {
         return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501);
@@ -38,7 +42,7 @@ export const getAiAssistantsFactory: GetAiAssistantsFactory = (crowi) => {
 
       try {
         const accessibleAiAssistants =
-          await openaiService.getAccessibleAiAssistants(req.user);
+          await openaiService.getAccessibleAiAssistants(user);
 
         return res.apiv3({ accessibleAiAssistants });
       } catch (err) {

+ 13 - 11
apps/app/src/features/openai/server/routes/delete-ai-assistant.ts

@@ -1,13 +1,15 @@
+import assert from 'node:assert';
 import type { IUserHasId } from '@growi/core';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
-import { param, type ValidationChain } from 'express-validator';
+import { param } from 'express-validator';
 import { isHttpError } from 'http-errors';
 
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
@@ -16,22 +18,18 @@ import { certifyAiService } from './middlewares/certify-ai-service';
 
 const logger = loggerFactory('growi:routes:apiv3:openai:delete-ai-assistants');
 
-type DeleteAiAssistantsFactory = (crowi: Crowi) => RequestHandler[];
-
 type ReqParams = {
   id: string;
 };
 
-type Req = Request<ReqParams, Response, undefined> & {
-  user: IUserHasId;
+type Req = Request<ReqParams, ApiV3Response, undefined> & {
+  user?: IUserHasId;
 };
 
-export const deleteAiAssistantsFactory: DeleteAiAssistantsFactory = (crowi) => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
+export const deleteAiAssistantsFactory = (crowi: Crowi): RequestHandler[] => {
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
-  const validator: ValidationChain[] = [
+  const validator = [
     param('id').isMongoId().withMessage('aiAssistant id is required'),
   ];
 
@@ -41,11 +39,15 @@ export const deleteAiAssistantsFactory: DeleteAiAssistantsFactory = (crowi) => {
     }),
     loginRequiredStrictly,
     certifyAiService,
-    validator,
+    ...validator,
     apiV3FormValidator,
     async (req: Req, res: ApiV3Response) => {
       const { id } = req.params;
       const { user } = req;
+      assert(
+        user != null,
+        'user is required (ensured by loginRequiredStrictly middleware)',
+      );
 
       try {
         const deletedAiAssistant = await deleteAiAssistant(user._id, id);

+ 13 - 11
apps/app/src/features/openai/server/routes/delete-thread.ts

@@ -1,13 +1,15 @@
+import assert from 'node:assert';
 import type { IUserHasId } from '@growi/core';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
-import { param, type ValidationChain } from 'express-validator';
+import { param } from 'express-validator';
 import { isHttpError } from 'http-errors';
 
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
@@ -17,20 +19,16 @@ import { certifyAiService } from './middlewares/certify-ai-service';
 
 const logger = loggerFactory('growi:routes:apiv3:openai:delete-thread');
 
-type DeleteThreadFactory = (crowi: Crowi) => RequestHandler[];
-
 type ReqParams = IApiv3DeleteThreadParams;
 
-type Req = Request<ReqParams, Response, undefined> & {
-  user: IUserHasId;
+type Req = Request<ReqParams, ApiV3Response, undefined> & {
+  user?: IUserHasId;
 };
 
-export const deleteThreadFactory: DeleteThreadFactory = (crowi) => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
+export const deleteThreadFactory = (crowi: Crowi): RequestHandler[] => {
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
-  const validator: ValidationChain[] = [
+  const validator = [
     param('aiAssistantId').isMongoId().withMessage('threadId is required'),
     param('threadRelationId')
       .isMongoId()
@@ -43,11 +41,15 @@ export const deleteThreadFactory: DeleteThreadFactory = (crowi) => {
     }),
     loginRequiredStrictly,
     certifyAiService,
-    validator,
+    ...validator,
     apiV3FormValidator,
     async (req: Req, res: ApiV3Response) => {
       const { aiAssistantId, threadRelationId } = req.params;
       const { user } = req;
+      assert(
+        user != null,
+        'user is required (ensured by loginRequiredStrictly middleware)',
+      );
 
       const openaiService = getOpenaiService();
       if (openaiService == null) {

+ 19 - 20
apps/app/src/features/openai/server/routes/edit/index.ts

@@ -1,9 +1,9 @@
+import assert from 'node:assert';
 import { getIdStringForRef } from '@growi/core';
 import type { IUserHasId } from '@growi/core/dist/interfaces';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
-import type { Request, RequestHandler, Response } from 'express';
-import type { ValidationChain } from 'express-validator';
+import type { Request, RequestHandler } from 'express';
 import { body } from 'express-validator';
 import { zodResponseFormat } from 'openai/helpers/zod';
 import type { MessageDelta } from 'openai/resources/beta/threads/messages.mjs';
@@ -13,6 +13,7 @@ import { z } from 'zod';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
@@ -52,16 +53,10 @@ const LlmEditorAssistantResponseSchema = z
   })
   .describe('The response format for the editor assistant');
 
-type Req = Request<undefined, Response, EditRequestBody> & {
-  user: IUserHasId;
+type Req = Request<Record<string, string>, ApiV3Response, EditRequestBody> & {
+  user?: IUserHasId;
 };
 
-// -----------------------------------------------------------------------------
-// Endpoint handler factory
-// -----------------------------------------------------------------------------
-
-type PostMessageHandlersFactory = (crowi: Crowi) => RequestHandler[];
-
 // -----------------------------------------------------------------------------
 // Instructions
 // -----------------------------------------------------------------------------
@@ -173,15 +168,13 @@ ${
 /**
  * Create endpoint handlers for editor assistant
  */
-export const postMessageToEditHandlersFactory: PostMessageHandlersFactory = (
-  crowi,
-) => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
+export const postMessageToEditHandlersFactory = (
+  crowi: Crowi,
+): RequestHandler[] => {
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
   // Validator setup
-  const validator: ValidationChain[] = [
+  const validator = [
     body('userMessage')
       .isString()
       .withMessage('userMessage must be string')
@@ -214,9 +207,15 @@ export const postMessageToEditHandlersFactory: PostMessageHandlersFactory = (
     }),
     loginRequiredStrictly,
     certifyAiService,
-    validator,
+    ...validator,
     apiV3FormValidator,
     async (req: Req, res: ApiV3Response) => {
+      const { user } = req;
+      assert(
+        user != null,
+        'user is required (ensured by loginRequiredStrictly middleware)',
+      );
+
       const {
         userMessage,
         pageBody,
@@ -261,7 +260,7 @@ export const postMessageToEditHandlersFactory: PostMessageHandlersFactory = (
       if (aiAssistantId != null) {
         const isAiAssistantUsable = await openaiService.isAiAssistantUsable(
           aiAssistantId,
-          req.user,
+          user,
         );
         if (!isAiAssistantUsable) {
           return res.apiv3Err(
@@ -339,7 +338,7 @@ export const postMessageToEditHandlersFactory: PostMessageHandlersFactory = (
 
           // Process annotations
           if (content?.type === 'text' && content?.text?.annotations != null) {
-            await replaceAnnotationWithPageLink(content, req.user.lang);
+            await replaceAnnotationWithPageLink(content, user.lang);
           }
 
           // Process text

+ 23 - 12
apps/app/src/features/openai/server/routes/get-recent-threads.ts

@@ -1,12 +1,14 @@
+import assert from 'node:assert';
 import { type IUserHasId, SCOPE } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
-import { query, type ValidationChain } from 'express-validator';
+import { query } from 'express-validator';
 import type { PaginateResult } from 'mongoose';
 
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
@@ -18,23 +20,24 @@ import { certifyAiService } from './middlewares/certify-ai-service';
 
 const logger = loggerFactory('growi:routes:apiv3:openai:get-recent-threads');
 
-type GetRecentThreadsFactory = (crowi: Crowi) => RequestHandler[];
-
 type ReqQuery = {
   page?: number;
   limit?: number;
 };
 
-type Req = Request<undefined, Response, undefined, ReqQuery> & {
-  user: IUserHasId;
+type Req = Request<
+  Record<string, string>,
+  ApiV3Response,
+  undefined,
+  ReqQuery
+> & {
+  user?: IUserHasId;
 };
 
-export const getRecentThreadsFactory: GetRecentThreadsFactory = (crowi) => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
+export const getRecentThreadsFactory = (crowi: Crowi): RequestHandler[] => {
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
-  const validator: ValidationChain[] = [
+  const validator = [
     query('page')
       .optional()
       .isInt()
@@ -48,14 +51,22 @@ export const getRecentThreadsFactory: GetRecentThreadsFactory = (crowi) => {
   ];
 
   return [
+    // biome-ignore lint/suspicious/noTsIgnore: Suppress auto fix by lefthook
+    // @ts-ignore - Scope type causes "Type instantiation is excessively deep" with tsgo
     accessTokenParser([SCOPE.READ.FEATURES.AI_ASSISTANT], {
       acceptLegacy: true,
     }),
     loginRequiredStrictly,
     certifyAiService,
-    validator,
+    ...validator,
     apiV3FormValidator,
     async (req: Req, res: ApiV3Response) => {
+      const { user } = req;
+      assert(
+        user != null,
+        'user is required (ensured by loginRequiredStrictly middleware)',
+      );
+
       const openaiService = getOpenaiService();
       if (openaiService == null) {
         return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501);
@@ -65,7 +76,7 @@ export const getRecentThreadsFactory: GetRecentThreadsFactory = (crowi) => {
         const paginateResult: PaginateResult<ThreadRelationDocument> =
           await ThreadRelationModel.paginate(
             {
-              userId: req.user._id,
+              userId: user._id,
               type: ThreadType.KNOWLEDGE,
               isActive: true,
             },

+ 22 - 16
apps/app/src/features/openai/server/routes/get-threads.ts

@@ -1,12 +1,14 @@
+import assert from 'node:assert';
 import type { IUserHasId } from '@growi/core';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
-import { param, type ValidationChain } from 'express-validator';
+import { param } from 'express-validator';
 
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
@@ -15,36 +17,36 @@ import { certifyAiService } from './middlewares/certify-ai-service';
 
 const logger = loggerFactory('growi:routes:apiv3:openai:get-threads');
 
-type GetThreadsFactory = (crowi: Crowi) => RequestHandler[];
-
-type ReqParams = {
-  aiAssistantId: string;
-};
-
-type Req = Request<ReqParams, Response, undefined> & {
-  user: IUserHasId;
+type Req = Request<Record<string, string>, ApiV3Response, undefined> & {
+  user?: IUserHasId;
 };
 
-export const getThreadsFactory: GetThreadsFactory = (crowi) => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
+export const getThreadsFactory = (crowi: Crowi): RequestHandler[] => {
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
-  const validator: ValidationChain[] = [
+  const validator = [
     param('aiAssistantId')
       .isMongoId()
       .withMessage('aiAssistantId must be string'),
   ];
 
   return [
+    // biome-ignore lint/suspicious/noTsIgnore: Suppress auto fix by lefthook
+    // @ts-ignore - Scope type causes "Type instantiation is excessively deep" with tsgo
     accessTokenParser([SCOPE.READ.FEATURES.AI_ASSISTANT], {
       acceptLegacy: true,
     }),
     loginRequiredStrictly,
     certifyAiService,
-    validator,
+    ...validator,
     apiV3FormValidator,
     async (req: Req, res: ApiV3Response) => {
+      const { user } = req;
+      assert(
+        user != null,
+        'user is required (ensured by loginRequiredStrictly middleware)',
+      );
+
       const openaiService = getOpenaiService();
       if (openaiService == null) {
         return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501);
@@ -52,10 +54,14 @@ export const getThreadsFactory: GetThreadsFactory = (crowi) => {
 
       try {
         const { aiAssistantId } = req.params;
+        assert(
+          aiAssistantId != null,
+          'aiAssistantId is required (validated by express-validator)',
+        );
 
         const isAiAssistantUsable = await openaiService.isAiAssistantUsable(
           aiAssistantId,
-          req.user,
+          user,
         );
         if (!isAiAssistantUsable) {
           return res.apiv3Err(

+ 24 - 15
apps/app/src/features/openai/server/routes/message/get-messages.ts

@@ -1,12 +1,14 @@
+import assert from 'node:assert';
 import type { IUserHasId } from '@growi/core';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
-import { param, type ValidationChain } from 'express-validator';
+import { param } from 'express-validator';
 
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
@@ -15,26 +17,22 @@ import { certifyAiService } from '../middlewares/certify-ai-service';
 
 const logger = loggerFactory('growi:routes:apiv3:openai:get-message');
 
-type GetMessagesFactory = (crowi: Crowi) => RequestHandler[];
-
 type ReqParam = {
-  threadId: string;
-  aiAssistantId: string;
+  threadId?: string;
+  aiAssistantId?: string;
   before?: string;
   after?: string;
   limit?: number;
 };
 
-type Req = Request<ReqParam, Response, undefined> & {
-  user: IUserHasId;
+type Req = Request<ReqParam, ApiV3Response, undefined> & {
+  user?: IUserHasId;
 };
 
-export const getMessagesFactory: GetMessagesFactory = (crowi) => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
+export const getMessagesFactory = (crowi: Crowi): RequestHandler[] => {
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
-  const validator: ValidationChain[] = [
+  const validator = [
     param('threadId').isString().withMessage('threadId must be string'),
     param('aiAssistantId')
       .isMongoId()
@@ -50,9 +48,15 @@ export const getMessagesFactory: GetMessagesFactory = (crowi) => {
     }),
     loginRequiredStrictly,
     certifyAiService,
-    validator,
+    ...validator,
     apiV3FormValidator,
     async (req: Req, res: ApiV3Response) => {
+      const { user } = req;
+      assert(
+        user != null,
+        'user is required (ensured by loginRequiredStrictly middleware)',
+      );
+
       const openaiService = getOpenaiService();
       if (openaiService == null) {
         return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501);
@@ -61,9 +65,14 @@ export const getMessagesFactory: GetMessagesFactory = (crowi) => {
       try {
         const { threadId, aiAssistantId, limit, before, after } = req.params;
 
+        assert(
+          threadId != null && aiAssistantId != null,
+          'threadId and aiAssistantId are required (validated by express-validator)',
+        );
+
         const isAiAssistantUsable = await openaiService.isAiAssistantUsable(
           aiAssistantId,
-          req.user,
+          user,
         );
         if (!isAiAssistantUsable) {
           return res.apiv3Err(
@@ -74,7 +83,7 @@ export const getMessagesFactory: GetMessagesFactory = (crowi) => {
 
         const messages = await openaiService.getMessageData(
           threadId,
-          req.user.lang,
+          user.lang,
           {
             limit,
             before,

+ 19 - 16
apps/app/src/features/openai/server/routes/message/post-message.ts

@@ -1,8 +1,8 @@
+import assert from 'node:assert';
 import type { IUserHasId } from '@growi/core/dist/interfaces';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
-import type { Request, RequestHandler, Response } from 'express';
-import type { ValidationChain } from 'express-validator';
+import type { Request, RequestHandler } from 'express';
 import { body } from 'express-validator';
 import type { AssistantStream } from 'openai/lib/AssistantStream';
 import type { MessageDelta } from 'openai/resources/beta/threads/messages.mjs';
@@ -12,6 +12,7 @@ import { getOrCreateChatAssistant } from '~/features/openai/server/services/assi
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
@@ -55,20 +56,14 @@ type ReqBody = {
   extendedThinkingMode?: boolean;
 };
 
-type Req = Request<undefined, Response, ReqBody> & {
-  user: IUserHasId;
+type Req = Request<Record<string, string>, ApiV3Response, ReqBody> & {
+  user?: IUserHasId;
 };
 
-type PostMessageHandlersFactory = (crowi: Crowi) => RequestHandler[];
+export const postMessageHandlersFactory = (crowi: Crowi): RequestHandler[] => {
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
-export const postMessageHandlersFactory: PostMessageHandlersFactory = (
-  crowi,
-) => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
-
-  const validator: ValidationChain[] = [
+  const validator = [
     body('userMessage')
       .isString()
       .withMessage('userMessage must be string')
@@ -84,14 +79,22 @@ export const postMessageHandlersFactory: PostMessageHandlersFactory = (
   ];
 
   return [
+    // biome-ignore lint/suspicious/noTsIgnore: Suppress auto fix by lefthook
+    // @ts-ignore - Scope type causes "Type instantiation is excessively deep" with tsgo
     accessTokenParser([SCOPE.WRITE.FEATURES.AI_ASSISTANT], {
       acceptLegacy: true,
     }),
     loginRequiredStrictly,
     certifyAiService,
-    validator,
+    ...validator,
     apiV3FormValidator,
     async (req: Req, res: ApiV3Response) => {
+      const { user } = req;
+      assert(
+        user != null,
+        'user is required (ensured by loginRequiredStrictly middleware)',
+      );
+
       const { aiAssistantId, threadId } = req.body;
 
       if (threadId == null) {
@@ -111,7 +114,7 @@ export const postMessageHandlersFactory: PostMessageHandlersFactory = (
 
       const isAiAssistantUsable = await openaiService.isAiAssistantUsable(
         aiAssistantId,
-        req.user,
+        user,
       );
       if (!isAiAssistantUsable) {
         return res.apiv3Err(
@@ -188,7 +191,7 @@ export const postMessageHandlersFactory: PostMessageHandlersFactory = (
 
         // If annotation is found
         if (content?.type === 'text' && content?.text?.annotations != null) {
-          await replaceAnnotationWithPageLink(content, req.user.lang);
+          await replaceAnnotationWithPageLink(content, user.lang);
         }
 
         res.write(`data: ${JSON.stringify(delta)}\n\n`);

+ 4 - 3
apps/app/src/features/openai/server/routes/middlewares/certify-ai-service.ts

@@ -1,5 +1,6 @@
-import type { NextFunction, Request, Response } from 'express';
+import type { NextFunction, Request } from 'express';
 
+import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 
@@ -8,8 +9,8 @@ import { OpenaiServiceTypes } from '../../../interfaces/ai';
 const logger = loggerFactory('growi:middlewares:certify-ai-service');
 
 export const certifyAiService = (
-  req: Request,
-  res: Response & { apiv3Err },
+  _req: Request,
+  res: ApiV3Response,
   next: NextFunction,
 ): void => {
   const aiEnabled = configManager.getConfig('app:aiEnabled');

+ 11 - 13
apps/app/src/features/openai/server/routes/set-default-ai-assistant.ts

@@ -1,12 +1,14 @@
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
-import { body, param, type ValidationChain } from 'express-validator';
+import { body, param } from 'express-validator';
 import { isHttpError } from 'http-errors';
 
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import adminRequiredFactory from '~/server/middlewares/admin-required';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
@@ -18,8 +20,6 @@ const logger = loggerFactory(
   'growi:routes:apiv3:openai:set-default-ai-assistants',
 );
 
-type setDefaultAiAssistantFactory = (crowi: Crowi) => RequestHandler[];
-
 type ReqParams = {
   id: string;
 };
@@ -28,17 +28,15 @@ type ReqBody = {
   isDefault: boolean;
 };
 
-type Req = Request<ReqParams, Response, ReqBody>;
+type Req = Request<ReqParams, ApiV3Response, ReqBody>;
 
-export const setDefaultAiAssistantFactory: setDefaultAiAssistantFactory = (
-  crowi,
-) => {
-  const adminRequired = require('~/server/middlewares/admin-required')(crowi);
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
+export const setDefaultAiAssistantFactory = (
+  crowi: Crowi,
+): RequestHandler[] => {
+  const adminRequired = adminRequiredFactory(crowi);
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
-  const validator: ValidationChain[] = [
+  const validator = [
     param('id').isMongoId().withMessage('aiAssistant id is required'),
     body('isDefault').isBoolean().withMessage('isDefault is required'),
   ];
@@ -50,7 +48,7 @@ export const setDefaultAiAssistantFactory: setDefaultAiAssistantFactory = (
     loginRequiredStrictly,
     adminRequired,
     certifyAiService,
-    validator,
+    ...validator,
     apiV3FormValidator,
     async (req: Req, res: ApiV3Response) => {
       const openaiService = getOpenaiService();

+ 19 - 12
apps/app/src/features/openai/server/routes/thread.ts

@@ -1,13 +1,14 @@
+import assert from 'node:assert';
 import type { IUserHasId } from '@growi/core/dist/interfaces';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
-import type { ValidationChain } from 'express-validator';
 import { body } from 'express-validator';
 
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
@@ -23,18 +24,18 @@ type ReqBody = {
   initialUserMessage?: string;
 };
 
-type CreateThreadReq = Request<undefined, ApiV3Response, ReqBody> & {
-  user: IUserHasId;
+type CreateThreadReq = Request<
+  Record<string, string>,
+  ApiV3Response,
+  ReqBody
+> & {
+  user?: IUserHasId;
 };
 
-type CreateThreadFactory = (crowi: Crowi) => RequestHandler[];
+export const createThreadHandlersFactory = (crowi: Crowi): RequestHandler[] => {
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
-export const createThreadHandlersFactory: CreateThreadFactory = (crowi) => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
-
-  const validator: ValidationChain[] = [
+  const validator = [
     body('type')
       .isIn(Object.values(ThreadType))
       .withMessage('type must be one of "editor" or "knowledge"'),
@@ -54,9 +55,15 @@ export const createThreadHandlersFactory: CreateThreadFactory = (crowi) => {
     }),
     loginRequiredStrictly,
     certifyAiService,
-    validator,
+    ...validator,
     apiV3FormValidator,
     async (req: CreateThreadReq, res: ApiV3Response) => {
+      const { user } = req;
+      assert(
+        user != null,
+        'user is required (ensured by loginRequiredStrictly middleware)',
+      );
+
       const openaiService = getOpenaiService();
       if (openaiService == null) {
         return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501);
@@ -68,7 +75,7 @@ export const createThreadHandlersFactory: CreateThreadFactory = (crowi) => {
 
       try {
         const thread = await openaiService.createThread(
-          req.user._id,
+          user._id,
           type,
           aiAssistantId,
           initialUserMessage,

+ 13 - 11
apps/app/src/features/openai/server/routes/update-ai-assistant.ts

@@ -1,13 +1,15 @@
+import assert from 'node:assert';
 import type { IUserHasId } from '@growi/core';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
-import { param, type ValidationChain } from 'express-validator';
+import { param } from 'express-validator';
 import { isHttpError } from 'http-errors';
 
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
@@ -18,24 +20,20 @@ import { upsertAiAssistantValidator } from './middlewares/upsert-ai-assistant-va
 
 const logger = loggerFactory('growi:routes:apiv3:openai:update-ai-assistants');
 
-type UpdateAiAssistantsFactory = (crowi: Crowi) => RequestHandler[];
-
 type ReqParams = {
   id: string;
 };
 
 type ReqBody = UpsertAiAssistantData;
 
-type Req = Request<ReqParams, Response, ReqBody> & {
-  user: IUserHasId;
+type Req = Request<ReqParams, ApiV3Response, ReqBody> & {
+  user?: IUserHasId;
 };
 
-export const updateAiAssistantsFactory: UpdateAiAssistantsFactory = (crowi) => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
+export const updateAiAssistantsFactory = (crowi: Crowi): RequestHandler[] => {
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
-  const validator: ValidationChain[] = [
+  const validator = [
     param('id').isMongoId().withMessage('aiAssistant id is required'),
     ...upsertAiAssistantValidator,
   ];
@@ -46,11 +44,15 @@ export const updateAiAssistantsFactory: UpdateAiAssistantsFactory = (crowi) => {
     }),
     loginRequiredStrictly,
     certifyAiService,
-    validator,
+    ...validator,
     apiV3FormValidator,
     async (req: Req, res: ApiV3Response) => {
       const { id } = req.params;
       const { user } = req;
+      assert(
+        user != null,
+        'user is required (ensured by loginRequiredStrictly middleware)',
+      );
 
       const openaiService = getOpenaiService();
       if (openaiService == null) {

+ 2 - 3
apps/app/src/features/page-bulk-export/server/routes/apiv3/page-bulk-export.ts

@@ -5,6 +5,7 @@ import { Router } from 'express';
 import { body, validationResult } from 'express-validator';
 
 import type Crowi from '~/server/crowi';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
@@ -23,9 +24,7 @@ interface AuthorizedRequest extends Request {
 
 module.exports = (crowi: Crowi): Router => {
   const accessTokenParser = crowi.accessTokenParser;
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
   const validators = {
     pageBulkExport: [

+ 2 - 3
apps/app/src/features/templates/server/routes/apiv3/index.ts

@@ -14,6 +14,7 @@ import { GrowiPlugin } from '~/features/growi-plugin/server/models';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import { resolveFromRoot } from '~/server/util/project-dir-utils';
 import loggerFactory from '~/utils/logger';
@@ -31,9 +32,7 @@ const validator = {
 let presetTemplateSummaries: TemplateSummary[];
 
 module.exports = (crowi: Crowi) => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
   /**
    * @swagger

+ 8 - 3
apps/app/test/integration/migrations/20210913153942-migrate-slack-app-integration-schema.test.ts → apps/app/src/migrations/20210913153942-migrate-slack-app-integration-schema.integ.ts

@@ -1,12 +1,17 @@
 import type { Collection } from 'mongodb';
-import mongoose from 'mongoose';
-
-import migrate from '~/migrations/20210913153942-migrate-slack-app-integration-schema';
+import * as mongoose from 'mongoose';
 
 describe('migrate-slack-app-integration-schema', () => {
   let collection: Collection;
+  // biome-ignore lint/suspicious/noExplicitAny: ignore
+  let migrate: any;
 
   beforeAll(async () => {
+    // Use dynamic import for ESM/CJS interop - Vitest handles the mixed syntax
+    const migrateModule = await import(
+      './20210913153942-migrate-slack-app-integration-schema.js'
+    );
+    migrate = migrateModule.default || migrateModule;
     collection = mongoose.connection.collection('slackappintegrations');
 
     await collection.insertMany([

+ 29 - 25
apps/app/src/server/crowi/index.ts

@@ -4,7 +4,7 @@ import path from 'node:path';
 import { createTerminus } from '@godaddy/terminus';
 import attachmentRoutes from '@growi/remark-attachment-refs/dist/server';
 import lsxRoutes from '@growi/remark-lsx/dist/server/index.cjs';
-import type { Express, RequestHandler } from 'express';
+import type { Express } from 'express';
 import mongoose from 'mongoose';
 
 import { KeycloakUserGroupSyncService } from '~/features/external-user-group/server/service/keycloak-user-group-sync';
@@ -19,13 +19,22 @@ import { projectRoot } from '~/server/util/project-dir-utils';
 import { getGrowiVersion } from '~/utils/growi-version';
 import loggerFactory from '~/utils/logger';
 
+import ActivityEvent from '../events/activity';
+import AdminEvent from '../events/admin';
+import BookmarkEvent from '../events/bookmark';
+import PageEvent from '../events/page';
+import TagEvent from '../events/tag';
 import UserEvent from '../events/user';
 import type { AccessTokenParser } from '../middlewares/access-token-parser';
 import { accessTokenParser } from '../middlewares/access-token-parser';
+import httpErrorHandler from '../middlewares/http-error-handler';
+import loginRequiredFactory from '../middlewares/login-required';
 import type { AclService } from '../service/acl';
 import { aclService as aclServiceSingletonInstance } from '../service/acl';
+import ActivityService from '../service/activity';
 import AppService from '../service/app';
 import { AttachmentService } from '../service/attachment';
+import CommentService from '../service/comment';
 import { configManager as configManagerSingletonInstance } from '../service/config-manager';
 import type { ConfigManager } from '../service/config-manager/config-manager';
 import instanciateExportService from '../service/export';
@@ -37,6 +46,7 @@ import {
 } from '../service/g2g-transfer';
 import { GrowiBridgeService } from '../service/growi-bridge';
 import { initializeImportService } from '../service/import';
+import InAppNotificationService from '../service/in-app-notification';
 import { InstallerService } from '../service/installer';
 import { normalizeData } from '../service/normalize-data';
 import PageService from '../service/page';
@@ -47,6 +57,7 @@ import PassportService from '../service/passport';
 import SearchService from '../service/search';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { SocketIoService } from '../service/socket-io';
+import SyncPageStatusService from '../service/system-events/sync-page-status';
 import UserGroupService from '../service/user-group';
 import { UserNotificationService } from '../service/user-notification';
 import { initializeYjsService } from '../service/yjs';
@@ -55,7 +66,6 @@ import type { ModelsMapDependentOnCrowi } from './setup-models';
 import { setupModelsDependentOnCrowi } from './setup-models';
 
 const logger = loggerFactory('growi:crowi');
-const httpErrorHandler = require('../middlewares/http-error-handler');
 
 const sep = path.sep;
 
@@ -102,6 +112,8 @@ class Crowi {
    */
   accessTokenParser: AccessTokenParser;
 
+  loginRequiredFactory: typeof loginRequiredFactory;
+
   nextApp!: ReturnType<typeof next>;
 
   configManager!: ConfigManager;
@@ -217,6 +229,7 @@ class Crowi {
     this.cacheDir = path.join(this.tmpDir, 'cache');
 
     this.accessTokenParser = accessTokenParser;
+    this.loginRequiredFactory = loginRequiredFactory;
 
     this.config = {};
     this.s2sMessagingService = null;
@@ -242,11 +255,11 @@ class Crowi {
 
     this.events = {
       user: new UserEvent(this),
-      page: new (require('../events/page'))(this),
-      activity: new (require('../events/activity'))(this),
-      bookmark: new (require('../events/bookmark'))(this),
-      tag: new (require('../events/tag'))(this),
-      admin: new (require('../events/admin'))(this),
+      page: new PageEvent(this),
+      activity: new ActivityEvent(this),
+      bookmark: new BookmarkEvent(this),
+      tag: new TagEvent(this),
+      admin: new AdminEvent(this),
     };
   }
 
@@ -583,7 +596,7 @@ class Crowi {
 
     // setup Express Routes
     this.setupRoutesForPlugins();
-    this.setupRoutesAtLast();
+    await this.setupRoutesAtLast();
 
     // setup Global Error Handlers
     this.setupGlobalErrorHandlers();
@@ -644,8 +657,13 @@ class Crowi {
    * setup Express Routes
    * !! this must be at last because it includes '/*' route !!
    */
-  setupRoutesAtLast(): void {
-    require('../routes')(this, this.express);
+  async setupRoutesAtLast(): Promise<void> {
+    type RoutesSetup = (crowi: Crowi, app: Express) => void;
+    // CommonJS modules are always wrapped in { default } when dynamically imported
+    const { default: setupRoutes } = (await import('../routes')) as unknown as {
+      default: RoutesSetup;
+    };
+    setupRoutes(this, this.express);
   }
 
   /**
@@ -653,17 +671,7 @@ class Crowi {
    * !! this must be after the Routes setup !!
    */
   setupGlobalErrorHandlers(): void {
-    this.express.use(httpErrorHandler as RequestHandler);
-  }
-
-  /**
-   * require API for plugins
-   *
-   * @param modulePath relative path from /lib/crowi/index.js
-   * @return module
-   */
-  require(modulePath: string): unknown {
-    return require(modulePath);
+    this.express.use(httpErrorHandler);
   }
 
   /**
@@ -808,14 +816,12 @@ class Crowi {
   }
 
   async setupInAppNotificationService(): Promise<void> {
-    const InAppNotificationService = require('../service/in-app-notification');
     if (this.inAppNotificationService == null) {
       this.inAppNotificationService = new InAppNotificationService(this);
     }
   }
 
   async setupActivityService(): Promise<void> {
-    const ActivityService = require('../service/activity');
     if (this.activityService == null) {
       this.activityService = new ActivityService(this);
       await this.activityService.createTtlIndex();
@@ -823,14 +829,12 @@ class Crowi {
   }
 
   async setupCommentService(): Promise<void> {
-    const CommentService = require('../service/comment');
     if (this.commentService == null) {
       this.commentService = new CommentService(this);
     }
   }
 
   async setupSyncPageStatusService(): Promise<void> {
-    const SyncPageStatusService = require('../service/system-events/sync-page-status');
     if (this.syncPageStatusService == null) {
       this.syncPageStatusService = new SyncPageStatusService(
         this,

+ 3 - 7
apps/app/src/server/events/activity.ts

@@ -1,12 +1,8 @@
-import loggerFactory from '~/utils/logger';
+import events from 'node:events';
+import util from 'node:util';
 
 import type Crowi from '../crowi';
 
-const logger = loggerFactory('growi:events:activity');
-
-const events = require('events');
-const util = require('util');
-
 function ActivityEvent(crowi: Crowi) {
   this.crowi = crowi;
 
@@ -14,4 +10,4 @@ function ActivityEvent(crowi: Crowi) {
 }
 util.inherits(ActivityEvent, events.EventEmitter);
 
-module.exports = ActivityEvent;
+export default ActivityEvent;

+ 0 - 12
apps/app/src/server/events/admin.js

@@ -1,12 +0,0 @@
-const events = require('events');
-const util = require('util');
-
-/** @param {import('~/server/crowi').default} crowi Crowi instance */
-function AdminEvent(crowi) {
-  this.crowi = crowi;
-
-  events.EventEmitter.call(this);
-}
-util.inherits(AdminEvent, events.EventEmitter);
-
-module.exports = AdminEvent;

+ 14 - 0
apps/app/src/server/events/admin.ts

@@ -0,0 +1,14 @@
+import EventEmitter from 'node:events';
+
+import type Crowi from '../crowi';
+
+class AdminEvent extends EventEmitter {
+  crowi: Crowi;
+
+  constructor(crowi: Crowi) {
+    super();
+    this.crowi = crowi;
+  }
+}
+
+export default AdminEvent;

+ 0 - 15
apps/app/src/server/events/bookmark.js

@@ -1,15 +0,0 @@
-const events = require('events');
-const util = require('util');
-
-/** @param {import('~/server/crowi').default} crowi Crowi instance */
-function BookmarkEvent(crowi) {
-  this.crowi = crowi;
-
-  events.EventEmitter.call(this);
-}
-util.inherits(BookmarkEvent, events.EventEmitter);
-
-BookmarkEvent.prototype.onCreate = (bookmark) => {};
-BookmarkEvent.prototype.onDelete = (bookmark) => {};
-
-module.exports = BookmarkEvent;

+ 22 - 0
apps/app/src/server/events/bookmark.ts

@@ -0,0 +1,22 @@
+import EventEmitter from 'node:events';
+
+import type Crowi from '../crowi';
+
+class BookmarkEvent extends EventEmitter {
+  crowi: Crowi;
+
+  constructor(crowi: Crowi) {
+    super();
+    this.crowi = crowi;
+  }
+
+  onCreate(_bookmark: unknown): void {
+    // placeholder for event handler
+  }
+
+  onDelete(_bookmark: unknown): void {
+    // placeholder for event handler
+  }
+}
+
+export default BookmarkEvent;

+ 0 - 28
apps/app/src/server/events/page.js

@@ -1,28 +0,0 @@
-import events from 'events';
-import util from 'util';
-
-import loggerFactory from '~/utils/logger';
-
-const logger = loggerFactory('growi:events:page');
-
-/** @param {import('~/server/crowi').default} crowi Crowi instance */
-function PageEvent(crowi) {
-  this.crowi = crowi;
-
-  events.EventEmitter.call(this);
-}
-util.inherits(PageEvent, events.EventEmitter);
-
-PageEvent.prototype.onCreate = (page, user) => {
-  logger.debug('onCreate event fired');
-};
-PageEvent.prototype.onUpdate = (page, user) => {
-  logger.debug('onUpdate event fired');
-};
-PageEvent.prototype.onCreateMany = (pages, user) => {
-  logger.debug('onCreateMany event fired');
-};
-PageEvent.prototype.onAddSeenUsers = (pages, user) => {
-  logger.debug('onAddSeenUsers event fired');
-};
-module.exports = PageEvent;

+ 35 - 0
apps/app/src/server/events/page.ts

@@ -0,0 +1,35 @@
+import EventEmitter from 'node:events';
+import type { IPage, IUserHasId } from '@growi/core';
+
+import loggerFactory from '~/utils/logger';
+
+import type Crowi from '../crowi';
+
+const logger = loggerFactory('growi:events:page');
+
+class PageEvent extends EventEmitter {
+  crowi: Crowi;
+
+  constructor(crowi: Crowi) {
+    super();
+    this.crowi = crowi;
+  }
+
+  onCreate(_page: IPage, _user: IUserHasId): void {
+    logger.debug('onCreate event fired');
+  }
+
+  onUpdate(_page: IPage, _user: IUserHasId): void {
+    logger.debug('onUpdate event fired');
+  }
+
+  onCreateMany(_pages: IPage[], _user: IUserHasId): void {
+    logger.debug('onCreateMany event fired');
+  }
+
+  onAddSeenUsers(_pages: IPage[], _user: IUserHasId): void {
+    logger.debug('onAddSeenUsers event fired');
+  }
+}
+
+export default PageEvent;

+ 0 - 14
apps/app/src/server/events/tag.js

@@ -1,14 +0,0 @@
-const events = require('events');
-const util = require('util');
-
-/** @param {import('~/server/crowi').default} crowi Crowi instance */
-function TagEvent(crowi) {
-  this.crowi = crowi;
-
-  events.EventEmitter.call(this);
-}
-util.inherits(TagEvent, events.EventEmitter);
-
-TagEvent.prototype.onUpdate = (tag) => {};
-
-module.exports = TagEvent;

+ 19 - 0
apps/app/src/server/events/tag.ts

@@ -0,0 +1,19 @@
+import EventEmitter from 'node:events';
+import type { ITag } from '@growi/core';
+
+import type Crowi from '../crowi';
+
+class TagEvent extends EventEmitter {
+  crowi: Crowi;
+
+  constructor(crowi: Crowi) {
+    super();
+    this.crowi = crowi;
+  }
+
+  onUpdate(_tag: ITag): void {
+    // placeholder for event handler
+  }
+}
+
+export default TagEvent;

+ 20 - 3
apps/app/src/server/middlewares/admin-required.js → apps/app/src/server/middlewares/admin-required.ts

@@ -1,10 +1,25 @@
+import type { IUserHasId } from '@growi/core';
+import type { NextFunction, Request, Response } from 'express';
+
 import loggerFactory from '~/utils/logger';
 
+import type Crowi from '../crowi';
+
 const logger = loggerFactory('growi:middleware:admin-required');
 
-/** @param {import('~/server/crowi').default} crowi Crowi instance */
-module.exports = (crowi, fallback = null) => {
-  return (req, res, next) => {
+type RequestWithUser = Request & { user?: IUserHasId };
+
+type FallbackFunction = (
+  req: RequestWithUser,
+  res: Response,
+  next: NextFunction,
+) => void;
+
+const adminRequiredFactory = (
+  _crowi: Crowi,
+  fallback: FallbackFunction | null = null,
+) => {
+  return (req: RequestWithUser, res: Response, next: NextFunction) => {
     if (req.user != null && req.user instanceof Object && '_id' in req.user) {
       if (req.user.admin) {
         return next();
@@ -26,3 +41,5 @@ module.exports = (crowi, fallback = null) => {
     return res.redirect('/login');
   };
 };
+
+export default adminRequiredFactory;

+ 6 - 3
apps/app/src/server/middlewares/http-error-handler.js → apps/app/src/server/middlewares/http-error-handler.ts

@@ -1,10 +1,11 @@
+import type { ErrorRequestHandler } from 'express';
 import { isHttpError } from 'http-errors';
 
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:middleware:htto-error-handler');
 
-module.exports = async (err, req, res, next) => {
+const httpErrorHandler: ErrorRequestHandler = (err, _req, res, next) => {
   // handle if the err is a HttpError instance
   if (isHttpError(err)) {
     const httpError = err;
@@ -14,10 +15,12 @@ module.exports = async (err, req, res, next) => {
         status: httpError.status,
         message: httpError.message,
       });
-    } catch (err) {
-      logger.error('Cannot call res.send() twice:', err);
+    } catch (e) {
+      logger.error('Cannot call res.send() twice:', e);
     }
   }
 
   next(err);
 };
+
+export default httpErrorHandler;

+ 100 - 89
apps/app/test/integration/middlewares/login-required.test.js → apps/app/src/server/middlewares/login-required.spec.ts

@@ -1,38 +1,47 @@
-const { UserStatus } = require('../../../src/server/models/user/conts');
-const { getInstance } = require('../setup-crowi');
+import type Crowi from '../crowi';
+import { UserStatus } from '../models/user/conts';
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type LoginRequiredMiddleware = (req: any, res: any, next: any) => any;
 
 describe('loginRequired', () => {
-  let crowi;
-  const fallbackMock = jest.fn().mockReturnValue('fallback');
+  let fallbackMock: ReturnType<typeof vi.fn>;
+
+  let loginRequiredStrictly: LoginRequiredMiddleware;
+  let loginRequired: LoginRequiredMiddleware;
+  let loginRequiredWithFallback: LoginRequiredMiddleware;
 
-  let loginRequiredStrictly;
-  let loginRequired;
-  let loginRequiredWithFallback;
+  // Mock Crowi with only the required aclService
+  const aclServiceMock = {
+    isGuestAllowedToRead: vi.fn(),
+  };
+  const crowiMock = {
+    aclService: aclServiceMock,
+  } as unknown as Crowi;
 
   beforeEach(async () => {
-    crowi = await getInstance();
-    loginRequiredStrictly = require('~/server/middlewares/login-required')(
-      crowi,
-    );
-    loginRequired = require('~/server/middlewares/login-required')(crowi, true);
-    loginRequiredWithFallback = require('~/server/middlewares/login-required')(
-      crowi,
+    vi.resetAllMocks();
+    fallbackMock = vi.fn().mockReturnValue('fallback');
+
+    // Use dynamic import to load the middleware factory
+    const loginRequiredFactory = (await import('./login-required')).default;
+
+    loginRequiredStrictly = loginRequiredFactory(crowiMock);
+    loginRequired = loginRequiredFactory(crowiMock, true);
+    loginRequiredWithFallback = loginRequiredFactory(
+      crowiMock,
       false,
       fallbackMock,
     );
   });
 
   describe('not strict mode', () => {
-    const res = {
-      redirect: jest.fn().mockReturnValue('redirect'),
-      sendStatus: jest.fn().mockReturnValue('sendStatus'),
-    };
-    const next = jest.fn().mockReturnValue('next');
-
     describe('and when aclService.isGuestAllowedToRead() returns false', () => {
-      let req;
-
-      let isGuestAllowedToReadSpy;
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      let req: any;
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      let res: any;
+      let next: ReturnType<typeof vi.fn>;
 
       beforeEach(() => {
         // setup req
@@ -40,12 +49,13 @@ describe('loginRequired', () => {
           originalUrl: 'original url 1',
           session: {},
         };
-        // reset session object
-        req.session = {};
-        // prepare spy for AclService.isGuestAllowedToRead
-        isGuestAllowedToReadSpy = jest
-          .spyOn(crowi.aclService, 'isGuestAllowedToRead')
-          .mockImplementation(() => false);
+        res = {
+          redirect: vi.fn().mockReturnValue('redirect'),
+          sendStatus: vi.fn().mockReturnValue('sendStatus'),
+        };
+        next = vi.fn().mockReturnValue('next');
+        // prepare mock for AclService.isGuestAllowedToRead
+        aclServiceMock.isGuestAllowedToRead.mockReturnValue(false);
       });
 
       test.each`
@@ -63,7 +73,9 @@ describe('loginRequired', () => {
 
           const result = loginRequired(req, res, next);
 
-          expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
+          expect(
+            crowiMock.aclService.isGuestAllowedToRead,
+          ).not.toHaveBeenCalled();
           expect(next).not.toHaveBeenCalled();
           expect(fallbackMock).not.toHaveBeenCalled();
           expect(res.sendStatus).not.toHaveBeenCalled();
@@ -79,7 +91,7 @@ describe('loginRequired', () => {
 
         const result = loginRequired(req, res, next);
 
-        expect(isGuestAllowedToReadSpy).toHaveBeenCalled();
+        expect(crowiMock.aclService.isGuestAllowedToRead).toHaveBeenCalled();
         expect(next).not.toHaveBeenCalled();
         expect(fallbackMock).not.toHaveBeenCalled();
         expect(res.sendStatus).not.toHaveBeenCalled();
@@ -94,7 +106,7 @@ describe('loginRequired', () => {
 
         const result = loginRequired(req, res, next);
 
-        expect(isGuestAllowedToReadSpy).toHaveBeenCalled();
+        expect(crowiMock.aclService.isGuestAllowedToRead).toHaveBeenCalled();
         expect(fallbackMock).not.toHaveBeenCalled();
         expect(res.sendStatus).not.toHaveBeenCalled();
         expect(next).toHaveBeenCalled();
@@ -104,9 +116,11 @@ describe('loginRequired', () => {
     });
 
     describe('and when aclService.isGuestAllowedToRead() returns true', () => {
-      let req;
-
-      let isGuestAllowedToReadSpy;
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      let req: any;
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      let res: any;
+      let next: ReturnType<typeof vi.fn>;
 
       beforeEach(() => {
         // setup req
@@ -114,12 +128,13 @@ describe('loginRequired', () => {
           originalUrl: 'original url 1',
           session: {},
         };
-        // reset session object
-        req.session = {};
-        // prepare spy for AclService.isGuestAllowedToRead
-        isGuestAllowedToReadSpy = jest
-          .spyOn(crowi.aclService, 'isGuestAllowedToRead')
-          .mockImplementation(() => true);
+        res = {
+          redirect: vi.fn().mockReturnValue('redirect'),
+          sendStatus: vi.fn().mockReturnValue('sendStatus'),
+        };
+        next = vi.fn().mockReturnValue('next');
+        // prepare mock for AclService.isGuestAllowedToRead
+        aclServiceMock.isGuestAllowedToRead.mockReturnValue(true);
       });
 
       test.each`
@@ -137,7 +152,9 @@ describe('loginRequired', () => {
 
           const result = loginRequired(req, res, next);
 
-          expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
+          expect(
+            crowiMock.aclService.isGuestAllowedToRead,
+          ).not.toHaveBeenCalled();
           expect(next).not.toHaveBeenCalled();
           expect(fallbackMock).not.toHaveBeenCalled();
           expect(res.sendStatus).not.toHaveBeenCalled();
@@ -151,7 +168,9 @@ describe('loginRequired', () => {
       test('pass guest user', () => {
         const result = loginRequired(req, res, next);
 
-        expect(isGuestAllowedToReadSpy).toHaveBeenCalledTimes(1);
+        expect(crowiMock.aclService.isGuestAllowedToRead).toHaveBeenCalledTimes(
+          1,
+        );
         expect(fallbackMock).not.toHaveBeenCalled();
         expect(res.sendStatus).not.toHaveBeenCalled();
         expect(next).toHaveBeenCalled();
@@ -164,7 +183,7 @@ describe('loginRequired', () => {
 
         const result = loginRequired(req, res, next);
 
-        expect(isGuestAllowedToReadSpy).toHaveBeenCalled();
+        expect(crowiMock.aclService.isGuestAllowedToRead).toHaveBeenCalled();
         expect(fallbackMock).not.toHaveBeenCalled();
         expect(res.sendStatus).not.toHaveBeenCalled();
         expect(next).toHaveBeenCalled();
@@ -175,27 +194,22 @@ describe('loginRequired', () => {
   });
 
   describe('strict mode', () => {
-    // setup req/res/next
-    const req = {
-      originalUrl: 'original url 1',
-      session: null,
-    };
-    const res = {
-      redirect: jest.fn().mockReturnValue('redirect'),
-      sendStatus: jest.fn().mockReturnValue('sendStatus'),
-    };
-    const next = jest.fn().mockReturnValue('next');
-
-    let isGuestAllowedToReadSpy;
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    let req: any;
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    let res: any;
+    let next: ReturnType<typeof vi.fn>;
 
     beforeEach(() => {
-      // reset session object
-      req.session = {};
-      // spy for AclService.isGuestAllowedToRead
-      isGuestAllowedToReadSpy = jest.spyOn(
-        crowi.aclService,
-        'isGuestAllowedToRead',
-      );
+      req = {
+        originalUrl: 'original url 1',
+        session: {},
+      };
+      res = {
+        redirect: vi.fn().mockReturnValue('redirect'),
+        sendStatus: vi.fn().mockReturnValue('sendStatus'),
+      };
+      next = vi.fn().mockReturnValue('next');
     });
 
     test("send status 403 when 'req.baseUrl' starts with '_api'", () => {
@@ -203,7 +217,7 @@ describe('loginRequired', () => {
 
       const result = loginRequiredStrictly(req, res, next);
 
-      expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
+      expect(crowiMock.aclService.isGuestAllowedToRead).not.toHaveBeenCalled();
       expect(next).not.toHaveBeenCalled();
       expect(fallbackMock).not.toHaveBeenCalled();
       expect(res.redirect).not.toHaveBeenCalled();
@@ -217,7 +231,7 @@ describe('loginRequired', () => {
 
       const result = loginRequiredStrictly(req, res, next);
 
-      expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
+      expect(crowiMock.aclService.isGuestAllowedToRead).not.toHaveBeenCalled();
       expect(next).not.toHaveBeenCalled();
       expect(fallbackMock).not.toHaveBeenCalled();
       expect(res.sendStatus).not.toHaveBeenCalled();
@@ -235,7 +249,7 @@ describe('loginRequired', () => {
 
       const result = loginRequiredStrictly(req, res, next);
 
-      expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
+      expect(crowiMock.aclService.isGuestAllowedToRead).not.toHaveBeenCalled();
       expect(fallbackMock).not.toHaveBeenCalled();
       expect(res.sendStatus).not.toHaveBeenCalled();
       expect(res.redirect).not.toHaveBeenCalled();
@@ -259,7 +273,9 @@ describe('loginRequired', () => {
 
         const result = loginRequiredStrictly(req, res, next);
 
-        expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
+        expect(
+          crowiMock.aclService.isGuestAllowedToRead,
+        ).not.toHaveBeenCalled();
         expect(next).not.toHaveBeenCalled();
         expect(fallbackMock).not.toHaveBeenCalled();
         expect(res.sendStatus).not.toHaveBeenCalled();
@@ -279,7 +295,7 @@ describe('loginRequired', () => {
 
       const result = loginRequiredStrictly(req, res, next);
 
-      expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
+      expect(crowiMock.aclService.isGuestAllowedToRead).not.toHaveBeenCalled();
       expect(next).not.toHaveBeenCalled();
       expect(fallbackMock).not.toHaveBeenCalled();
       expect(res.sendStatus).not.toHaveBeenCalled();
@@ -291,27 +307,22 @@ describe('loginRequired', () => {
   });
 
   describe('specified fallback', () => {
-    // setup req/res/next
-    const req = {
-      originalUrl: 'original url 1',
-      session: null,
-    };
-    const res = {
-      redirect: jest.fn().mockReturnValue('redirect'),
-      sendStatus: jest.fn().mockReturnValue('sendStatus'),
-    };
-    const next = jest.fn().mockReturnValue('next');
-
-    let isGuestAllowedToReadSpy;
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    let req: any;
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    let res: any;
+    let next: ReturnType<typeof vi.fn>;
 
     beforeEach(() => {
-      // reset session object
-      req.session = {};
-      // spy for AclService.isGuestAllowedToRead
-      isGuestAllowedToReadSpy = jest.spyOn(
-        crowi.aclService,
-        'isGuestAllowedToRead',
-      );
+      req = {
+        originalUrl: 'original url 1',
+        session: {},
+      };
+      res = {
+        redirect: vi.fn().mockReturnValue('redirect'),
+        sendStatus: vi.fn().mockReturnValue('sendStatus'),
+      };
+      next = vi.fn().mockReturnValue('next');
     });
 
     test("invoke fallback when 'req.path' starts with '_api'", () => {
@@ -319,7 +330,7 @@ describe('loginRequired', () => {
 
       const result = loginRequiredWithFallback(req, res, next);
 
-      expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
+      expect(crowiMock.aclService.isGuestAllowedToRead).not.toHaveBeenCalled();
       expect(next).not.toHaveBeenCalled();
       expect(res.redirect).not.toHaveBeenCalled();
       expect(res.sendStatus).not.toHaveBeenCalled();
@@ -333,7 +344,7 @@ describe('loginRequired', () => {
 
       const result = loginRequiredWithFallback(req, res, next);
 
-      expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
+      expect(crowiMock.aclService.isGuestAllowedToRead).not.toHaveBeenCalled();
       expect(next).not.toHaveBeenCalled();
       expect(res.sendStatus).not.toHaveBeenCalled();
       expect(res.redirect).not.toHaveBeenCalled();

+ 31 - 6
apps/app/src/server/middlewares/login-required.js → apps/app/src/server/middlewares/login-required.ts

@@ -1,18 +1,39 @@
+import type { IUserHasId } from '@growi/core';
+import type { NextFunction, Request, Response } from 'express';
+
 import { createRedirectToForUnauthenticated } from '~/server/util/createRedirectToForUnauthenticated';
 import loggerFactory from '~/utils/logger';
 
+import type Crowi from '../crowi';
 import { UserStatus } from '../models/user/conts';
 
 const logger = loggerFactory('growi:middleware:login-required');
 
+type RequestWithUser = Request & {
+  user?: IUserHasId;
+  isSharedPage?: boolean;
+  isBrandLogo?: boolean;
+  session?: { redirectTo?: string };
+};
+
+type FallbackFunction = (
+  req: RequestWithUser,
+  res: Response,
+  next: NextFunction,
+) => void;
+
 /**
  * require login handler
- * @param {import('~/server/crowi').default} crowi Crowi instance
- * @param {boolean} isGuestAllowed whether guest user is allowed (default false)
- * @param {function} fallback fallback function which will be triggered when the check cannot be passed
+ * @param crowi Crowi instance
+ * @param isGuestAllowed whether guest user is allowed (default false)
+ * @param fallback fallback function which will be triggered when the check cannot be passed
  */
-module.exports = (crowi, isGuestAllowed = false, fallback = null) => {
-  return (req, res, next) => {
+const loginRequiredFactory = (
+  crowi: Crowi,
+  isGuestAllowed = false,
+  fallback: FallbackFunction | null = null,
+) => {
+  return (req: RequestWithUser, res: Response, next: NextFunction) => {
     // check the user logged in
     if (req.user != null && req.user instanceof Object && '_id' in req.user) {
       if (req.user.status === UserStatus.STATUS_ACTIVE) {
@@ -55,7 +76,11 @@ module.exports = (crowi, isGuestAllowed = false, fallback = null) => {
     if (fallback != null) {
       return fallback(req, res, next);
     }
-    req.session.redirectTo = req.originalUrl;
+    if (req.session != null) {
+      req.session.redirectTo = req.originalUrl;
+    }
     return res.redirect('/login');
   };
 };
+
+export default loginRequiredFactory;

+ 13 - 25
apps/app/test/integration/models/page-redirect.test.js → apps/app/src/server/models/page-redirect.integ.ts

@@ -1,18 +1,6 @@
-import mongoose from 'mongoose';
-
-import { getInstance } from '../setup-crowi';
+import PageRedirect from './page-redirect';
 
 describe('PageRedirect', () => {
-  // biome-ignore lint/correctness/noUnusedVariables: ignore
-  let crowi;
-  let PageRedirect;
-
-  beforeAll(async () => {
-    crowi = await getInstance();
-
-    PageRedirect = mongoose.model('PageRedirect');
-  });
-
   beforeEach(async () => {
     // clear collection
     await PageRedirect.deleteMany({});
@@ -91,12 +79,12 @@ describe('PageRedirect', () => {
 
       // then:
       expect(endpoints).not.toBeNull();
-      expect(endpoints.start).not.toBeNull();
-      expect(endpoints.start.fromPath).toEqual('/path1');
-      expect(endpoints.start.toPath).toEqual('/path2');
-      expect(endpoints.end).not.toBeNull();
-      expect(endpoints.end.fromPath).toEqual('/path1');
-      expect(endpoints.end.toPath).toEqual('/path2');
+      expect(endpoints?.start).not.toBeNull();
+      expect(endpoints?.start.fromPath).toEqual('/path1');
+      expect(endpoints?.start.toPath).toEqual('/path2');
+      expect(endpoints?.end).not.toBeNull();
+      expect(endpoints?.end.fromPath).toEqual('/path1');
+      expect(endpoints?.end.toPath).toEqual('/path2');
     });
 
     test('shoud return IPageRedirectEnds', async () => {
@@ -117,12 +105,12 @@ describe('PageRedirect', () => {
 
       // then:
       expect(endpoints).not.toBeNull();
-      expect(endpoints.start).not.toBeNull();
-      expect(endpoints.start.fromPath).toEqual('/path1');
-      expect(endpoints.start.toPath).toEqual('/path2');
-      expect(endpoints.end).not.toBeNull();
-      expect(endpoints.end.fromPath).toEqual('/path3');
-      expect(endpoints.end.toPath).toEqual('/path4');
+      expect(endpoints?.start).not.toBeNull();
+      expect(endpoints?.start.fromPath).toEqual('/path1');
+      expect(endpoints?.start.toPath).toEqual('/path2');
+      expect(endpoints?.end).not.toBeNull();
+      expect(endpoints?.end.fromPath).toEqual('/path3');
+      expect(endpoints?.end.toPath).toEqual('/path4');
     });
   });
 });

+ 75 - 32
apps/app/test/integration/models/page.test.js → apps/app/src/server/models/page.integ.ts

@@ -1,31 +1,74 @@
-const mongoose = require('mongoose');
+import { EventEmitter } from 'node:events';
+import { mock } from 'vitest-mock-extended';
 
-const { getInstance } = require('../setup-crowi');
+import type Crowi from '~/server/crowi';
+import { configManager } from '~/server/service/config-manager';
+import type { S2sMessagingService } from '~/server/service/s2s-messaging/base';
 
-let testUser0;
-let testUser1;
-let testUser2;
-let testGroup0;
-let parentPage;
+// Minimal mock for PageEvent that extends EventEmitter
+class MockPageEvent extends EventEmitter {
+  onCreate = vi.fn();
+
+  onUpdate = vi.fn();
+
+  onCreateMany = vi.fn();
+
+  onAddSeenUsers = vi.fn();
+}
 
 describe('Page', () => {
-  // biome-ignore lint/correctness/noUnusedVariables: ignore
-  let crowi;
-  let Page;
-  let PageQueryBuilder;
-  let User;
-  let UserGroup;
-  let UserGroupRelation;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  let Page: any;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  let PageQueryBuilder: any;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  let User: any;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  let UserGroup: any;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  let UserGroupRelation: any;
+
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  let testUser0: any;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  let testUser1: any;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  let testUser2: any;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  let testGroup0: any;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  let parentPage: any;
+
+  // Mock Crowi instance with minimal required properties
+  const mockPageEvent = new MockPageEvent();
+  const crowiMock = {
+    events: {
+      page: mockPageEvent,
+    },
+  } as unknown as Crowi;
 
   beforeAll(async () => {
-    crowi = await getInstance();
-
-    User = mongoose.model('User');
-    UserGroup = mongoose.model('UserGroup');
-    UserGroupRelation = mongoose.model('UserGroupRelation');
-    Page = mongoose.model('Page');
+    // Initialize configManager
+    const s2sMessagingServiceMock = mock<S2sMessagingService>();
+    configManager.setS2sMessagingService(s2sMessagingServiceMock);
+    await configManager.loadConfigs();
+
+    // Initialize models with mocked Crowi using dynamic import
+    const pageModule = await import('./page');
+    const pageFactory = pageModule.default;
+    Page = pageFactory(crowiMock);
     PageQueryBuilder = Page.PageQueryBuilder;
 
+    const userModule = await import('./user/index');
+    const userFactory = userModule.default;
+    User = userFactory(null);
+
+    const userGroupModule = await import('./user-group');
+    UserGroup = userGroupModule.default;
+
+    const userGroupRelationModule = await import('./user-group-relation');
+    UserGroupRelation = userGroupRelationModule.default;
+
     await User.insertMany([
       {
         name: 'Anon 0',
@@ -278,7 +321,7 @@ describe('Page', () => {
       // assert totalCount
       expect(result.length).toEqual(1);
       // assert paths
-      const pagePaths = result.map((page) => {
+      const pagePaths = result.map((page: { path: string }) => {
         return page.path;
       });
       expect(pagePaths).toContainEqual('/page/child/without/parents');
@@ -293,7 +336,7 @@ describe('Page', () => {
       // assert totalCount
       expect(result.length).toEqual(2);
       // assert paths
-      const pagePaths = result.map((page) => {
+      const pagePaths = result.map((page: { path: string }) => {
         return page.path;
       });
       expect(pagePaths).toContainEqual('/page1');
@@ -311,7 +354,7 @@ describe('Page', () => {
       // assert totalCount
       expect(result.length).toEqual(1);
       // assert paths
-      const pagePaths = result.map((page) => {
+      const pagePaths = result.map((page: { path: string }) => {
         return page.path;
       });
       expect(pagePaths).toContainEqual('/page/child/without/parents');
@@ -326,7 +369,7 @@ describe('Page', () => {
       // assert totalCount
       expect(result.length).toEqual(1);
       // assert paths
-      const pagePaths = result.map((page) => {
+      const pagePaths = result.map((page: { path: string }) => {
         return page.path;
       });
       expect(pagePaths).toContainEqual('/page1/child1');
@@ -343,7 +386,7 @@ describe('Page', () => {
       // assert totalCount
       expect(result.length).toEqual(4);
       // assert paths
-      const pagePaths = result.map((page) => {
+      const pagePaths = result.map((page: { path: string }) => {
         return page.path;
       });
       expect(pagePaths).toContainEqual('/page/child/without/parents');
@@ -362,7 +405,7 @@ describe('Page', () => {
       expect(pages.length).toEqual(5);
 
       // assert paths
-      const pagePaths = await pages.map((page) => {
+      const pagePaths = pages.map((page: { path: string }) => {
         return page.path;
       });
       expect(pagePaths).toContainEqual('/grant/groupacl');
@@ -380,7 +423,7 @@ describe('Page', () => {
       expect(pages.length).toEqual(5);
 
       // assert paths
-      const pagePaths = await pages.map((page) => {
+      const pagePaths = pages.map((page: { path: string }) => {
         return page.path;
       });
       expect(pagePaths).toContainEqual('/grant/groupacl');
@@ -398,7 +441,7 @@ describe('Page', () => {
       expect(pages.length).toEqual(5);
 
       // assert paths
-      const pagePaths = await pages.map((page) => {
+      const pagePaths = pages.map((page: { path: string }) => {
         return page.path;
       });
       expect(pagePaths).toContainEqual('/grant/groupacl');
@@ -416,7 +459,7 @@ describe('Page', () => {
       expect(pages.length).toEqual(5);
 
       // assert paths
-      const pagePaths = await pages.map((page) => {
+      const pagePaths = pages.map((page: { path: string }) => {
         return page.path;
       });
       expect(pagePaths).toContainEqual('/grant/groupacl');
@@ -438,7 +481,7 @@ describe('Page', () => {
       expect(pages.length).toEqual(5);
 
       // assert paths
-      const pagePaths = await pages.map((page) => {
+      const pagePaths = pages.map((page: { path: string }) => {
         return page.path;
       });
       expect(pagePaths).toContainEqual('/grant/groupacl');
@@ -458,7 +501,7 @@ describe('Page', () => {
       expect(pages.length).toEqual(3);
 
       // assert paths
-      const pagePaths = await pages.map((page) => {
+      const pagePaths = pages.map((page: { path: string }) => {
         return page.path;
       });
       expect(pagePaths).toContainEqual('/grant/groupacl');
@@ -476,7 +519,7 @@ describe('Page', () => {
       expect(pages.length).toEqual(2);
 
       // assert paths
-      const pagePaths = await pages.map((page) => {
+      const pagePaths = pages.map((page: { path: string }) => {
         return page.path;
       });
       expect(pagePaths).toContainEqual('/grant/public');

+ 1 - 1
apps/app/test/integration/models/update-post.test.js → apps/app/src/server/models/update-post.spec.ts

@@ -1,4 +1,4 @@
-import UpdatePost from '../../../src/server/models/update-post';
+import UpdatePost from './update-post';
 
 describe('UpdatePost', () => {
   describe('.createPrefixesByPathPattern', () => {

+ 59 - 30
apps/app/test/integration/models/user.test.js → apps/app/src/server/models/user/user.integ.ts

@@ -1,18 +1,39 @@
-const mongoose = require('mongoose');
+import type mongoose from 'mongoose';
+import { mock } from 'vitest-mock-extended';
 
-const { getInstance } = require('../setup-crowi');
-const { UserStatus } = require('../../../src/server/models/user/conts');
+import type Crowi from '~/server/crowi';
+import type UserEvent from '~/server/events/user';
+import { configManager } from '~/server/service/config-manager';
+import type { S2sMessagingService } from '~/server/service/s2s-messaging/base';
 
-describe('User', () => {
-  // biome-ignore lint/correctness/noUnusedVariables: ignore
-  let crowi;
-  let User;
+import { UserStatus } from './conts';
 
-  let adminusertestToBeRemovedId;
+describe('User', () => {
+  let User: any;
+  let adminusertestToBeRemovedId: mongoose.Types.ObjectId;
 
   beforeAll(async () => {
-    crowi = await getInstance();
-    User = mongoose.model('User');
+    // Initialize configManager
+    const s2sMessagingServiceMock = mock<S2sMessagingService>();
+    configManager.setS2sMessagingService(s2sMessagingServiceMock);
+    await configManager.loadConfigs();
+
+    // Mock Crowi instance with required properties
+    const crowiMock = mock<Crowi>({
+      events: {
+        user: mock<UserEvent>({
+          on: vi.fn(),
+        }),
+      },
+      env: {
+        PASSWORD_SEED: 'testPasswordSeed',
+      },
+    });
+
+    // Initialize User model with mocked Crowi using dynamic import
+    const userModule = await import('./index');
+    const userFactory = userModule.default;
+    User = userFactory(crowiMock);
 
     await User.insertMany([
       {
@@ -61,20 +82,26 @@ describe('User', () => {
 
   describe('Create and Find.', () => {
     describe('The user', () => {
-      test('should created with createUserByEmailAndPassword', (done) => {
-        User.createUserByEmailAndPassword(
-          'Example2 for User Test',
-          'usertest2',
-          'usertest2@example.com',
-          'usertest2pass',
-          'en_US',
-          (err, userData) => {
-            expect(err).toBeNull();
-            expect(userData).toBeInstanceOf(User);
-            expect(userData.name).toBe('Example2 for User Test');
-            done();
-          },
-        );
+      test('should created with createUserByEmailAndPassword', async () => {
+        await new Promise<void>((resolve, reject) => {
+          User.createUserByEmailAndPassword(
+            'Example2 for User Test',
+            'usertest2',
+            'usertest2@example.com',
+            'usertest2pass',
+            'en_US',
+            (err: Error | null, userData: typeof User) => {
+              try {
+                expect(err).toBeNull();
+                expect(userData).toBeInstanceOf(User);
+                expect(userData.name).toBe('Example2 for User Test');
+                resolve();
+              } catch (error) {
+                reject(error);
+              }
+            },
+          );
+        });
       });
 
       test('should be found by findUserByUsername', async () => {
@@ -106,13 +133,14 @@ describe('User', () => {
     test('should retrieves only active users', async () => {
       const users = await User.findAdmins();
       const adminusertestActive = users.find(
-        (user) => user.username === 'adminusertest1',
+        (user: { username: string }) => user.username === 'adminusertest1',
       );
       const adminusertestSuspended = users.find(
-        (user) => user.username === 'adminusertest2',
+        (user: { username: string }) => user.username === 'adminusertest2',
       );
       const adminusertestToBeRemoved = users.find(
-        (user) => user._id.toString() === adminusertestToBeRemovedId.toString(),
+        (user: { _id: mongoose.Types.ObjectId }) =>
+          user._id.toString() === adminusertestToBeRemovedId.toString(),
       );
 
       expect(adminusertestActive).toBeInstanceOf(User);
@@ -125,13 +153,14 @@ describe('User', () => {
         status: [UserStatus.STATUS_ACTIVE, UserStatus.STATUS_SUSPENDED],
       });
       const adminusertestActive = users.find(
-        (user) => user.username === 'adminusertest1',
+        (user: { username: string }) => user.username === 'adminusertest1',
       );
       const adminusertestSuspended = users.find(
-        (user) => user.username === 'adminusertest2',
+        (user: { username: string }) => user.username === 'adminusertest2',
       );
       const adminusertestToBeRemoved = users.find(
-        (user) => user._id.toString() === adminusertestToBeRemovedId.toString(),
+        (user: { _id: mongoose.Types.ObjectId }) =>
+          user._id.toString() === adminusertestToBeRemovedId.toString(),
       );
 
       expect(adminusertestActive).toBeInstanceOf(User);

+ 305 - 225
apps/app/test/integration/models/v5.page.test.js → apps/app/src/server/models/v5.page.integ.ts

@@ -1,50 +1,82 @@
-import { GroupType, getIdForRef, PageGrant } from '@growi/core';
-import mongoose from 'mongoose';
-
-import { ExternalGroupProviderType } 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 UserGroup from '../../../src/server/models/user-group';
-import UserGroupRelation from '../../../src/server/models/user-group-relation';
-import { getInstance } from '../setup-crowi';
+import assert from 'node:assert';
+import {
+  GroupType,
+  getIdForRef,
+  type IGrantedGroup,
+  type IRevision,
+  type IUser,
+  PageGrant,
+} from '@growi/core/dist/interfaces';
+import mongoose, { type HydratedDocument, type Model } from 'mongoose';
+
+import { getInstance } from '^/test/setup/crowi';
+
+import type { CommentModel } from '~/features/comment/server';
+import { ExternalGroupProviderType } from '~/features/external-user-group/interfaces/external-user-group';
+import ExternalUserGroup from '~/features/external-user-group/server/models/external-user-group';
+import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
+import type { IBookmark } from '~/interfaces/bookmark-info';
+import type { IComment } from '~/interfaces/comment';
+import { PageActionType } from '~/interfaces/page-operation';
+import type { IShareLink } from '~/interfaces/share-link';
+import type Crowi from '~/server/crowi';
+import type { PageDocument, PageModel } from '~/server/models/page';
+import type {
+  IPageOperation,
+  PageOperationModel,
+} from '~/server/models/page-operation';
+import UserGroup from '~/server/models/user-group';
+import UserGroupRelation from '~/server/models/user-group-relation';
+
+import type { IPageService } from '../service/page';
+import type { BookmarkModel } from './bookmark';
+import type { IPageRedirect, PageRedirectModel } from './page-redirect';
+import type { ShareLinkModel } from './share-link';
 
 describe('Page', () => {
-  let crowi;
-  let pageGrantService;
-  let pageService;
-
-  let Page;
-  let Revision;
-  let User;
-  let Bookmark;
-  let Comment;
-  let ShareLink;
-  let PageRedirect;
-  let xssSpy;
-
-  let rootPage;
-  let dummyUser1;
-  let pModelUser1;
-  let pModelUser2;
-  let pModelUser3;
-  let userGroupIdPModelIsolate;
-  let userGroupIdPModelA;
-  let userGroupIdPModelB;
-  let userGroupIdPModelC;
-  let externalUserGroupIdPModelIsolate;
-  let externalUserGroupIdPModelA;
-  let externalUserGroupIdPModelB;
-  let externalUserGroupIdPModelC;
+  let crowi: Crowi;
+  let pageService: IPageService;
+
+  let Page: PageModel;
+  let Revision: Model<IRevision>;
+  let Bookmark: BookmarkModel;
+  let Comment: CommentModel;
+  let User: Model<IUser>;
+  let ShareLink: ShareLinkModel;
+  let PageRedirect: PageRedirectModel;
+  let PageOperation: PageOperationModel;
+
+  let rootPage: PageDocument;
+  let dummyUser1: HydratedDocument<IUser>;
+  let pModelUser1: HydratedDocument<IUser>;
+  let pModelUser2: HydratedDocument<IUser>;
+  let pModelUser3: HydratedDocument<IUser>;
+  let userGroupIdPModelIsolate: mongoose.Types.ObjectId;
+  let userGroupIdPModelA: mongoose.Types.ObjectId;
+  let userGroupIdPModelB: mongoose.Types.ObjectId;
+  let userGroupIdPModelC: mongoose.Types.ObjectId;
+  let externalUserGroupIdPModelIsolate: mongoose.Types.ObjectId;
+  let externalUserGroupIdPModelA: mongoose.Types.ObjectId;
+  let externalUserGroupIdPModelB: mongoose.Types.ObjectId;
+  let externalUserGroupIdPModelC: mongoose.Types.ObjectId;
 
   // To test updatePage overwriting descendants (prefix `upod`)
-  let upodUserA;
-  let upodUserB;
-  let upodUserC;
-  let upodGroupAB;
-  let upodGroupA;
-  let upodGroupAIsolated;
-  let upodGroupB;
-  let upodGroupC;
+  // biome-ignore lint/suspicious/noExplicitAny: <explanation>
+  let upodUserA: any;
+  // biome-ignore lint/suspicious/noExplicitAny: <explanation>
+  let upodUserB: any;
+  // biome-ignore lint/suspicious/noExplicitAny: <explanation>
+  let upodUserC: any;
+  // biome-ignore lint/suspicious/noExplicitAny: <explanation>
+  let upodGroupAB: any;
+  // biome-ignore lint/suspicious/noExplicitAny: <explanation>
+  let upodGroupA: any;
+  // biome-ignore lint/suspicious/noExplicitAny: <explanation>
+  let upodGroupAIsolated: any;
+  // biome-ignore lint/suspicious/noExplicitAny: <explanation>
+  let upodGroupB: any;
+  // biome-ignore lint/suspicious/noExplicitAny: <explanation>
+  let upodGroupC: any;
   const upodUserGroupIdA = new mongoose.Types.ObjectId();
   const upodUserGroupIdAIsolated = new mongoose.Types.ObjectId();
   const upodUserGroupIdB = new mongoose.Types.ObjectId();
@@ -62,18 +94,20 @@ describe('Page', () => {
   const upodPageIdPublic5 = new mongoose.Types.ObjectId();
   const upodPageIdPublic6 = new mongoose.Types.ObjectId();
 
-  // Since updatePageSubOperation is asyncronously called from updatePageSubOperation,
-  // mock it inside updatePageSubOperation, and later call it independently to await for it's execution.
+  // Since updatePageSubOperation is asynchronously called from updatePage,
+  // we use a polling pattern to wait for the async operation to complete.
+  // The PageOperation document is deleted when updatePageSubOperation finishes.
   const updatePage = async (
-    page,
-    newRevisionBody,
-    oldRevisionBody,
-    user,
-    options = {},
+    // biome-ignore lint/suspicious/noExplicitAny: <explanation>
+    page: any,
+    newRevisionBody: string,
+    oldRevisionBody: string,
+    // biome-ignore lint/suspicious/noExplicitAny: <explanation>
+    user: any,
+    // biome-ignore lint/suspicious/noExplicitAny: <explanation>
+    options: any = {},
   ) => {
-    const mockedUpdatePageSubOperation = jest
-      .spyOn(pageService, 'updatePageSubOperation')
-      .mockReturnValue(null);
+    const fromPath = page.path;
 
     const savedPage = await pageService.updatePage(
       page,
@@ -83,12 +117,19 @@ describe('Page', () => {
       options,
     );
 
-    const argsForUpdatePageSubOperation =
-      mockedUpdatePageSubOperation.mock.calls[0];
-
-    mockedUpdatePageSubOperation.mockRestore();
-
-    await pageService.updatePageSubOperation(...argsForUpdatePageSubOperation);
+    // Wait for the async updatePageSubOperation to complete by polling PageOperation
+    const startTime = Date.now();
+    const maxWaitMs = 5000;
+    while (Date.now() - startTime < maxWaitMs) {
+      const op = await PageOperation.findOne({
+        fromPath,
+        actionType: PageActionType.Update,
+      });
+      if (op == null) {
+        break; // Operation completed
+      }
+      await new Promise((resolve) => setTimeout(resolve, 50));
+    }
 
     return savedPage;
   };
@@ -508,31 +549,61 @@ describe('Page', () => {
   };
 
   // normalize for result comparison
-  const normalizeGrantedGroups = (grantedGroups) => {
-    return grantedGroups.map((group) => {
+  const normalizeGrantedGroups = (
+    grantedGroups: IGrantedGroup[] | undefined,
+  ) => {
+    return grantedGroups?.map((group) => {
       return { item: getIdForRef(group.item), type: group.type };
     });
   };
 
   beforeAll(async () => {
     crowi = await getInstance();
-    pageGrantService = crowi.pageGrantService;
     pageService = crowi.pageService;
 
     await crowi.configManager.updateConfig('app:isV5Compatible', true);
 
-    jest.restoreAllMocks();
+    vi.restoreAllMocks();
     User = mongoose.model('User');
-    Page = mongoose.model('Page');
+    Page = mongoose.model('Page') as PageModel;
     Revision = mongoose.model('Revision');
-    Bookmark = mongoose.model('Bookmark');
-    Comment = mongoose.model('Comment');
-    ShareLink = mongoose.model('ShareLink');
-    PageRedirect = mongoose.model('PageRedirect');
-
-    dummyUser1 = await User.findOne({ username: 'v5DummyUser1' });
+    Bookmark = mongoose.model<IBookmark, BookmarkModel>('Bookmark');
+    Comment = mongoose.model<IComment, CommentModel>('Comment');
+    ShareLink = mongoose.model<IShareLink, ShareLinkModel>('ShareLink');
+    PageRedirect = mongoose.model<IPageRedirect, PageRedirectModel>(
+      'PageRedirect',
+    );
+    PageOperation = mongoose.model<IPageOperation, PageOperationModel>(
+      'PageOperation',
+    );
 
-    rootPage = await Page.findOne({ path: '/' });
+    // Create dummy user if it doesn't exist
+    const existingUser1 = await User.findOne({ username: 'v5DummyUser1' });
+    if (existingUser1 == null) {
+      await User.insertMany([
+        {
+          name: 'v5DummyUser1',
+          username: 'v5DummyUser1',
+          email: 'v5dummyuser1@example.com',
+        },
+      ]);
+    }
+    const foundDummyUser1 = await User.findOne({ username: 'v5DummyUser1' });
+    assert(foundDummyUser1 != null);
+    dummyUser1 = foundDummyUser1;
+
+    // Ensure root page exists
+    const existingRootPage = await Page.findOne({ path: '/' });
+    if (existingRootPage == null) {
+      const rootPageId = new mongoose.Types.ObjectId();
+      rootPage = await Page.create({
+        _id: rootPageId,
+        path: '/',
+        grant: PageGrant.GRANT_PUBLIC,
+      });
+    } else {
+      rootPage = existingRootPage;
+    }
 
     const pModelUserId1 = new mongoose.Types.ObjectId();
     const pModelUserId2 = new mongoose.Types.ObjectId();
@@ -557,9 +628,15 @@ describe('Page', () => {
         email: 'pModelUser3@example.com',
       },
     ]);
-    pModelUser1 = await User.findOne({ _id: pModelUserId1 });
-    pModelUser2 = await User.findOne({ _id: pModelUserId2 });
-    pModelUser3 = await User.findOne({ _id: pModelUserId3 });
+    const foundPModelUser1 = await User.findOne({ _id: pModelUserId1 });
+    const foundPModelUser2 = await User.findOne({ _id: pModelUserId2 });
+    const foundPModelUser3 = await User.findOne({ _id: pModelUserId3 });
+    assert(foundPModelUser1 != null);
+    assert(foundPModelUser2 != null);
+    assert(foundPModelUser3 != null);
+    pModelUser1 = foundPModelUser1;
+    pModelUser2 = foundPModelUser2;
+    pModelUser3 = foundPModelUser3;
 
     userGroupIdPModelIsolate = new mongoose.Types.ObjectId();
     userGroupIdPModelA = new mongoose.Types.ObjectId();
@@ -1098,7 +1175,7 @@ describe('Page', () => {
 
   describe('updatePage with overwriteScopesOfDescendants false', () => {
     describe('Changing grant from PUBLIC to RESTRICTED of', () => {
-      test('an only-child page will delete its empty parent page', async () => {
+      it('an only-child page will delete its empty parent page', async () => {
         const pathT = '/mup13_top';
         const path1 = '/mup13_top/mup1_emp';
         const path2 = '/mup13_top/mup1_emp/mup2_pub';
@@ -1133,9 +1210,9 @@ describe('Page', () => {
         expect(_pageT).toBeTruthy();
         expect(_page1).toBeNull();
         expect(_page2).toBeTruthy();
-        expect(_pageT.descendantCount).toBe(1);
+        expect(_pageT?.descendantCount).toBe(1);
       });
-      test('a page that has children will create an empty page with the same path and it becomes a new parent', async () => {
+      it('a page that has children will create an empty page with the same path and it becomes a new parent', async () => {
         const pathT = '/mup14_top';
         const path1 = '/mup14_top/mup6_pub';
         const path2 = '/mup14_top/mup6_pub/mup7_pub';
@@ -1174,14 +1251,14 @@ describe('Page', () => {
         expect(_page2).toBeTruthy();
         expect(_pageN).toBeTruthy();
 
-        expect(_page1.parent).toBeNull();
-        expect(_page2.parent).toStrictEqual(_pageN._id);
-        expect(_pageN.parent).toStrictEqual(top._id);
-        expect(_pageN.isEmpty).toBe(true);
-        expect(_pageN.descendantCount).toBe(1);
-        expect(_top.descendantCount).toBe(1);
+        expect(_page1?.parent).toBeNull();
+        expect(_page2?.parent).toStrictEqual(_pageN?._id);
+        expect(_pageN?.parent).toStrictEqual(top?._id);
+        expect(_pageN?.isEmpty).toBe(true);
+        expect(_pageN?.descendantCount).toBe(1);
+        expect(_top?.descendantCount).toBe(1);
       });
-      test('of a leaf page will NOT have an empty page with the same path', async () => {
+      it('of a leaf page will NOT have an empty page with the same path', async () => {
         const pathT = '/mup15_top';
         const path1 = '/mup15_top/mup8_pub';
         const pageT = await Page.findOne({ path: pathT, descendantCount: 1 });
@@ -1189,7 +1266,7 @@ describe('Page', () => {
           path: path1,
           grant: Page.GRANT_PUBLIC,
         });
-        const count = await Page.count({ path: path1 });
+        const count = await Page.countDocuments({ path: path1 });
         expect(pageT).toBeTruthy();
         expect(page1).toBeTruthy();
         expect(count).toBe(1);
@@ -1214,12 +1291,12 @@ describe('Page', () => {
         expect(_pageT).toBeTruthy();
         expect(_page1).toBeTruthy();
         expect(_pageNotExist).toBeNull();
-        expect(_pageT.descendantCount).toBe(0);
+        expect(_pageT?.descendantCount).toBe(0);
       });
     });
 
     describe('Changing grant to GRANT_RESTRICTED', () => {
-      test('successfully change to GRANT_RESTRICTED from GRANT_OWNER', async () => {
+      it('successfully change to GRANT_RESTRICTED from GRANT_OWNER', async () => {
         const path = '/mup40';
         const _page = await Page.findOne({
           path,
@@ -1238,13 +1315,13 @@ describe('Page', () => {
 
         const page = await Page.findOne({ path });
         expect(page).toBeTruthy();
-        expect(page.grant).toBe(Page.GRANT_RESTRICTED);
-        expect(page.grantedUsers).toStrictEqual([]);
+        expect(page?.grant).toBe(Page.GRANT_RESTRICTED);
+        expect(page?.grantedUsers).toStrictEqual([]);
       });
     });
 
     describe('Changing grant from RESTRICTED to PUBLIC of', () => {
-      test('a page will create ancestors if they do not exist', async () => {
+      it('a page will create ancestors if they do not exist', async () => {
         const pathT = '/mup16_top';
         const path1 = '/mup16_top/mup9_pub';
         const path2 = '/mup16_top/mup9_pub/mup10_pub';
@@ -1279,12 +1356,12 @@ describe('Page', () => {
         expect(_page1).toBeTruthy();
         expect(_page2).toBeTruthy();
         expect(_page3).toBeTruthy();
-        expect(_page1.parent).toStrictEqual(top._id);
-        expect(_page2.parent).toStrictEqual(_page1._id);
-        expect(_page3.parent).toStrictEqual(_page2._id);
-        expect(_pageT.descendantCount).toBe(1);
+        expect(_page1?.parent).toStrictEqual(top?._id);
+        expect(_page2?.parent).toStrictEqual(_page1?._id);
+        expect(_page3?.parent).toStrictEqual(_page2?._id);
+        expect(_pageT?.descendantCount).toBe(1);
       });
-      test('a page will replace an empty page with the same path if any', async () => {
+      it('a page will replace an empty page with the same path if any', async () => {
         const pathT = '/mup17_top';
         const path1 = '/mup17_top/mup12_emp';
         const path2 = '/mup17_top/mup12_emp/mup18_pub';
@@ -1320,15 +1397,15 @@ describe('Page', () => {
         expect(_page1).toBeNull();
         expect(_page2).toBeTruthy();
         expect(_page3).toBeTruthy();
-        expect(_page2.grant).toBe(Page.GRANT_PUBLIC);
-        expect(_page2.parent).toStrictEqual(_pageT._id);
-        expect(_page3.parent).toStrictEqual(_page2._id);
-        expect(_pageT.descendantCount).toBe(2);
+        expect(_page2?.grant).toBe(Page.GRANT_PUBLIC);
+        expect(_page2?.parent).toStrictEqual(_pageT?._id);
+        expect(_page3?.parent).toStrictEqual(_page2?._id);
+        expect(_pageT?.descendantCount).toBe(2);
       });
     });
 
     describe('Changing grant to GRANT_OWNER(onlyme)', () => {
-      test('successfully change to GRANT_OWNER from GRANT_PUBLIC', async () => {
+      it('successfully change to GRANT_OWNER from GRANT_PUBLIC', async () => {
         const path = '/mup19';
         const _page = await Page.findOne({ path, grant: Page.GRANT_PUBLIC });
         expect(_page).toBeTruthy();
@@ -1342,10 +1419,10 @@ describe('Page', () => {
         );
 
         const page = await Page.findOne({ path });
-        expect(page.grant).toBe(Page.GRANT_OWNER);
-        expect(page.grantedUsers).toStrictEqual([dummyUser1._id]);
+        expect(page?.grant).toBe(Page.GRANT_OWNER);
+        expect(page?.grantedUsers).toStrictEqual([dummyUser1._id]);
       });
-      test('successfully change to GRANT_OWNER from GRANT_USER_GROUP', async () => {
+      it('successfully change to GRANT_OWNER from GRANT_USER_GROUP', async () => {
         const path = '/mup20';
         const _page = await Page.findOne({
           path,
@@ -1363,11 +1440,11 @@ describe('Page', () => {
         );
 
         const page = await Page.findOne({ path });
-        expect(page.grant).toBe(Page.GRANT_OWNER);
-        expect(page.grantedUsers).toStrictEqual([pModelUser1._id]);
-        expect(page.grantedGroups.length).toBe(0);
+        expect(page?.grant).toBe(Page.GRANT_OWNER);
+        expect(page?.grantedUsers).toStrictEqual([pModelUser1._id]);
+        expect(page?.grantedGroups?.length).toBe(0);
       });
-      test('successfully change to GRANT_OWNER from GRANT_RESTRICTED', async () => {
+      it('successfully change to GRANT_OWNER from GRANT_RESTRICTED', async () => {
         const path = '/mup21';
         const _page = await Page.findOne({
           path,
@@ -1384,10 +1461,10 @@ describe('Page', () => {
         );
 
         const page = await Page.findOne({ path });
-        expect(page.grant).toBe(Page.GRANT_OWNER);
-        expect(page.grantedUsers).toStrictEqual([dummyUser1._id]);
+        expect(page?.grant).toBe(Page.GRANT_OWNER);
+        expect(page?.grantedUsers).toStrictEqual([dummyUser1._id]);
       });
-      test('Failed to change to GRANT_OWNER if one of the ancestors is GRANT_USER_GROUP page', async () => {
+      it('Failed to change to GRANT_OWNER if one of the ancestors is GRANT_USER_GROUP page', async () => {
         const path1 = '/mup22';
         const path2 = '/mup22/mup23';
         const _page1 = await Page.findOne({
@@ -1414,13 +1491,13 @@ describe('Page', () => {
 
         const page1 = await Page.findOne({ path1 });
         expect(page1).toBeTruthy();
-        expect(page1.grant).toBe(Page.GRANT_PUBLIC);
-        expect(page1.grantedUsers).not.toStrictEqual([dummyUser1._id]);
+        expect(page1?.grant).toBe(Page.GRANT_PUBLIC);
+        expect(page1?.grantedUsers).not.toStrictEqual([dummyUser1._id]);
       });
     });
     describe('Changing grant to GRANT_USER_GROUP', () => {
       describe('update grant of a page under a page with GRANT_PUBLIC', () => {
-        test('successfully change to GRANT_USER_GROUP from GRANT_PUBLIC if parent page is GRANT_PUBLIC', async () => {
+        it('successfully change to GRANT_USER_GROUP from GRANT_PUBLIC if parent page is GRANT_PUBLIC', async () => {
           // path
           const path1 = '/mup24_pub';
           const path2 = '/mup24_pub/mup25_pub';
@@ -1432,7 +1509,7 @@ describe('Page', () => {
           const _page2 = await Page.findOne({
             path: path2,
             grant: Page.GRANT_PUBLIC,
-            parent: _page1._id,
+            parent: _page1?._id,
           }); // update target
           expect(_page1).toBeTruthy();
           expect(_page2).toBeTruthy();
@@ -1457,21 +1534,21 @@ describe('Page', () => {
             options,
           ); // from GRANT_PUBLIC to GRANT_USER_GROUP(userGroupIdPModelA)
 
-          const page1 = await Page.findById(_page1._id);
-          const page2 = await Page.findById(_page2._id);
+          const page1 = await Page.findById(_page1?._id);
+          const page2 = await Page.findById(_page2?._id);
           expect(page1).toBeTruthy();
           expect(page2).toBeTruthy();
           expect(updatedPage).toBeTruthy();
-          expect(updatedPage._id).toStrictEqual(page2._id);
+          expect(updatedPage._id).toStrictEqual(page2?._id);
 
           // check page2 grant and group
-          expect(page2.grant).toBe(Page.GRANT_USER_GROUP);
-          expect(normalizeGrantedGroups(page2.grantedGroups)).toStrictEqual(
+          expect(page2?.grant).toBe(Page.GRANT_USER_GROUP);
+          expect(normalizeGrantedGroups(page2?.grantedGroups)).toStrictEqual(
             newGrantedGroups,
           );
         });
 
-        test('successfully change to GRANT_USER_GROUP from GRANT_RESTRICTED if parent page is GRANT_PUBLIC', async () => {
+        it('successfully change to GRANT_USER_GROUP from GRANT_RESTRICTED if parent page is GRANT_PUBLIC', async () => {
           // path
           const _path1 = '/mup26_awl';
           // page
@@ -1501,23 +1578,23 @@ describe('Page', () => {
             options,
           ); // from GRANT_RESTRICTED to GRANT_USER_GROUP(userGroupIdPModelA)
 
-          const page1 = await Page.findById(_page1._id);
+          const page1 = await Page.findById(_page1?._id);
           expect(page1).toBeTruthy();
           expect(updatedPage).toBeTruthy();
-          expect(updatedPage._id).toStrictEqual(page1._id);
+          expect(updatedPage._id).toStrictEqual(page1?._id);
 
           // updated page
-          expect(page1.grant).toBe(Page.GRANT_USER_GROUP);
-          expect(normalizeGrantedGroups(page1.grantedGroups)).toStrictEqual(
+          expect(page1?.grant).toBe(Page.GRANT_USER_GROUP);
+          expect(normalizeGrantedGroups(page1?.grantedGroups)).toStrictEqual(
             newGrantedGroups,
           );
 
           // parent's grant check
-          const parent = await Page.findById(page1.parent);
-          expect(parent.grant).toBe(Page.GRANT_PUBLIC);
+          const parent = await Page.findById(page1?.parent);
+          expect(parent?.grant).toBe(Page.GRANT_PUBLIC);
         });
 
-        test('successfully change to GRANT_USER_GROUP from GRANT_OWNER if parent page is GRANT_PUBLIC', async () => {
+        it('successfully change to GRANT_USER_GROUP from GRANT_OWNER if parent page is GRANT_PUBLIC', async () => {
           // path
           const path1 = '/mup27_pub';
           const path2 = '/mup27_pub/mup28_owner';
@@ -1530,7 +1607,7 @@ describe('Page', () => {
             path: path2,
             grant: Page.GRANT_OWNER,
             grantedUsers: [pModelUser1],
-            parent: _page1._id,
+            parent: _page1?._id,
           }); // update target
           expect(_page1).toBeTruthy();
           expect(_page2).toBeTruthy();
@@ -1555,23 +1632,23 @@ describe('Page', () => {
             options,
           ); // from GRANT_OWNER to GRANT_USER_GROUP(userGroupIdPModelA)
 
-          const page1 = await Page.findById(_page1._id);
-          const page2 = await Page.findById(_page2._id);
+          const page1 = await Page.findById(_page1?._id);
+          const page2 = await Page.findById(_page2?._id);
           expect(page1).toBeTruthy();
           expect(page2).toBeTruthy();
           expect(updatedPage).toBeTruthy();
-          expect(updatedPage._id).toStrictEqual(page2._id);
+          expect(updatedPage._id).toStrictEqual(page2?._id);
 
           // grant check
-          expect(page2.grant).toBe(Page.GRANT_USER_GROUP);
-          expect(normalizeGrantedGroups(page2.grantedGroups)).toStrictEqual(
+          expect(page2?.grant).toBe(Page.GRANT_USER_GROUP);
+          expect(normalizeGrantedGroups(page2?.grantedGroups)).toStrictEqual(
             newGrantedGroups,
           );
-          expect(page2.grantedUsers.length).toBe(0);
+          expect(page2?.grantedUsers?.length).toBe(0);
         });
       });
       describe('update grant of a page under a page with GRANT_USER_GROUP', () => {
-        test('successfully change to GRANT_USER_GROUP if the group to set is the child or descendant of the parent page group', async () => {
+        it('successfully change to GRANT_USER_GROUP if the group to set is the child or descendant of the parent page group', async () => {
           // path
           const _path1 = '/mup29_A';
           const _path2 = '/mup29_A/mup30_owner';
@@ -1586,7 +1663,7 @@ describe('Page', () => {
             path: _path2,
             grant: Page.GRANT_OWNER,
             grantedUsers: [pModelUser1],
-            parent: _page1._id,
+            parent: _page1?._id,
           });
           expect(_page1).toBeTruthy();
           expect(_page2).toBeTruthy();
@@ -1612,18 +1689,18 @@ describe('Page', () => {
             options,
           ); // from GRANT_OWNER to GRANT_USER_GROUP(userGroupIdPModelB)
 
-          const page1 = await Page.findById(_page1._id);
-          const page2 = await Page.findById(_page2._id);
+          const page1 = await Page.findById(_page1?._id);
+          const page2 = await Page.findById(_page2?._id);
           expect(page1).toBeTruthy();
           expect(page2).toBeTruthy();
           expect(updatedPage).toBeTruthy();
-          expect(updatedPage._id).toStrictEqual(page2._id);
+          expect(updatedPage._id).toStrictEqual(page2?._id);
 
-          expect(page2.grant).toBe(Page.GRANT_USER_GROUP);
-          expect(normalizeGrantedGroups(page2.grantedGroups)).toStrictEqual(
+          expect(page2?.grant).toBe(Page.GRANT_USER_GROUP);
+          expect(normalizeGrantedGroups(page2?.grantedGroups)).toStrictEqual(
             newGrantedGroups,
           );
-          expect(page2.grantedUsers.length).toBe(0);
+          expect(page2?.grantedUsers?.length).toBe(0);
 
           // Second round
           // Update group to groupC which is a grandchild from pageA's point of view
@@ -1639,8 +1716,9 @@ describe('Page', () => {
             userRelatedGrantUserGroupIds: secondRoundNewGrantedGroups,
           }; // from GRANT_USER_GROUP(userGroupIdPModelB) to GRANT_USER_GROUP(userGroupIdPModelC)
           // undo grantedGroups populate to prevent Page.hydrate error
-          _page2.grantedGroups.forEach((group) => {
-            group.item = group.item._id;
+          _page2?.grantedGroups?.forEach((group) => {
+            // biome-ignore lint/suspicious/noExplicitAny: <explanation>
+            (group as any).item = (group.item as any)._id;
           });
           const secondRoundUpdatedPage = await updatePage(
             _page2,
@@ -1656,7 +1734,7 @@ describe('Page', () => {
             normalizeGrantedGroups(secondRoundUpdatedPage.grantedGroups),
           ).toStrictEqual(secondRoundNewGrantedGroups);
         });
-        test('Fail to change to GRANT_USER_GROUP if the group to set is NOT the child or descendant of the parent page group', async () => {
+        it('Fail to change to GRANT_USER_GROUP if the group to set is NOT the child or descendant of the parent page group', async () => {
           // path
           const _path1 = '/mup31_A';
           const _path2 = '/mup31_A/mup32_owner';
@@ -1671,7 +1749,7 @@ describe('Page', () => {
             path: _path2,
             grant: Page.GRANT_OWNER,
             grantedUsers: [pModelUser1._id],
-            parent: _page1._id,
+            parent: _page1?._id,
           });
           expect(_page1).toBeTruthy();
           expect(_page2).toBeTruthy();
@@ -1682,7 +1760,7 @@ describe('Page', () => {
           );
           expect(_groupIsolated).toBeTruthy();
           // group parent check
-          expect(_groupIsolated.parent).toBeUndefined(); // should have no parent
+          expect(_groupIsolated?.parent).toBeUndefined(); // should have no parent
 
           const options = {
             grant: Page.GRANT_USER_GROUP,
@@ -1701,16 +1779,16 @@ describe('Page', () => {
               ),
             );
 
-          const page1 = await Page.findById(_page1._id);
-          const page2 = await Page.findById(_page2._id);
+          const page1 = await Page.findById(_page1?._id);
+          const page2 = await Page.findById(_page2?._id);
           expect(page1).toBeTruthy();
           expect(page1).toBeTruthy();
 
-          expect(page2.grant).toBe(Page.GRANT_OWNER); // should be the same before the update
-          expect(page2.grantedUsers).toStrictEqual([pModelUser1._id]); // should be the same before the update
-          expect(page2.grantedGroups.length).toBe(0); // no group should be set
+          expect(page2?.grant).toBe(Page.GRANT_OWNER); // should be the same before the update
+          expect(page2?.grantedUsers).toStrictEqual([pModelUser1._id]); // should be the same before the update
+          expect(page2?.grantedGroups?.length).toBe(0); // no group should be set
         });
-        test('Fail to change to GRANT_USER_GROUP if the group to set is an ancestor of the parent page group', async () => {
+        it('Fail to change to GRANT_USER_GROUP if the group to set is an ancestor of the parent page group', async () => {
           // path
           const _path1 = '/mup33_C';
           const _path2 = '/mup33_C/mup34_owner';
@@ -1725,7 +1803,7 @@ describe('Page', () => {
             path: _path2,
             grant: Page.GRANT_OWNER,
             grantedUsers: [pModelUser3],
-            parent: _page1._id,
+            parent: _page1?._id,
           });
           expect(_page1).toBeTruthy();
           expect(_page2).toBeTruthy();
@@ -1750,18 +1828,18 @@ describe('Page', () => {
               ),
             );
 
-          const page1 = await Page.findById(_page1._id);
-          const page2 = await Page.findById(_page2._id);
+          const page1 = await Page.findById(_page1?._id);
+          const page2 = await Page.findById(_page2?._id);
           expect(page1).toBeTruthy();
           expect(page2).toBeTruthy();
 
-          expect(page2.grant).toBe(Page.GRANT_OWNER); // should be the same before the update
-          expect(page2.grantedUsers).toStrictEqual([pModelUser3._id]); // should be the same before the update
-          expect(page2.grantedGroups.length).toBe(0); // no group should be set
+          expect(page2?.grant).toBe(Page.GRANT_OWNER); // should be the same before the update
+          expect(page2?.grantedUsers).toStrictEqual([pModelUser3._id]); // should be the same before the update
+          expect(page2?.grantedGroups?.length).toBe(0); // no group should be set
         });
       });
       describe('update grant of a page under a page with GRANT_OWNER', () => {
-        test('Fail to change from GRNAT_OWNER', async () => {
+        it('Fail to change from GRNAT_OWNER', async () => {
           // path
           const path1 = '/mup35_owner';
           const path2 = '/mup35_owner/mup36_owner';
@@ -1776,7 +1854,7 @@ describe('Page', () => {
             path: path2,
             grant: Page.GRANT_OWNER,
             grantedUsers: [pModelUser1],
-            parent: _page1._id,
+            parent: _page1?._id,
           });
           expect(_page1).toBeTruthy();
           expect(_page2).toBeTruthy();
@@ -1794,17 +1872,17 @@ describe('Page', () => {
               ),
             );
 
-          const page1 = await Page.findById(_page1.id);
-          const page2 = await Page.findById(_page2.id);
+          const page1 = await Page.findById(_page1?.id);
+          const page2 = await Page.findById(_page2?.id);
           expect(page1).toBeTruthy();
           expect(page2).toBeTruthy();
-          expect(page2.grant).toBe(Page.GRANT_OWNER); // should be the same before the update
-          expect(page2.grantedUsers).toStrictEqual([pModelUser1._id]); // should be the same before the update
-          expect(page2.grantedGroups.length).toBe(0); // no group should be set
+          expect(page2?.grant).toBe(Page.GRANT_OWNER); // should be the same before the update
+          expect(page2?.grantedUsers).toStrictEqual([pModelUser1._id]); // should be the same before the update
+          expect(page2?.grantedGroups?.length).toBe(0); // no group should be set
         });
       });
       describe('update grant of a page from GRANT_USER_GROUP to GRANT_USER_GROUP', () => {
-        test('successfully change the granted groups, with the previous groups wich user is not related to remaining', async () => {
+        it('successfully change the granted groups, with the previous groups wich user is not related to remaining', async () => {
           // path
           const path = '/with_multiple_individual_granted_groups';
           // page
@@ -1834,21 +1912,21 @@ describe('Page', () => {
             options,
           ); // from GRANT_PUBLIC to GRANT_USER_GROUP(userGroupIdPModelA)
 
-          const page = await Page.findById(_page._id);
+          const page = await Page.findById(_page?._id);
           expect(page).toBeTruthy();
           expect(updatedPage).toBeTruthy();
-          expect(updatedPage._id).toStrictEqual(page._id);
+          expect(updatedPage._id).toStrictEqual(page?._id);
 
           // check page grant and group
-          expect(page.grant).toBe(Page.GRANT_USER_GROUP);
-          expect(normalizeGrantedGroups(page.grantedGroups)).toEqual(
+          expect(page?.grant).toBe(Page.GRANT_USER_GROUP);
+          expect(normalizeGrantedGroups(page?.grantedGroups)).toEqual(
             expect.arrayContaining([
               ...newUserRelatedGrantedGroups,
               // userB group remains, although options does not include it
               { item: userGroupIdPModelB, type: GroupType.userGroup },
             ]),
           );
-          expect(normalizeGrantedGroups(page.grantedGroups).length).toBe(3);
+          expect(normalizeGrantedGroups(page?.grantedGroups)?.length).toBe(3);
         });
       });
     });
@@ -1856,7 +1934,7 @@ describe('Page', () => {
 
   // see: https://dev.growi.org/635a314eac6bcd85cbf359fc about the specification
   describe('updatePage with overwriteScopesOfDescendants true', () => {
-    test('(case 1) it should update all granted descendant pages when update grant is GRANT_PUBLIC', async () => {
+    it('(case 1) it should update all granted descendant pages when update grant is GRANT_PUBLIC', async () => {
       const upodPagegAB = await Page.findOne({ path: '/gAB_upod_1' });
       const upodPagegB = await Page.findOne({ path: '/gAB_upod_1/gB_upod_1' });
       const upodPageonlyB = await Page.findOne({
@@ -1871,10 +1949,10 @@ describe('Page', () => {
       expect(upodPageonlyB).not.toBeNull();
       expect(upodPagegAgB).not.toBeNull();
 
-      expect(upodPagegAB.grant).toBe(PageGrant.GRANT_USER_GROUP);
-      expect(upodPagegB.grant).toBe(PageGrant.GRANT_USER_GROUP);
-      expect(upodPageonlyB.grant).toBe(PageGrant.GRANT_OWNER);
-      expect(upodPagegAgB.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPagegAB?.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPagegB?.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPageonlyB?.grant).toBe(PageGrant.GRANT_OWNER);
+      expect(upodPagegAgB?.grant).toBe(PageGrant.GRANT_USER_GROUP);
 
       // Update
       const options = {
@@ -1903,20 +1981,20 @@ describe('Page', () => {
       const newGrant = PageGrant.GRANT_PUBLIC;
       expect(updatedPage.grant).toBe(newGrant);
       // Not changed
-      expect(upodPagegBUpdated.grant).toBe(PageGrant.GRANT_USER_GROUP);
-      expect(upodPagegBUpdated.grantedGroups).toStrictEqual(
-        upodPagegB.grantedGroups,
+      expect(upodPagegBUpdated?.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPagegBUpdated?.grantedGroups).toStrictEqual(
+        upodPagegB?.grantedGroups,
       );
-      expect(upodPageonlyBUpdated.grant).toBe(PageGrant.GRANT_OWNER);
-      expect(upodPageonlyBUpdated.grantedUsers).toStrictEqual(
-        upodPageonlyB.grantedUsers,
+      expect(upodPageonlyBUpdated?.grant).toBe(PageGrant.GRANT_OWNER);
+      expect(upodPageonlyBUpdated?.grantedUsers).toStrictEqual(
+        upodPageonlyB?.grantedUsers,
       );
-      expect(upodPagegAgBUpdated.grant).toBe(PageGrant.GRANT_USER_GROUP);
-      expect(upodPagegAgBUpdated.grantedGroups).toStrictEqual(
-        upodPagegAgB.grantedGroups,
+      expect(upodPagegAgBUpdated?.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPagegAgBUpdated?.grantedGroups).toStrictEqual(
+        upodPagegAgB?.grantedGroups,
       );
     });
-    test('(case 2) it should update all granted descendant pages when all descendant pages are granted to the operator', async () => {
+    it('(case 2) it should update all granted descendant pages when all descendant pages are granted to the operator', async () => {
       const upodPagePublic = await Page.findOne({ path: '/public_upod_2' });
       const upodPagegA = await Page.findOne({
         path: '/public_upod_2/gA_upod_2',
@@ -1933,10 +2011,10 @@ describe('Page', () => {
       expect(upodPagegAIsolated).not.toBeNull();
       expect(upodPageonlyA).not.toBeNull();
 
-      expect(upodPagePublic.grant).toBe(PageGrant.GRANT_PUBLIC);
-      expect(upodPagegA.grant).toBe(PageGrant.GRANT_USER_GROUP);
-      expect(upodPagegAIsolated.grant).toBe(PageGrant.GRANT_USER_GROUP);
-      expect(upodPageonlyA.grant).toBe(PageGrant.GRANT_OWNER);
+      expect(upodPagePublic?.grant).toBe(PageGrant.GRANT_PUBLIC);
+      expect(upodPagegA?.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPagegAIsolated?.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPageonlyA?.grant).toBe(PageGrant.GRANT_OWNER);
 
       // Update
       const options = {
@@ -1966,16 +2044,16 @@ describe('Page', () => {
       const newGrantedUsers = [upodUserA._id];
       expect(updatedPage.grant).toBe(newGrant);
       expect(updatedPage.grantedUsers).toStrictEqual(newGrantedUsers);
-      expect(upodPagegAUpdated.grant).toBe(newGrant);
-      expect(upodPagegAUpdated.grantedUsers).toStrictEqual(newGrantedUsers);
-      expect(upodPagegAIsolatedUpdated.grant).toBe(newGrant);
-      expect(upodPagegAIsolatedUpdated.grantedUsers).toStrictEqual(
+      expect(upodPagegAUpdated?.grant).toBe(newGrant);
+      expect(upodPagegAUpdated?.grantedUsers).toStrictEqual(newGrantedUsers);
+      expect(upodPagegAIsolatedUpdated?.grant).toBe(newGrant);
+      expect(upodPagegAIsolatedUpdated?.grantedUsers).toStrictEqual(
         newGrantedUsers,
       );
-      expect(upodPageonlyAUpdated.grant).toBe(newGrant);
-      expect(upodPageonlyAUpdated.grantedUsers).toStrictEqual(newGrantedUsers);
+      expect(upodPageonlyAUpdated?.grant).toBe(newGrant);
+      expect(upodPageonlyAUpdated?.grantedUsers).toStrictEqual(newGrantedUsers);
     });
-    test(`(case 3) it should update all granted descendant pages when update grant is GRANT_USER_GROUP
+    it(`(case 3) it should update all granted descendant pages when update grant is GRANT_USER_GROUP
     , all user groups of descendants are the children or itself of the update user group
     , and all users of descendants belong to the update user group`, async () => {
       const upodPagePublic = await Page.findOne({ path: '/public_upod_3' });
@@ -1998,11 +2076,11 @@ describe('Page', () => {
       expect(upodPagegB).not.toBeNull();
       expect(upodPageonlyB).not.toBeNull();
 
-      expect(upodPagePublic.grant).toBe(PageGrant.GRANT_PUBLIC);
-      expect(upodPagegAB.grant).toBe(PageGrant.GRANT_USER_GROUP);
-      expect(upodPagegAgB.grant).toBe(PageGrant.GRANT_USER_GROUP);
-      expect(upodPagegB.grant).toBe(PageGrant.GRANT_USER_GROUP);
-      expect(upodPageonlyB.grant).toBe(PageGrant.GRANT_OWNER);
+      expect(upodPagePublic?.grant).toBe(PageGrant.GRANT_PUBLIC);
+      expect(upodPagegAB?.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPagegAgB?.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPagegB?.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPageonlyB?.grant).toBe(PageGrant.GRANT_OWNER);
 
       // Update
       const options = {
@@ -2047,14 +2125,16 @@ describe('Page', () => {
       expect(normalizeGrantedGroups(updatedPage.grantedGroups)).toStrictEqual(
         newGrantedGroups,
       );
-      expect(upodPagegABUpdated.grant).toBe(newGrant);
+      expect(upodPagegABUpdated?.grant).toBe(newGrant);
       expect(
-        normalizeGrantedGroups(upodPagegABUpdated.grantedGroups),
+        normalizeGrantedGroups(upodPagegABUpdated?.grantedGroups),
       ).toStrictEqual(newGrantedGroups);
-      expect(upodPagegAgBUpdated.grant).toBe(newGrant);
+      expect(upodPagegAgBUpdated?.grant).toBe(newGrant);
       // For multi group granted pages, the grant update will only add/remove groups that the user belongs to,
       // and groups that the user doesn't belong to will stay as it was before the update.
-      expect(normalizeGrantedGroups(upodPagegAgBUpdated.grantedGroups)).toEqual(
+      expect(
+        normalizeGrantedGroups(upodPagegAgBUpdated?.grantedGroups),
+      ).toEqual(
         expect.arrayContaining([
           ...newGrantedGroups,
           { item: upodUserGroupIdB, type: GroupType.userGroup },
@@ -2062,20 +2142,20 @@ describe('Page', () => {
         ]),
       );
       expect(
-        normalizeGrantedGroups(upodPagegAgBUpdated.grantedGroups).length,
+        normalizeGrantedGroups(upodPagegAgBUpdated?.grantedGroups)?.length,
       ).toBe(4);
 
       // Not changed
-      expect(upodPagegBUpdated.grant).toBe(PageGrant.GRANT_USER_GROUP);
-      expect(upodPagegBUpdated.grantedGroups).toStrictEqual(
-        upodPagegB.grantedGroups,
+      expect(upodPagegBUpdated?.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPagegBUpdated?.grantedGroups).toStrictEqual(
+        upodPagegB?.grantedGroups,
       );
-      expect(upodPageonlyBUpdated.grant).toBe(PageGrant.GRANT_OWNER);
-      expect(upodPageonlyBUpdated.grantedUsers).toStrictEqual(
-        upodPageonlyB.grantedUsers,
+      expect(upodPageonlyBUpdated?.grant).toBe(PageGrant.GRANT_OWNER);
+      expect(upodPageonlyBUpdated?.grantedUsers).toStrictEqual(
+        upodPageonlyB?.grantedUsers,
       );
     });
-    test(`(case 4) it should throw when some of descendants is not granted
+    it(`(case 4) it should throw when some of descendants is not granted
     , update grant is GRANT_USER_GROUP
     , and some of user groups of descendants are not children or itself of the update user group`, async () => {
       const upodPagePublic = await Page.findOne({ path: '/public_upod_4' });
@@ -2090,9 +2170,9 @@ describe('Page', () => {
       expect(upodPagegA).not.toBeNull();
       expect(upodPagegC).not.toBeNull();
 
-      expect(upodPagePublic.grant).toBe(PageGrant.GRANT_PUBLIC);
-      expect(upodPagegA.grant).toBe(PageGrant.GRANT_USER_GROUP);
-      expect(upodPagegC.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPagePublic?.grant).toBe(PageGrant.GRANT_PUBLIC);
+      expect(upodPagegA?.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPagegC?.grant).toBe(PageGrant.GRANT_USER_GROUP);
 
       // Update
       const options = {
@@ -2116,7 +2196,7 @@ describe('Page', () => {
 
       await expect(updatedPagePromise).rejects.toThrowError();
     });
-    test(`(case 5) it should throw when some of descendants is not granted
+    it(`(case 5) it should throw when some of descendants is not granted
     , update grant is GRANT_USER_GROUP
     , and some of users of descendants does NOT belong to the update user group`, async () => {
       const upodPagePublic = await Page.findOne({ path: '/public_upod_5' });
@@ -2131,9 +2211,9 @@ describe('Page', () => {
       expect(upodPagegA).not.toBeNull();
       expect(upodPageonlyC).not.toBeNull();
 
-      expect(upodPagePublic.grant).toBe(PageGrant.GRANT_PUBLIC);
-      expect(upodPagegA.grant).toBe(PageGrant.GRANT_USER_GROUP);
-      expect(upodPageonlyC.grant).toBe(PageGrant.GRANT_OWNER);
+      expect(upodPagePublic?.grant).toBe(PageGrant.GRANT_PUBLIC);
+      expect(upodPagegA?.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPageonlyC?.grant).toBe(PageGrant.GRANT_OWNER);
 
       // Update
       const options = {
@@ -2157,7 +2237,7 @@ describe('Page', () => {
 
       await expect(updatedPagePromise).rejects.toThrowError();
     });
-    test('(case 6) it should throw when some of descendants is not granted and update grant is GRANT_OWNER', async () => {
+    it('(case 6) it should throw when some of descendants is not granted and update grant is GRANT_OWNER', async () => {
       const upodPagePublic = await Page.findOne({ path: '/public_upod_6' });
       const upodPageonlyC = await Page.findOne({
         path: '/public_upod_6/onlyC_upod_6',
@@ -2166,8 +2246,8 @@ describe('Page', () => {
       expect(upodPagePublic).not.toBeNull();
       expect(upodPageonlyC).not.toBeNull();
 
-      expect(upodPagePublic.grant).toBe(PageGrant.GRANT_PUBLIC);
-      expect(upodPageonlyC.grant).toBe(PageGrant.GRANT_OWNER);
+      expect(upodPagePublic?.grant).toBe(PageGrant.GRANT_PUBLIC);
+      expect(upodPageonlyC?.grant).toBe(PageGrant.GRANT_OWNER);
 
       // Update
       const options = {

+ 4 - 4
apps/app/src/server/routes/apiv3/activity.ts

@@ -7,6 +7,8 @@ import { query } from 'express-validator';
 
 import type { IActivity, ISearchFilter } from '~/interfaces/activity';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import adminRequiredFactory from '~/server/middlewares/admin-required';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import Activity from '~/server/models/activity';
 import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
@@ -173,10 +175,8 @@ const validator = {
  */
 
 module.exports = (crowi: Crowi): Router => {
-  const adminRequired = require('../../middlewares/admin-required')(crowi);
-  const loginRequiredStrictly = require('../../middlewares/login-required')(
-    crowi,
-  );
+  const adminRequired = adminRequiredFactory(crowi);
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
   const router = express.Router();
 

+ 4 - 4
apps/app/src/server/routes/apiv3/admin-home.ts

@@ -3,6 +3,8 @@ import { SCOPE } from '@growi/core/dist/interfaces';
 import type { IResAdminHome } from '~/interfaces/res/admin/admin-home';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import adminRequiredFactory from '~/server/middlewares/admin-required';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import { configManager } from '~/server/service/config-manager';
 import { getGrowiVersion } from '~/utils/growi-version';
 
@@ -62,10 +64,8 @@ const router = express.Router();
  *            description: installed plugins
  */
 module.exports = (crowi: Crowi) => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(
-    crowi,
-  );
-  const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
+  const adminRequired = adminRequiredFactory(crowi);
 
   /**
    * @swagger

+ 41 - 33
apps/app/src/server/routes/apiv3/app-settings/file-upload-setting.integ.ts

@@ -1,7 +1,6 @@
 import { toNonBlankString } from '@growi/core/dist/interfaces';
-import type { Request } from 'express';
+import type { NextFunction, Request, Response } from 'express';
 import express from 'express';
-import mockRequire from 'mock-require';
 import request from 'supertest';
 import { mock } from 'vitest-mock-extended';
 
@@ -10,35 +9,42 @@ import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-respo
 import { configManager } from '~/server/service/config-manager';
 import type { S2sMessagingService } from '~/server/service/s2s-messaging/base';
 
-// Mock middlewares using mock-require BEFORE importing the router
 const mockActivityId = '507f1f77bcf86cd799439011';
 
-// Mock the dependencies that login-required.js and admin-required.js need
-mockRequire.stopAll();
-
-mockRequire('~/server/middlewares/access-token-parser', {
-  accessTokenParser:
-    () => (_req: Request, _res: ApiV3Response, next: () => void) =>
-      next(),
-});
-
-mockRequire(
-  '../../../middlewares/login-required',
-  () => (_req: Request, _res: ApiV3Response, next: () => void) => next(),
-);
-mockRequire(
-  '../../../middlewares/admin-required',
-  () => (_req: Request, _res: ApiV3Response, next: () => void) => next(),
-);
-
-mockRequire('../../../middlewares/add-activity', {
-  generateAddActivityMiddleware:
-    () => (_req: Request, res: ApiV3Response, next: () => void) => {
-      res.locals = res.locals || {};
-      res.locals.activity = { _id: mockActivityId };
-      next();
-    },
-});
+// Passthrough middleware for testing - skips authentication
+const passthroughMiddleware = (
+  _req: Request,
+  _res: Response,
+  next: NextFunction,
+) => next();
+
+// Add activity middleware mock - sets activity in res.locals
+const mockAddActivityMiddleware = (
+  _req: Request,
+  res: Response,
+  next: NextFunction,
+) => {
+  res.locals = res.locals || {};
+  res.locals.activity = { _id: mockActivityId };
+  next();
+};
+
+// Mock middlewares using vi.mock (hoisted to top)
+vi.mock('~/server/middlewares/access-token-parser', () => ({
+  accessTokenParser: () => passthroughMiddleware,
+}));
+
+vi.mock('~/server/middlewares/login-required', () => ({
+  default: () => passthroughMiddleware,
+}));
+
+vi.mock('~/server/middlewares/admin-required', () => ({
+  default: () => passthroughMiddleware,
+}));
+
+vi.mock('../../../middlewares/add-activity', () => ({
+  generateAddActivityMiddleware: () => mockAddActivityMiddleware,
+}));
 
 describe('file-upload-setting route', () => {
   let app: express.Application;
@@ -53,8 +59,10 @@ describe('file-upload-setting route', () => {
     // Mock crowi instance
     crowiMock = mock<Crowi>({
       events: {
-        // Mock a generic event emitter
-      } as any,
+        activity: {
+          emit: vi.fn(),
+        },
+      },
       setUpFileUpload: vi.fn().mockResolvedValue(undefined),
       fileUploaderSwitchService: {
         publishUpdatedMessage: vi.fn(),
@@ -82,8 +90,8 @@ describe('file-upload-setting route', () => {
     app.use('/', fileUploadSettingRouter);
   });
 
-  afterAll(() => {
-    mockRequire.stopAll();
+  afterEach(() => {
+    vi.clearAllMocks();
   });
 
   it('should update file upload type to local', async () => {

+ 4 - 4
apps/app/src/server/routes/apiv3/app-settings/file-upload-setting.ts

@@ -10,6 +10,8 @@ import { body } from 'express-validator';
 import { SupportedAction } from '~/interfaces/activity';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import adminRequiredFactory from '~/server/middlewares/admin-required';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import { configManager } from '~/server/service/config-manager';
 import { getTranslation } from '~/server/service/i18next';
 import loggerFactory from '~/utils/logger';
@@ -135,10 +137,8 @@ const validator = {
  *                      $ref: '#/components/schemas/FileUploadSettingParams'
  */
 module.exports = (crowi: Crowi) => {
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(
-    crowi,
-  );
-  const adminRequired = require('../../../middlewares/admin-required')(crowi);
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
+  const adminRequired = adminRequiredFactory(crowi);
   const addActivity = generateAddActivityMiddleware();
 
   const activityEvent = crowi.events.activity;

+ 4 - 4
apps/app/src/server/routes/apiv3/app-settings/index.ts

@@ -10,6 +10,8 @@ import { SupportedAction } from '~/interfaces/activity';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import adminRequiredFactory from '~/server/middlewares/admin-required';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import { configManager } from '~/server/service/config-manager';
 import { getTranslation } from '~/server/service/i18next';
 import loggerFactory from '~/utils/logger';
@@ -316,10 +318,8 @@ const router = express.Router();
  *            description: is enable internal stream system for azure file request
  */
 module.exports = (crowi: Crowi) => {
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(
-    crowi,
-  );
-  const adminRequired = require('../../../middlewares/admin-required')(crowi);
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
+  const adminRequired = adminRequiredFactory(crowi);
   const addActivity = generateAddActivityMiddleware();
 
   const activityEvent = crowi.events.activity;

+ 3 - 7
apps/app/src/server/routes/apiv3/attachment.js

@@ -8,6 +8,7 @@ import autoReap from 'multer-autoreap';
 import { SupportedAction } from '~/interfaces/activity';
 import { AttachmentType } from '~/server/interfaces/attachment';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import { Attachment } from '~/server/models/attachment';
 import {
   serializePageSecurely,
@@ -134,13 +135,8 @@ const { query, param, body } = require('express-validator');
  */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
-  const loginRequired = require('../../middlewares/login-required')(
-    crowi,
-    true,
-  );
-  const loginRequiredStrictly = require('../../middlewares/login-required')(
-    crowi,
-  );
+  const loginRequired = loginRequiredFactory(crowi, true);
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
   const { Page, User } = crowi.models;
   const { attachmentService } = crowi;
   const uploads = multer({ dest: `${crowi.tmpDir}uploads` });

+ 2 - 3
apps/app/src/server/routes/apiv3/bookmark-folder.ts

@@ -7,6 +7,7 @@ import type { BookmarkFolderItems } from '~/interfaces/bookmark-info';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import { InvalidParentBookmarkFolderError } from '~/server/models/errors';
 import { serializeBookmarkSecurely } from '~/server/models/serializers/bookmark-serializer';
 import loggerFactory from '~/utils/logger';
@@ -134,9 +135,7 @@ const validator = {
 };
 
 module.exports = (crowi: Crowi) => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(
-    crowi,
-  );
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
   /**
    * @swagger

+ 3 - 7
apps/app/src/server/routes/apiv3/bookmarks.ts

@@ -8,6 +8,7 @@ import type { IBookmarkInfo } from '~/interfaces/bookmark-info';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { BookmarkDocument, BookmarkModel } from '~/server/models/bookmark';
 import type { PageDocument, PageModel } from '~/server/models/page';
 import { serializeBookmarkSecurely } from '~/server/models/serializers/bookmark-serializer';
@@ -89,13 +90,8 @@ const router = express.Router();
  *              $ref: '#/components/schemas/User'
  */
 module.exports = (crowi: Crowi) => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(
-    crowi,
-  );
-  const loginRequired = require('../../middlewares/login-required')(
-    crowi,
-    true,
-  );
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
+  const loginRequired = loginRequiredFactory(crowi, true);
   const addActivity = generateAddActivityMiddleware();
 
   const activityEvent = crowi.events.activity;

+ 4 - 4
apps/app/src/server/routes/apiv3/customize-setting.js

@@ -9,6 +9,8 @@ import { GrowiPlugin } from '~/features/growi-plugin/server/models';
 import { SupportedAction } from '~/interfaces/activity';
 import { AttachmentType } from '~/server/interfaces/attachment';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import adminRequiredFactory from '~/server/middlewares/admin-required';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import { Attachment } from '~/server/models/attachment';
 import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
@@ -188,10 +190,8 @@ const router = express.Router();
  */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(
-    crowi,
-  );
-  const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
+  const adminRequired = adminRequiredFactory(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
   const activityEvent = crowi.events.activity;

+ 5 - 3
apps/app/src/server/routes/apiv3/export.js

@@ -1,12 +1,14 @@
+import fs from 'node:fs';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import express from 'express';
 import { body, param } from 'express-validator';
-import fs from 'fs';
 import mongoose from 'mongoose';
 import sanitize from 'sanitize-filename';
 
 import { SupportedAction } from '~/interfaces/activity';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import adminRequiredFactory from '~/server/middlewares/admin-required';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import { exportService } from '~/server/service/export';
 import loggerFactory from '~/utils/logger';
 
@@ -122,8 +124,8 @@ const router = express.Router();
  */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
-  const loginRequired = require('../../middlewares/login-required')(crowi);
-  const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const loginRequired = loginRequiredFactory(crowi);
+  const adminRequired = adminRequiredFactory(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
   const { socketIoService } = crowi;

+ 4 - 4
apps/app/src/server/routes/apiv3/g2g-transfer.ts

@@ -9,6 +9,8 @@ import path from 'pathe';
 
 import type { GrowiArchiveImportOption } from '~/models/admin/growi-archive-import-option';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import adminRequiredFactory from '~/server/middlewares/admin-required';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import { isG2GTransferError } from '~/server/models/vo/g2g-transfer-error';
 import { configManager } from '~/server/service/config-manager';
 import { exportService } from '~/server/service/export';
@@ -131,10 +133,8 @@ module.exports = (crowi: Crowi): Router => {
 
   const isInstalled = configManager.getConfig('app:installed');
 
-  const adminRequired = require('../../middlewares/admin-required')(crowi);
-  const loginRequiredStrictly = require('../../middlewares/login-required')(
-    crowi,
-  );
+  const adminRequired = adminRequiredFactory(crowi);
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
   // Middleware
   const adminRequiredIfInstalled = (

+ 4 - 2
apps/app/src/server/routes/apiv3/import.ts

@@ -6,6 +6,8 @@ import { SupportedAction } from '~/interfaces/activity';
 import type { GrowiArchiveImportOption } from '~/models/admin/growi-archive-import-option';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import adminRequiredFactory from '~/server/middlewares/admin-required';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { ImportSettings } from '~/server/service/import';
 import { getImportService } from '~/server/service/import';
 import { generateOverwriteParams } from '~/server/service/import/overwrite-params';
@@ -130,8 +132,8 @@ export default function route(crowi: Crowi): Router {
   const { growiBridgeService, socketIoService } = crowi;
   const importService = getImportService();
 
-  const loginRequired = require('../../middlewares/login-required')(crowi);
-  const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const loginRequired = loginRequiredFactory(crowi);
+  const adminRequired = adminRequiredFactory(crowi);
   const addActivity = generateAddActivityMiddleware();
 
   const adminEvent = crowi.events.admin;

+ 2 - 3
apps/app/src/server/routes/apiv3/in-app-notification.ts

@@ -7,6 +7,7 @@ import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 
 import type { IInAppNotification } from '../../../interfaces/in-app-notification';
 import type { ApiV3Response } from './interfaces/apiv3-response';
@@ -86,9 +87,7 @@ const router = express.Router();
  *             $ref: '#/components/schemas/User'
  */
 module.exports = (crowi: Crowi) => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(
-    crowi,
-  );
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
   const addActivity = generateAddActivityMiddleware();
 
   const inAppNotificationService = crowi.inAppNotificationService;

+ 4 - 4
apps/app/src/server/routes/apiv3/markdown-setting.js

@@ -3,6 +3,8 @@ import { ErrorV3 } from '@growi/core/dist/models';
 
 import { SupportedAction } from '~/interfaces/activity';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import adminRequiredFactory from '~/server/middlewares/admin-required';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 
@@ -122,10 +124,8 @@ const validator = {
 
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(
-    crowi,
-  );
-  const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
+  const adminRequired = adminRequiredFactory(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
   const activityEvent = crowi.events.activity;

+ 4 - 4
apps/app/src/server/routes/apiv3/mongo.js

@@ -1,6 +1,8 @@
 import { SCOPE } from '@growi/core/dist/interfaces';
 
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import adminRequiredFactory from '~/server/middlewares/admin-required';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import loggerFactory from '~/utils/logger';
 
 const _logger = loggerFactory('growi:routes:apiv3:mongo');
@@ -12,10 +14,8 @@ const router = express.Router();
 
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(
-    crowi,
-  );
-  const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
+  const adminRequired = adminRequiredFactory(crowi);
 
   /**
    * @swagger

+ 13 - 11
apps/app/src/server/routes/apiv3/notification-setting.js

@@ -4,6 +4,8 @@ import express from 'express';
 
 import { SupportedAction } from '~/interfaces/activity';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import adminRequiredFactory from '~/server/middlewares/admin-required';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import { GlobalNotificationSettingType } from '~/server/models/GlobalNotificationSetting';
 import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
@@ -182,8 +184,8 @@ const validator = {
  */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
-  const Strictly = require('../../middlewares/login-required')(crowi);
-  const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
+  const adminRequired = adminRequiredFactory(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
   const activityEvent = crowi.events.activity;
@@ -219,7 +221,7 @@ module.exports = (crowi) => {
   router.get(
     '/',
     accessTokenParser([SCOPE.READ.ADMIN.EXTERNAL_NOTIFICATION]),
-    Strictly,
+    loginRequiredStrictly,
     adminRequired,
     async (req, res) => {
       const notificationParams = {
@@ -283,7 +285,7 @@ module.exports = (crowi) => {
   router.post(
     '/user-notification',
     accessTokenParser([SCOPE.WRITE.ADMIN.EXTERNAL_NOTIFICATION]),
-    Strictly,
+    loginRequiredStrictly,
     adminRequired,
     addActivity,
     validator.userNotification,
@@ -343,7 +345,7 @@ module.exports = (crowi) => {
   router.delete(
     '/user-notification/:id',
     accessTokenParser([SCOPE.WRITE.ADMIN.EXTERNAL_NOTIFICATION]),
-    Strictly,
+    loginRequiredStrictly,
     adminRequired,
     addActivity,
     async (req, res) => {
@@ -400,7 +402,7 @@ module.exports = (crowi) => {
   router.get(
     '/global-notification/:id',
     accessTokenParser([SCOPE.READ.ADMIN.EXTERNAL_NOTIFICATION]),
-    Strictly,
+    loginRequiredStrictly,
     adminRequired,
     validator.globalNotification,
     async (req, res) => {
@@ -453,7 +455,7 @@ module.exports = (crowi) => {
   router.post(
     '/global-notification',
     accessTokenParser([SCOPE.WRITE.ADMIN.EXTERNAL_NOTIFICATION]),
-    Strictly,
+    loginRequiredStrictly,
     adminRequired,
     addActivity,
     validator.globalNotification,
@@ -528,7 +530,7 @@ module.exports = (crowi) => {
   router.put(
     '/global-notification/:id',
     accessTokenParser([SCOPE.WRITE.ADMIN.EXTERNAL_NOTIFICATION]),
-    Strictly,
+    loginRequiredStrictly,
     adminRequired,
     addActivity,
     validator.globalNotification,
@@ -613,7 +615,7 @@ module.exports = (crowi) => {
   router.put(
     '/notify-for-page-grant',
     accessTokenParser([SCOPE.WRITE.ADMIN.EXTERNAL_NOTIFICATION]),
-    Strictly,
+    loginRequiredStrictly,
     adminRequired,
     addActivity,
     validator.notifyForPageGrant,
@@ -697,7 +699,7 @@ module.exports = (crowi) => {
   router.put(
     '/global-notification/:id/enabled',
     accessTokenParser([SCOPE.WRITE.ADMIN.EXTERNAL_NOTIFICATION]),
-    Strictly,
+    loginRequiredStrictly,
     adminRequired,
     addActivity,
     async (req, res) => {
@@ -757,7 +759,7 @@ module.exports = (crowi) => {
   router.delete(
     '/global-notification/:id',
     accessTokenParser([SCOPE.WRITE.ADMIN.EXTERNAL_NOTIFICATION]),
-    Strictly,
+    loginRequiredStrictly,
     adminRequired,
     addActivity,
     async (req, res) => {

+ 2 - 4
apps/app/src/server/routes/apiv3/page-listing.ts

@@ -10,6 +10,7 @@ import mongoose from 'mongoose';
 
 import type { IPageForTreeItem } from '~/interfaces/page';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import { configManager } from '~/server/service/config-manager';
 import type { IPageGrantService } from '~/server/service/page-grant';
 import { pageListingService } from '~/server/service/page-listing';
@@ -60,10 +61,7 @@ const validator = {
  * Routes
  */
 const routerFactory = (crowi: Crowi): Router => {
-  const loginRequired = require('../../middlewares/login-required')(
-    crowi,
-    true,
-  );
+  const loginRequired = loginRequiredFactory(crowi, true);
 
   const router = express.Router();
 

+ 8 - 13
apps/app/src/server/routes/apiv3/page/check-page-existence.ts

@@ -3,13 +3,13 @@ import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { normalizePath } from '@growi/core/dist/utils/path-utils';
 import type { Request, RequestHandler } from 'express';
-import type { ValidationChain } from 'express-validator';
 import { query } from 'express-validator';
 import mongoose from 'mongoose';
 
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { PageModel } from '~/server/models/page';
 import loggerFactory from '~/utils/logger';
 
@@ -22,30 +22,25 @@ type ReqQuery = {
 };
 
 interface Req extends Request<ReqQuery, ApiV3Response> {
-  user: IUserHasId;
+  user?: IUserHasId;
 }
 
-type CreatePageHandlersFactory = (crowi: Crowi) => RequestHandler[];
-
-export const checkPageExistenceHandlersFactory: CreatePageHandlersFactory = (
-  crowi,
-) => {
+export const checkPageExistenceHandlersFactory = (
+  crowi: Crowi,
+): RequestHandler[] => {
   const Page = mongoose.model<IPage, PageModel>('Page');
 
-  const loginRequired = require('../../../middlewares/login-required')(
-    crowi,
-    true,
-  );
+  const loginRequired = loginRequiredFactory(crowi, true);
 
   // define validators for req.body
-  const validator: ValidationChain[] = [
+  const validator = [
     query('path').isString().withMessage('The param "path" must be specified'),
   ];
 
   return [
     accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }),
     loginRequired,
-    validator,
+    ...validator,
     apiV3FormValidator,
     async (req: Req, res: ApiV3Response) => {
       const { path } = req.query;

+ 5 - 8
apps/app/src/server/routes/apiv3/page/create-page.ts

@@ -11,7 +11,7 @@ import {
   attachTitleHeader,
   normalizePath,
 } from '@growi/core/dist/utils/path-utils';
-import type { Request, RequestHandler } from 'express';
+import type { Request } from 'express';
 import type { ValidationChain } from 'express-validator';
 import { body } from 'express-validator';
 import type { HydratedDocument } from 'mongoose';
@@ -25,6 +25,7 @@ import type { IOptionsForCreate } from '~/interfaces/page';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import { GlobalNotificationSettingEvent } from '~/server/models/GlobalNotificationSetting';
 import type { PageDocument, PageModel } from '~/server/models/page';
 import PageTagRelation from '~/server/models/page-tag-relation';
@@ -112,17 +113,13 @@ interface CreatePageRequest extends Request<undefined, ApiV3Response, ReqBody> {
   user: IUserHasId;
 }
 
-type CreatePageHandlersFactory = (crowi: Crowi) => RequestHandler[];
-
-export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
+export const createPageHandlersFactory = (crowi: Crowi) => {
   const Page = mongoose.model<IPage, PageModel>('Page');
   const User = mongoose.model<IUser, { isExistUserByUserPagePath: any }>(
     'User',
   );
 
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(
-    crowi,
-  );
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
   // define validators for req.body
   const validator: ValidationChain[] = [
@@ -291,7 +288,7 @@ export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
     loginRequiredStrictly,
     excludeReadOnlyUser,
     addActivity,
-    validator,
+    ...validator,
     apiV3FormValidator,
     async (req: CreatePageRequest, res: ApiV3Response) => {
       const { body: bodyByParam, pageTags: tagsByParam } = req.body;

+ 76 - 68
apps/app/src/server/routes/apiv3/page/get-page-paths-with-descendant-count.ts

@@ -1,3 +1,4 @@
+import assert from 'node:assert';
 import type { IPage, IUserHasId } from '@growi/core';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import type { Request, RequestHandler } from 'express';
@@ -7,6 +8,7 @@ import mongoose from 'mongoose';
 
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { PageModel } from '~/server/models/page';
 import loggerFactory from '~/utils/logger';
 
@@ -15,84 +17,90 @@ import type { ApiV3Response } from '../interfaces/apiv3-response';
 
 const logger = loggerFactory('growi:routes:apiv3:page:get-pages-by-page-paths');
 
-type GetPagePathsWithDescendantCountFactory = (
-  crowi: Crowi,
-) => RequestHandler[];
-
 type ReqQuery = {
-  paths: string[];
+  paths?: string[];
   userGroups?: string[];
   isIncludeEmpty?: boolean;
   includeAnyoneWithTheLink?: boolean;
 };
 
-interface Req extends Request<undefined, ApiV3Response, undefined, ReqQuery> {
-  user: IUserHasId;
+interface Req
+  extends Request<Record<string, string>, ApiV3Response, undefined, ReqQuery> {
+  user?: IUserHasId;
 }
-export const getPagePathsWithDescendantCountFactory: GetPagePathsWithDescendantCountFactory =
-  (crowi) => {
-    const Page = mongoose.model<IPage, PageModel>('Page');
-    const loginRequiredStrictly =
-      require('../../../middlewares/login-required')(crowi);
+export const getPagePathsWithDescendantCountFactory = (
+  crowi: Crowi,
+): RequestHandler[] => {
+  const Page = mongoose.model<IPage, PageModel>('Page');
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
-    const validator: ValidationChain[] = [
-      query('paths').isArray().withMessage('paths must be an array of strings'),
-      query('paths').custom((paths: string[]) => {
-        if (paths.length > 300) {
-          throw new Error(
-            'paths must be an array of strings with a maximum length of 300',
-          );
-        }
-        return true;
-      }),
-      query('paths.*') // each item of paths
-        .isString()
-        .withMessage('paths must be an array of strings'),
+  const validator: ValidationChain[] = [
+    query('paths').isArray().withMessage('paths must be an array of strings'),
+    query('paths').custom((paths: string[]) => {
+      if (paths.length > 300) {
+        throw new Error(
+          'paths must be an array of strings with a maximum length of 300',
+        );
+      }
+      return true;
+    }),
+    query('paths.*') // each item of paths
+      .isString()
+      .withMessage('paths must be an array of strings'),
 
-      query('userGroups')
-        .optional()
-        .isArray()
-        .withMessage('userGroups must be an array of strings'),
-      query('userGroups.*') // each item of userGroups
-        .isMongoId()
-        .withMessage('userGroups must be an array of strings'),
+    query('userGroups')
+      .optional()
+      .isArray()
+      .withMessage('userGroups must be an array of strings'),
+    query('userGroups.*') // each item of userGroups
+      .isMongoId()
+      .withMessage('userGroups must be an array of strings'),
 
-      query('isIncludeEmpty')
-        .optional()
-        .isBoolean()
-        .withMessage('isIncludeEmpty must be a boolean'),
-      query('isIncludeEmpty').toBoolean(),
+    query('isIncludeEmpty')
+      .optional()
+      .isBoolean()
+      .withMessage('isIncludeEmpty must be a boolean'),
+    query('isIncludeEmpty').toBoolean(),
 
-      query('includeAnyoneWithTheLink')
-        .optional()
-        .isBoolean()
-        .withMessage('includeAnyoneWithTheLink must be a boolean'),
-      query('includeAnyoneWithTheLink').toBoolean(),
-    ];
+    query('includeAnyoneWithTheLink')
+      .optional()
+      .isBoolean()
+      .withMessage('includeAnyoneWithTheLink must be a boolean'),
+    query('includeAnyoneWithTheLink').toBoolean(),
+  ];
 
-    return [
-      accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
-      loginRequiredStrictly,
-      validator,
-      apiV3FormValidator,
-      async (req: Req, res: ApiV3Response) => {
-        const { paths, userGroups, isIncludeEmpty, includeAnyoneWithTheLink } =
-          req.query;
+  return [
+    accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    ...validator,
+    apiV3FormValidator,
+    async (req: Req, res: ApiV3Response) => {
+      const { user } = req;
+      assert(
+        user != null,
+        'user is required (ensured by loginRequiredStrictly middleware)',
+      );
 
-        try {
-          const pagePathsWithDescendantCount =
-            await Page.descendantCountByPaths(
-              paths,
-              req.user,
-              userGroups,
-              isIncludeEmpty,
-              includeAnyoneWithTheLink,
-            );
-          return res.apiv3({ pagePathsWithDescendantCount });
-        } catch (err) {
-          logger.error(err);
-          return res.apiv3Err(err);
-        }
-      },
-    ];
-  };
+      const { paths, userGroups, isIncludeEmpty, includeAnyoneWithTheLink } =
+        req.query;
+      assert(
+        paths != null,
+        'paths is required (validated by express-validator)',
+      );
+
+      try {
+        const pagePathsWithDescendantCount = await Page.descendantCountByPaths(
+          paths,
+          user,
+          userGroups,
+          isIncludeEmpty,
+          includeAnyoneWithTheLink,
+        );
+        return res.apiv3({ pagePathsWithDescendantCount });
+      } catch (err) {
+        logger.error(err);
+        return res.apiv3Err(err);
+      }
+    },
+  ];
+};

+ 6 - 10
apps/app/src/server/routes/apiv3/page/get-yjs-data.ts

@@ -2,12 +2,12 @@ import type { IPage, IUserHasId } from '@growi/core';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
-import type { ValidationChain } from 'express-validator';
 import { param } from 'express-validator';
 import mongoose from 'mongoose';
 
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { PageModel } from '~/server/models/page';
 import loggerFactory from '~/utils/logger';
 
@@ -16,22 +16,18 @@ import type { ApiV3Response } from '../interfaces/apiv3-response';
 
 const logger = loggerFactory('growi:routes:apiv3:page:get-yjs-data');
 
-type GetYjsDataHandlerFactory = (crowi: Crowi) => RequestHandler[];
-
 type ReqParams = {
   pageId: string;
 };
 interface Req extends Request<ReqParams, ApiV3Response> {
-  user: IUserHasId;
+  user?: IUserHasId;
 }
-export const getYjsDataHandlerFactory: GetYjsDataHandlerFactory = (crowi) => {
+export const getYjsDataHandlerFactory = (crowi: Crowi): RequestHandler[] => {
   const Page = mongoose.model<IPage, PageModel>('Page');
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(
-    crowi,
-  );
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
   // define validators for req.params
-  const validator: ValidationChain[] = [
+  const validator = [
     param('pageId')
       .isMongoId()
       .withMessage('The param "pageId" must be specified'),
@@ -40,7 +36,7 @@ export const getYjsDataHandlerFactory: GetYjsDataHandlerFactory = (crowi) => {
   return [
     accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
     loginRequiredStrictly,
-    validator,
+    ...validator,
     apiV3FormValidator,
     async (req: Req, res: ApiV3Response) => {
       const { pageId } = req.params;

+ 3 - 7
apps/app/src/server/routes/apiv3/page/index.ts

@@ -32,6 +32,7 @@ import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { excludeReadOnlyUser } from '~/server/middlewares/exclude-read-only-user';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import { GlobalNotificationSettingEvent } from '~/server/models/GlobalNotificationSetting';
 import type { PageDocument, PageModel } from '~/server/models/page';
 import { Revision } from '~/server/models/revision';
@@ -81,13 +82,8 @@ const router = express.Router();
  *
  */
 module.exports = (crowi: Crowi) => {
-  const loginRequired = require('../../../middlewares/login-required')(
-    crowi,
-    true,
-  );
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(
-    crowi,
-  );
+  const loginRequired = loginRequiredFactory(crowi, true);
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
   const certifySharedPage = require('../../../middlewares/certify-shared-page')(
     crowi,
   );

+ 6 - 12
apps/app/src/server/routes/apiv3/page/publish-page.ts

@@ -2,12 +2,12 @@ import type { IPage, IUserHasId } from '@growi/core';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
-import type { ValidationChain } from 'express-validator';
 import { param } from 'express-validator';
 import mongoose from 'mongoose';
 
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { PageModel } from '~/server/models/page';
 import loggerFactory from '~/utils/logger';
 
@@ -21,22 +21,16 @@ type ReqParams = {
 };
 
 interface Req extends Request<ReqParams, ApiV3Response> {
-  user: IUserHasId;
+  user?: IUserHasId;
 }
 
-type PublishPageHandlersFactory = (crowi: Crowi) => RequestHandler[];
-
-export const publishPageHandlersFactory: PublishPageHandlersFactory = (
-  crowi,
-) => {
+export const publishPageHandlersFactory = (crowi: Crowi): RequestHandler[] => {
   const Page = mongoose.model<IPage, PageModel>('Page');
 
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(
-    crowi,
-  );
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
   // define validators for req.body
-  const validator: ValidationChain[] = [
+  const validator = [
     param('pageId')
       .isMongoId()
       .withMessage('The param "pageId" must be specified'),
@@ -45,7 +39,7 @@ export const publishPageHandlersFactory: PublishPageHandlersFactory = (
   return [
     accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }),
     loginRequiredStrictly,
-    validator,
+    ...validator,
     apiV3FormValidator,
     async (req: Req, res: ApiV3Response) => {
       const { pageId } = req.params;

+ 48 - 51
apps/app/src/server/routes/apiv3/page/sync-latest-revision-body-to-yjs-draft.ts

@@ -8,6 +8,7 @@ import mongoose from 'mongoose';
 
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { PageModel } from '~/server/models/page';
 import { getYjsService } from '~/server/service/yjs';
 import loggerFactory from '~/utils/logger';
@@ -19,10 +20,6 @@ const logger = loggerFactory(
   'growi:routes:apiv3:page:sync-latest-revision-body-to-yjs-draft',
 );
 
-type SyncLatestRevisionBodyToYjsDraftHandlerFactory = (
-  crowi: Crowi,
-) => RequestHandler[];
-
 type ReqParams = {
   pageId: string;
 };
@@ -32,54 +29,54 @@ type ReqBody = {
 interface Req extends Request<ReqParams, ApiV3Response, ReqBody> {
   user: IUserHasId;
 }
-export const syncLatestRevisionBodyToYjsDraftHandlerFactory: SyncLatestRevisionBodyToYjsDraftHandlerFactory =
-  (crowi) => {
-    const Page = mongoose.model<IPage, PageModel>('Page');
-    const loginRequiredStrictly =
-      require('../../../middlewares/login-required')(crowi);
+export const syncLatestRevisionBodyToYjsDraftHandlerFactory = (
+  crowi: Crowi,
+): RequestHandler[] => {
+  const Page = mongoose.model<IPage, PageModel>('Page');
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
-    // define validators for req.params
-    const validator: ValidationChain[] = [
-      param('pageId')
-        .isMongoId()
-        .withMessage('The param "pageId" must be specified'),
-      body('editingMarkdownLength')
-        .optional()
-        .isInt()
-        .withMessage('The body "editingMarkdownLength" must be integer'),
-    ];
+  // define validators for req.params
+  const validator: ValidationChain[] = [
+    param('pageId')
+      .isMongoId()
+      .withMessage('The param "pageId" must be specified'),
+    body('editingMarkdownLength')
+      .optional()
+      .isInt()
+      .withMessage('The body "editingMarkdownLength" must be integer'),
+  ];
 
-    return [
-      accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }),
-      loginRequiredStrictly,
-      validator,
-      apiV3FormValidator,
-      async (req: Req, res: ApiV3Response) => {
-        const { pageId } = req.params;
-        const { editingMarkdownLength } = req.body;
+  return [
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    ...validator,
+    apiV3FormValidator,
+    async (req: Req, res: ApiV3Response) => {
+      const { pageId } = req.params;
+      const { editingMarkdownLength } = req.body;
 
-        // check whether accessible
-        if (!(await Page.isAccessiblePageByViewer(pageId, req.user))) {
-          return res.apiv3Err(
-            new ErrorV3(
-              'Current user is not accessible to this page.',
-              'forbidden-page',
-            ),
-            403,
-          );
-        }
+      // check whether accessible
+      if (!(await Page.isAccessiblePageByViewer(pageId, req.user))) {
+        return res.apiv3Err(
+          new ErrorV3(
+            'Current user is not accessible to this page.',
+            'forbidden-page',
+          ),
+          403,
+        );
+      }
 
-        try {
-          const yjsService = getYjsService();
-          const result = await yjsService.syncWithTheLatestRevisionForce(
-            pageId,
-            editingMarkdownLength,
-          );
-          return res.apiv3(result);
-        } catch (err) {
-          logger.error(err);
-          return res.apiv3Err(err);
-        }
-      },
-    ];
-  };
+      try {
+        const yjsService = getYjsService();
+        const result = await yjsService.syncWithTheLatestRevisionForce(
+          pageId,
+          editingMarkdownLength,
+        );
+        return res.apiv3(result);
+      } catch (err) {
+        logger.error(err);
+        return res.apiv3Err(err);
+      }
+    },
+  ];
+};

+ 8 - 12
apps/app/src/server/routes/apiv3/page/unpublish-page.ts

@@ -2,12 +2,12 @@ import type { IPage, IUserHasId } from '@growi/core';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
-import type { ValidationChain } from 'express-validator';
 import { param } from 'express-validator';
 import mongoose from 'mongoose';
 
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { PageModel } from '~/server/models/page';
 import loggerFactory from '~/utils/logger';
 
@@ -21,22 +21,18 @@ type ReqParams = {
 };
 
 interface Req extends Request<ReqParams, ApiV3Response> {
-  user: IUserHasId;
+  user?: IUserHasId;
 }
 
-type UnpublishPageHandlersFactory = (crowi: Crowi) => RequestHandler[];
-
-export const unpublishPageHandlersFactory: UnpublishPageHandlersFactory = (
-  crowi,
-) => {
+export const unpublishPageHandlersFactory = (
+  crowi: Crowi,
+): RequestHandler[] => {
   const Page = mongoose.model<IPage, PageModel>('Page');
 
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(
-    crowi,
-  );
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
   // define validators for req.body
-  const validator: ValidationChain[] = [
+  const validator = [
     param('pageId')
       .isMongoId()
       .withMessage('The param "pageId" must be specified'),
@@ -45,7 +41,7 @@ export const unpublishPageHandlersFactory: UnpublishPageHandlersFactory = (
   return [
     accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }),
     loginRequiredStrictly,
-    validator,
+    ...validator,
     apiV3FormValidator,
     async (req: Req, res: ApiV3Response) => {
       const { pageId } = req.params;

+ 6 - 8
apps/app/src/server/routes/apiv3/page/update-page.ts

@@ -23,6 +23,7 @@ import type { IOptionsForUpdate } from '~/interfaces/page';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import { GlobalNotificationSettingEvent } from '~/server/models/GlobalNotificationSetting';
 import type { PageDocument, PageModel } from '~/server/models/page';
 import {
@@ -43,19 +44,16 @@ const logger = loggerFactory('growi:routes:apiv3:page:update-page');
 
 type ReqBody = IApiv3PageUpdateParams;
 
-interface UpdatePageRequest extends Request<undefined, ApiV3Response, ReqBody> {
+interface UpdatePageRequest
+  extends Request<Record<string, string>, ApiV3Response, ReqBody> {
   user: IUserHasId;
 }
 
-type UpdatePageHandlersFactory = (crowi: Crowi) => RequestHandler[];
-
-export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
+export const updatePageHandlersFactory = (crowi: Crowi): RequestHandler[] => {
   const Page = mongoose.model<IPage, PageModel>('Page');
   const Revision = mongoose.model<IRevisionHasId>('Revision');
 
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(
-    crowi,
-  );
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
   // define validators for req.body
   const validator: ValidationChain[] = [
@@ -191,7 +189,7 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
     loginRequiredStrictly,
     excludeReadOnlyUser,
     addActivity,
-    validator,
+    ...validator,
     apiV3FormValidator,
     async (req: UpdatePageRequest, res: ApiV3Response) => {
       const { pageId, revisionId, body, origin, grant } = req.body;

+ 5 - 8
apps/app/src/server/routes/apiv3/pages/index.js

@@ -17,6 +17,8 @@ import { body, query } from 'express-validator';
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import adminRequiredFactory from '~/server/middlewares/admin-required';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import { GlobalNotificationSettingEvent } from '~/server/models/GlobalNotificationSetting';
 import PageTagRelation from '~/server/models/page-tag-relation';
 import { configManager } from '~/server/service/config-manager';
@@ -37,14 +39,9 @@ const LIMIT_FOR_MULTIPLE_PAGE_OP = 20;
 
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
-  const loginRequired = require('../../../middlewares/login-required')(
-    crowi,
-    true,
-  );
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(
-    crowi,
-  );
-  const adminRequired = require('../../../middlewares/admin-required')(crowi);
+  const loginRequired = loginRequiredFactory(crowi, true);
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
+  const adminRequired = adminRequiredFactory(crowi);
 
   const { Page, User } = crowi.models;
 

+ 34 - 35
apps/app/src/server/routes/apiv3/personal-setting/delete-access-token.ts

@@ -9,6 +9,7 @@ import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { excludeReadOnlyUser } from '~/server/middlewares/exclude-read-only-user';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import { AccessToken } from '~/server/models/access-token';
 import loggerFactory from '~/utils/logger';
 
@@ -23,14 +24,12 @@ type ReqQuery = {
 };
 
 type DeleteAccessTokenRequest = Request<
-  undefined,
+  Record<string, string>,
   ApiV3Response,
   undefined,
   ReqQuery
 >;
 
-type DeleteAccessTokenHandlersFactory = (crowi: Crowi) => RequestHandler[];
-
 const validator = [
   query('tokenId')
     .exists()
@@ -39,39 +38,39 @@ const validator = [
     .withMessage('tokenId must be a string'),
 ];
 
-export const deleteAccessTokenHandlersFactory: DeleteAccessTokenHandlersFactory =
-  (crowi) => {
-    const loginRequiredStrictly =
-      require('../../../middlewares/login-required')(crowi);
-    const addActivity = generateAddActivityMiddleware();
-    const activityEvent = crowi.events.activity;
+export const deleteAccessTokenHandlersFactory = (
+  crowi: Crowi,
+): RequestHandler[] => {
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
+  const addActivity = generateAddActivityMiddleware();
+  const activityEvent = crowi.events.activity;
 
-    return [
-      accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN]),
-      loginRequiredStrictly,
-      excludeReadOnlyUser,
-      addActivity,
-      validator,
-      apiV3FormValidator,
-      async (req: DeleteAccessTokenRequest, res: ApiV3Response) => {
-        const { query } = req;
-        const { tokenId } = query;
+  return [
+    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN]),
+    loginRequiredStrictly,
+    excludeReadOnlyUser,
+    addActivity,
+    ...validator,
+    apiV3FormValidator,
+    async (req: DeleteAccessTokenRequest, res: ApiV3Response) => {
+      const { query } = req;
+      const { tokenId } = query;
 
-        try {
-          await AccessToken.deleteTokenById(tokenId);
+      try {
+        await AccessToken.deleteTokenById(tokenId);
 
-          const parameters = {
-            action: SupportedAction.ACTION_USER_ACCESS_TOKEN_DELETE,
-          };
-          activityEvent.emit('update', res.locals.activity._id, parameters);
+        const parameters = {
+          action: SupportedAction.ACTION_USER_ACCESS_TOKEN_DELETE,
+        };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
 
-          return res.apiv3({});
-        } catch (err) {
-          logger.error(err);
-          return res.apiv3Err(
-            new ErrorV3(err.toString(), 'delete-access-token-failed'),
-          );
-        }
-      },
-    ];
-  };
+        return res.apiv3({});
+      } catch (err) {
+        logger.error(err);
+        return res.apiv3Err(
+          new ErrorV3(err.toString(), 'delete-access-token-failed'),
+        );
+      }
+    },
+  ];
+};

+ 35 - 36
apps/app/src/server/routes/apiv3/personal-setting/delete-all-access-tokens.ts

@@ -8,6 +8,7 @@ import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { excludeReadOnlyUser } from '~/server/middlewares/exclude-read-only-user';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import { AccessToken } from '~/server/models/access-token';
 import loggerFactory from '~/utils/logger';
 
@@ -18,42 +19,40 @@ const logger = loggerFactory(
 );
 
 interface DeleteAllAccessTokensRequest
-  extends Request<undefined, ApiV3Response, undefined> {
+  extends Request<Record<string, string>, ApiV3Response, undefined> {
   user: IUserHasId;
 }
 
-type DeleteAllAccessTokensHandlersFactory = (crowi: Crowi) => RequestHandler[];
-
-export const deleteAllAccessTokensHandlersFactory: DeleteAllAccessTokensHandlersFactory =
-  (crowi) => {
-    const loginRequiredStrictly =
-      require('../../../middlewares/login-required')(crowi);
-    const addActivity = generateAddActivityMiddleware();
-    const activityEvent = crowi.events.activity;
-
-    return [
-      accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN]),
-      loginRequiredStrictly,
-      excludeReadOnlyUser,
-      addActivity,
-      async (req: DeleteAllAccessTokensRequest, res: ApiV3Response) => {
-        const { user } = req;
-
-        try {
-          await AccessToken.deleteAllTokensByUserId(user._id);
-
-          const parameters = {
-            action: SupportedAction.ACTION_USER_ACCESS_TOKEN_DELETE,
-          };
-          activityEvent.emit('update', res.locals.activity._id, parameters);
-
-          return res.apiv3({});
-        } catch (err) {
-          logger.error(err);
-          return res.apiv3Err(
-            new ErrorV3(err.toString(), 'delete-all-access-token-failed'),
-          );
-        }
-      },
-    ];
-  };
+export const deleteAllAccessTokensHandlersFactory = (
+  crowi: Crowi,
+): RequestHandler[] => {
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
+  const addActivity = generateAddActivityMiddleware();
+  const activityEvent = crowi.events.activity;
+
+  return [
+    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN]),
+    loginRequiredStrictly,
+    excludeReadOnlyUser,
+    addActivity,
+    async (req: DeleteAllAccessTokensRequest, res: ApiV3Response) => {
+      const { user } = req;
+
+      try {
+        await AccessToken.deleteAllTokensByUserId(user._id);
+
+        const parameters = {
+          action: SupportedAction.ACTION_USER_ACCESS_TOKEN_DELETE,
+        };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
+        return res.apiv3({});
+      } catch (err) {
+        logger.error(err);
+        return res.apiv3Err(
+          new ErrorV3(err.toString(), 'delete-all-access-token-failed'),
+        );
+      }
+    },
+  ];
+};

+ 42 - 43
apps/app/src/server/routes/apiv3/personal-setting/generate-access-token.ts

@@ -7,6 +7,7 @@ import { SupportedAction } from '~/interfaces/activity';
 import type Crowi from '~/server/crowi';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { excludeReadOnlyUser } from '~/server/middlewares/exclude-read-only-user';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import { AccessToken } from '~/server/models/access-token';
 import { isValidScope } from '~/server/util/scope-utils';
 import loggerFactory from '~/utils/logger';
@@ -25,12 +26,10 @@ type ReqBody = {
 };
 
 interface GenerateAccessTokenRequest
-  extends Request<undefined, ApiV3Response, ReqBody> {
+  extends Request<Record<string, string>, ApiV3Response, ReqBody> {
   user: IUserHasId;
 }
 
-type GenerateAccessTokenHandlerFactory = (crowi: Crowi) => RequestHandler[];
-
 const validator = [
   body('expiredAt')
     .exists()
@@ -74,43 +73,43 @@ const validator = [
     .withMessage('Invalid scope'),
 ];
 
-export const generateAccessTokenHandlerFactory: GenerateAccessTokenHandlerFactory =
-  (crowi) => {
-    const loginRequiredStrictly =
-      require('../../../middlewares/login-required')(crowi);
-    const activityEvent = crowi.events.activity;
-    const addActivity = generateAddActivityMiddleware();
-
-    return [
-      loginRequiredStrictly,
-      excludeReadOnlyUser,
-      addActivity,
-      validator,
-      apiV3FormValidator,
-      async (req: GenerateAccessTokenRequest, res: ApiV3Response) => {
-        const { user, body } = req;
-        const { expiredAt, description, scopes } = body;
-
-        try {
-          const tokenData = await AccessToken.generateToken(
-            user._id,
-            expiredAt,
-            scopes,
-            description,
-          );
-
-          const parameters = {
-            action: SupportedAction.ACTION_USER_ACCESS_TOKEN_CREATE,
-          };
-          activityEvent.emit('update', res.locals.activity._id, parameters);
-
-          return res.apiv3(tokenData);
-        } catch (err) {
-          logger.error(err);
-          return res.apiv3Err(
-            new ErrorV3(err.toString(), 'generate-access-token-failed'),
-          );
-        }
-      },
-    ];
-  };
+export const generateAccessTokenHandlerFactory = (
+  crowi: Crowi,
+): RequestHandler[] => {
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
+  const activityEvent = crowi.events.activity;
+  const addActivity = generateAddActivityMiddleware();
+
+  return [
+    loginRequiredStrictly,
+    excludeReadOnlyUser,
+    addActivity,
+    ...validator,
+    apiV3FormValidator,
+    async (req: GenerateAccessTokenRequest, res: ApiV3Response) => {
+      const { user, body } = req;
+      const { expiredAt, description, scopes } = body;
+
+      try {
+        const tokenData = await AccessToken.generateToken(
+          user._id,
+          expiredAt,
+          scopes,
+          description,
+        );
+
+        const parameters = {
+          action: SupportedAction.ACTION_USER_ACCESS_TOKEN_CREATE,
+        };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
+        return res.apiv3(tokenData);
+      } catch (err) {
+        logger.error(err);
+        return res.apiv3Err(
+          new ErrorV3(err.toString(), 'generate-access-token-failed'),
+        );
+      }
+    },
+  ];
+};

+ 6 - 9
apps/app/src/server/routes/apiv3/personal-setting/get-access-tokens.ts

@@ -7,6 +7,7 @@ import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { excludeReadOnlyUser } from '~/server/middlewares/exclude-read-only-user';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import { AccessToken } from '~/server/models/access-token';
 import loggerFactory from '~/utils/logger';
 
@@ -17,18 +18,14 @@ const logger = loggerFactory(
 );
 
 interface GetAccessTokenRequest
-  extends Request<undefined, ApiV3Response, undefined> {
+  extends Request<Record<string, string>, ApiV3Response, undefined> {
   user: IUserHasId;
 }
 
-type GetAccessTokenHandlerFactory = (crowi: Crowi) => RequestHandler[];
-
-export const getAccessTokenHandlerFactory: GetAccessTokenHandlerFactory = (
-  crowi,
-) => {
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(
-    crowi,
-  );
+export const getAccessTokenHandlerFactory = (
+  crowi: Crowi,
+): RequestHandler[] => {
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
   const addActivity = generateAddActivityMiddleware();
 
   return [

+ 2 - 3
apps/app/src/server/routes/apiv3/personal-setting/index.js

@@ -6,6 +6,7 @@ import { i18n } from '^/config/next-i18next.config';
 
 import { SupportedAction } from '~/interfaces/activity';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import loggerFactory from '~/utils/logger';
 
 import { generateAddActivityMiddleware } from '../../../middlewares/add-activity';
@@ -73,9 +74,7 @@ const router = express.Router();
  */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(
-    crowi,
-  );
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
   const { User } = crowi.models;

+ 2 - 4
apps/app/src/server/routes/apiv3/revisions.js

@@ -4,6 +4,7 @@ import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import express from 'express';
 
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import { Revision } from '~/server/models/revision';
 import {
   getAppliedAtForRevisionFilter,
@@ -59,10 +60,7 @@ module.exports = (crowi) => {
   const certifySharedPage = require('../../middlewares/certify-shared-page')(
     crowi,
   );
-  const loginRequired = require('../../middlewares/login-required')(
-    crowi,
-    true,
-  );
+  const loginRequired = loginRequiredFactory(crowi, true);
 
   const { Page, User } = crowi.models;
 

+ 4 - 2
apps/app/src/server/routes/apiv3/search.js

@@ -3,6 +3,8 @@ import { ErrorV3 } from '@growi/core/dist/models';
 
 import { SupportedAction } from '~/interfaces/activity';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import adminRequiredFactory from '~/server/middlewares/admin-required';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import loggerFactory from '~/utils/logger';
 
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
@@ -100,8 +102,8 @@ const noCache = require('nocache');
  */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
-  const loginRequired = require('../../middlewares/login-required')(crowi);
-  const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const loginRequired = loginRequiredFactory(crowi);
+  const adminRequired = adminRequiredFactory(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
   const activityEvent = crowi.events.activity;

+ 4 - 4
apps/app/src/server/routes/apiv3/security-settings/index.js

@@ -10,7 +10,9 @@ import { SupportedAction } from '~/interfaces/activity';
 import { PageDeleteConfigValue } from '~/interfaces/page-delete-config';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import adminRequiredFactory from '~/server/middlewares/admin-required';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import ShareLink from '~/server/models/share-link';
 import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
@@ -412,10 +414,8 @@ const validator = {
  */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
-  const adminRequired = require('~/server/middlewares/admin-required')(crowi);
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
+  const adminRequired = adminRequiredFactory(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
   const activityEvent = crowi.events.activity;

+ 4 - 2
apps/app/src/server/routes/apiv3/share-links.js

@@ -6,8 +6,10 @@ import express from 'express';
 import { SupportedAction } from '~/interfaces/activity';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import adminRequiredFactory from '~/server/middlewares/admin-required';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { excludeReadOnlyUser } from '~/server/middlewares/exclude-read-only-user';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import ShareLink from '~/server/models/share-link';
 import loggerFactory from '~/utils/logger';
 
@@ -82,8 +84,8 @@ const today = new Date();
 
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
-  const loginRequired = require('../../middlewares/login-required')(crowi);
-  const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const loginRequired = loginRequiredFactory(crowi);
+  const adminRequired = adminRequiredFactory(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
   const { Page } = crowi.models;

+ 4 - 4
apps/app/src/server/routes/apiv3/slack-integration-legacy-settings.js

@@ -5,6 +5,8 @@ import { body } from 'express-validator';
 
 import { SupportedAction } from '~/interfaces/activity';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import adminRequiredFactory from '~/server/middlewares/admin-required';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 
@@ -51,10 +53,8 @@ const validator = {
  */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(
-    crowi,
-  );
-  const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
+  const adminRequired = adminRequiredFactory(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
   const activityEvent = crowi.events.activity;

+ 4 - 4
apps/app/src/server/routes/apiv3/slack-integration-settings.js

@@ -15,6 +15,8 @@ import {
 
 import { SupportedAction } from '~/interfaces/activity';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import adminRequiredFactory from '~/server/middlewares/admin-required';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import axios from '~/utils/axios';
 import loggerFactory from '~/utils/logger';
 
@@ -49,10 +51,8 @@ const router = express.Router();
 
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(
-    crowi,
-  );
-  const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
+  const adminRequired = adminRequiredFactory(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
   const { SlackAppIntegration } = crowi.models;

+ 2 - 3
apps/app/src/server/routes/apiv3/user-activities.ts

@@ -8,6 +8,7 @@ import { Types } from 'mongoose';
 
 import type { IActivity } from '~/interfaces/activity';
 import { ActivityLogActions } from '~/interfaces/activity';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import Activity from '~/server/models/activity';
 import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
@@ -143,9 +144,7 @@ type ActivityPaginationResult = PaginateResult<IActivity>;
  */
 
 module.exports = (crowi: Crowi): Router => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(
-    crowi,
-  );
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
   const router = express.Router();
 

+ 4 - 4
apps/app/src/server/routes/apiv3/user-group-relation.js

@@ -3,6 +3,8 @@ import { ErrorV3 } from '@growi/core/dist/models';
 import express from 'express';
 
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import adminRequiredFactory from '~/server/middlewares/admin-required';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import { serializeUserGroupRelationSecurely } from '~/server/models/serializers';
 import UserGroupRelation from '~/server/models/user-group-relation';
 import loggerFactory from '~/utils/logger';
@@ -17,10 +19,8 @@ const validator = {};
 
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(
-    crowi,
-  );
-  const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
+  const adminRequired = adminRequiredFactory(crowi);
 
   validator.list = [
     query('groupIds', 'groupIds is required and must be an array').isArray(),

+ 4 - 4
apps/app/src/server/routes/apiv3/user-group.js

@@ -7,6 +7,8 @@ import { body, param, query, sanitizeQuery } from 'express-validator';
 
 import { SupportedAction } from '~/interfaces/activity';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import adminRequiredFactory from '~/server/middlewares/admin-required';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import { serializeUserGroupRelationSecurely } from '~/server/models/serializers/user-group-relation-serializer';
 import UserGroup from '~/server/models/user-group';
 import UserGroupRelation from '~/server/models/user-group-relation';
@@ -27,10 +29,8 @@ const router = express.Router();
 
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(
-    crowi,
-  );
-  const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
+  const adminRequired = adminRequiredFactory(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
   const activityEvent = crowi.events.activity;

+ 5 - 8
apps/app/src/server/routes/apiv3/user/get-related-groups.ts

@@ -5,24 +5,21 @@ import type { Request, RequestHandler } from 'express';
 
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import loggerFactory from '~/utils/logger';
 
 import type { ApiV3Response } from '../interfaces/apiv3-response';
 
 const logger = loggerFactory('growi:routes:apiv3:user:get-related-groups');
 
-type GetRelatedGroupsHandlerFactory = (crowi: Crowi) => RequestHandler[];
-
 interface Req extends Request {
   user: IUserHasId;
 }
 
-export const getRelatedGroupsHandlerFactory: GetRelatedGroupsHandlerFactory = (
-  crowi,
-) => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
+export const getRelatedGroupsHandlerFactory = (
+  crowi: Crowi,
+): RequestHandler[] => {
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
   return [
     accessTokenParser([SCOPE.READ.USER_SETTINGS.INFO], { acceptLegacy: true }),

+ 6 - 9
apps/app/src/server/routes/apiv3/users.js

@@ -5,13 +5,15 @@ import { userHomepagePath } from '@growi/core/dist/utils/page-path-utils';
 import escapeStringRegexp from 'escape-string-regexp';
 import express from 'express';
 import { body, query } from 'express-validator';
-import path from 'path';
+import path from 'pathe';
 import { isEmail } from 'validator';
 
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import { deleteUserAiAssistant } from '~/features/openai/server/services/delete-ai-assistant';
 import { SupportedAction } from '~/interfaces/activity';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import adminRequiredFactory from '~/server/middlewares/admin-required';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import Activity from '~/server/models/activity';
 import ExternalAccount from '~/server/models/external-account';
 import { serializePageSecurely } from '~/server/models/serializers';
@@ -110,14 +112,9 @@ const validator = {};
 
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
-  const loginRequired = require('../../middlewares/login-required')(
-    crowi,
-    true,
-  );
-  const loginRequiredStrictly = require('../../middlewares/login-required')(
-    crowi,
-  );
-  const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const loginRequired = loginRequiredFactory(crowi, true);
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
+  const adminRequired = adminRequiredFactory(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
   const activityEvent = crowi.events.activity;

+ 2 - 4
apps/app/src/server/routes/attachment/download.ts

@@ -3,6 +3,7 @@ import express from 'express';
 
 import { SupportedAction } from '~/interfaces/activity';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import loggerFactory from '~/utils/logger';
 
 import type Crowi from '../../crowi';
@@ -25,10 +26,7 @@ const generateActivityParameters = (req: CrowiRequest) => {
 };
 
 export const downloadRouterFactory = (crowi: Crowi): Router => {
-  const loginRequired = require('../../middlewares/login-required')(
-    crowi,
-    true,
-  );
+  const loginRequired = loginRequiredFactory(crowi, true);
 
   const router = express.Router();
 

+ 2 - 4
apps/app/src/server/routes/attachment/get-brand-logo.ts

@@ -4,6 +4,7 @@ import express from 'express';
 
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import loggerFactory from '~/utils/logger';
 
 import type Crowi from '../../crowi';
@@ -17,10 +18,7 @@ const logger = loggerFactory('growi:routes:attachment:get-brand-logo');
 
 export const getBrandLogoRouterFactory = (crowi: Crowi): Router => {
   const certifyBrandLogo = generateCertifyBrandLogoMiddleware(crowi);
-  const loginRequired = require('../../middlewares/login-required')(
-    crowi,
-    true,
-  );
+  const loginRequired = loginRequiredFactory(crowi, true);
 
   const router = express.Router();
 

+ 2 - 4
apps/app/src/server/routes/attachment/get.ts

@@ -9,6 +9,7 @@ import {
   type RespondOptions,
   ResponseMode,
 } from '~/server/interfaces/attachment';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import {
   applyHeaders,
   createContentHeaders,
@@ -190,10 +191,7 @@ export type GetRequest = CrowiProperties &
 export type GetResponse = Response<any, LocalsAfterDataInjection>;
 
 export const getRouterFactory = (crowi: Crowi): Router => {
-  const loginRequired = require('../../middlewares/login-required')(
-    crowi,
-    true,
-  );
+  const loginRequired = loginRequiredFactory(crowi, true);
 
   const router = express.Router();
 

+ 5 - 3
apps/app/src/server/routes/index.js

@@ -5,6 +5,7 @@ import { middlewareFactory as rateLimiterFactory } from '~/features/rate-limiter
 
 import { accessTokenParser } from '../middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '../middlewares/add-activity';
+import adminRequiredFactory from '../middlewares/admin-required';
 import apiV1FormValidator from '../middlewares/apiv1-form-validator';
 import * as applicationNotInstalled from '../middlewares/application-not-installed';
 import {
@@ -14,6 +15,7 @@ import {
 import injectResetOrderByTokenMiddleware from '../middlewares/inject-reset-order-by-token-middleware';
 import injectUserRegistrationOrderByTokenMiddleware from '../middlewares/inject-user-registration-order-by-token-middleware';
 import * as loginFormValidator from '../middlewares/login-form-validator';
+import loginRequiredFactory from '../middlewares/login-required';
 import {
   generateUnavailableWhenMaintenanceModeMiddleware,
   generateUnavailableWhenMaintenanceModeMiddlewareForApi,
@@ -36,9 +38,9 @@ module.exports = (crowi, app) => {
   const applicationInstalled = require('../middlewares/application-installed')(
     crowi,
   );
-  const loginRequiredStrictly = require('../middlewares/login-required')(crowi);
-  const loginRequired = require('../middlewares/login-required')(crowi, true);
-  const adminRequired = require('../middlewares/admin-required')(crowi);
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
+  const loginRequired = loginRequiredFactory(crowi, true);
+  const adminRequired = adminRequiredFactory(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
   const uploads = multer({ dest: `${crowi.tmpDir}uploads` });

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

@@ -209,4 +209,4 @@ class ActivityService {
   };
 }
 
-module.exports = ActivityService;
+export default ActivityService;

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

@@ -94,4 +94,4 @@ class CommentService {
   };
 }
 
-module.exports = CommentService;
+export default CommentService;

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików