Yuki Takei 1 месяц назад
Родитель
Сommit
82b396301b

+ 99 - 22
apps/app/src/server/routes/apiv3/page/page-info-sharelink.integ.ts → apps/app/src/server/routes/apiv3/page/get-page-info.integ.ts

@@ -9,6 +9,12 @@ import type Crowi from '~/server/crowi';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import * as findPageModule from '~/server/service/page/find-page-and-meta-data-by-viewer';
 
+// Extend Request type for test
+interface TestRequest extends Request {
+  isSharedPage?: boolean;
+  crowi?: Crowi;
+}
+
 // Passthrough middleware for testing - skips authentication
 const passthroughMiddleware = (
   _req: Request,
@@ -18,7 +24,7 @@ const passthroughMiddleware = (
 
 // Mock certify-shared-page middleware - sets isSharedPage when shareLinkId is present
 const mockCertifySharedPage = (
-  req: Request,
+  req: TestRequest,
   _res: Response,
   next: NextFunction,
 ) => {
@@ -36,18 +42,17 @@ vi.mock('~/server/middlewares/access-token-parser', () => ({
 }));
 
 vi.mock('~/server/middlewares/login-required', () => ({
-  default: () => (req: Request, _res: Response, next: NextFunction) => {
+  default: () => (req: TestRequest, _res: Response, next: NextFunction) => {
     // Allow access if isSharedPage is true (anonymous user accessing share link)
     if (req.isSharedPage) {
       return next();
     }
     // For non-shared pages, authentication would be required
-    // In this test, we should not reach this path for share links
     return next();
   },
 }));
 
-describe('GET /info with Share Link', () => {
+describe('GET /info', () => {
   let app: express.Application;
   let crowi: Crowi;
 
@@ -79,7 +84,7 @@ describe('GET /info with Share Link', () => {
       meta: {
         isNotFound: false,
         isForbidden: false,
-      },
+      } as any,
     });
 
     // Setup express app
@@ -89,21 +94,21 @@ describe('GET /info with Share Link', () => {
     // Mock apiv3 response methods
     app.use((_req, res, next) => {
       const apiRes = res as ApiV3Response;
-      apiRes.apiv3 = (data) => res.json(data);
-      apiRes.apiv3Err = (error, statusCode?: number) => {
+      apiRes.apiv3 = (data: unknown) => res.json(data);
+      apiRes.apiv3Err = (error: unknown, statusCode?: number) => {
         // Check if error is validation error (array of ErrorV3)
         const isValidationError =
           Array.isArray(error) &&
-          error.some((e: any) => e?.code === 'validation_failed');
+          error.some((e: unknown) => (e as any)?.code === 'validation_failed');
         const status = statusCode ?? (isValidationError ? 400 : 500);
-        const errorMessage = error?.message || error;
+        const errorMessage = (error as any)?.message || error;
         return res.status(status).json({ error: errorMessage });
       };
       next();
     });
 
     // Inject crowi instance
-    app.use((req, _res, next) => {
+    app.use((req: TestRequest, _res, next) => {
       req.crowi = crowi;
       next();
     });
@@ -115,9 +120,9 @@ describe('GET /info with Share Link', () => {
     app.use('/', pageRouter);
 
     // Error handling middleware (must be after router)
-    app.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
+    app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
       const apiRes = res as ApiV3Response;
-      const statusCode = err.statusCode || err.status || 500;
+      const statusCode = (err as any).statusCode || (err as any).status || 500;
       return apiRes.apiv3Err(err, statusCode);
     });
   });
@@ -129,7 +134,61 @@ describe('GET /info with Share Link', () => {
     vi.restoreAllMocks();
   });
 
-  describe('with valid shareLinkId parameter', () => {
+  describe('Normal page access', () => {
+    it('should return 200 with page meta when pageId is valid', async () => {
+      const response = await request(app)
+        .get('/info')
+        .query({ pageId: validPageId });
+
+      expect(response.status).toBe(200);
+      expect(response.body).toHaveProperty('isNotFound');
+      expect(response.body).toHaveProperty('isForbidden');
+      expect(response.body.isNotFound).toBe(false);
+      expect(response.body.isForbidden).toBe(false);
+    });
+
+    it('should return 403 when page is forbidden', async () => {
+      vi.spyOn(findPageModule, 'findPageAndMetaDataByViewer').mockResolvedValue(
+        {
+          data: null,
+          meta: {
+            isNotFound: true,
+            isForbidden: true,
+          } as any,
+        },
+      );
+
+      const response = await request(app)
+        .get('/info')
+        .query({ pageId: validPageId });
+
+      expect(response.status).toBe(403);
+      expect(response.body).toHaveProperty('error');
+    });
+
+    it('should return 200 when page is empty (not found but not forbidden)', async () => {
+      vi.spyOn(findPageModule, 'findPageAndMetaDataByViewer').mockResolvedValue(
+        {
+          data: null,
+          meta: {
+            isNotFound: true,
+            isForbidden: false,
+            isEmpty: true,
+          } as any,
+        },
+      );
+
+      const response = await request(app)
+        .get('/info')
+        .query({ pageId: validPageId });
+
+      expect(response.status).toBe(200);
+      expect(response.body).toHaveProperty('isEmpty');
+      expect(response.body.isEmpty).toBe(true);
+    });
+  });
+
+  describe('Share link access', () => {
     it('should return 200 when accessing with both pageId and shareLinkId', async () => {
       const response = await request(app)
         .get('/info')
@@ -149,17 +208,15 @@ describe('GET /info with Share Link', () => {
     });
   });
 
-  describe('without shareLinkId parameter', () => {
-    it('should still work for normal page access', async () => {
+  describe('Validation', () => {
+    it('should reject invalid pageId format', async () => {
       const response = await request(app)
         .get('/info')
-        .query({ pageId: validPageId });
+        .query({ pageId: 'invalid-id' });
 
-      expect(response.status).toBe(200);
+      expect(response.status).toBe(400);
     });
-  });
 
-  describe('validation', () => {
     it('should reject invalid shareLinkId format', async () => {
       const response = await request(app)
         .get('/info')
@@ -168,12 +225,32 @@ describe('GET /info with Share Link', () => {
       expect(response.status).toBe(400);
     });
 
-    it('should still require pageId parameter', async () => {
+    it('should require pageId parameter', async () => {
+      const response = await request(app).get('/info');
+
+      expect(response.status).toBe(400);
+    });
+
+    it('should work with only pageId (shareLinkId is optional)', async () => {
       const response = await request(app)
         .get('/info')
-        .query({ shareLinkId: validShareLinkId });
+        .query({ pageId: validPageId });
 
-      expect(response.status).toBe(400);
+      expect(response.status).toBe(200);
+    });
+  });
+
+  describe('Error handling', () => {
+    it('should return 500 when service throws an error', async () => {
+      vi.spyOn(findPageModule, 'findPageAndMetaDataByViewer').mockRejectedValue(
+        new Error('Service error'),
+      );
+
+      const response = await request(app)
+        .get('/info')
+        .query({ pageId: validPageId });
+
+      expect(response.status).toBe(500);
     });
   });
 });

+ 113 - 0
apps/app/src/server/routes/apiv3/page/get-page-info.ts

@@ -0,0 +1,113 @@
+import type { IUserHasId } from '@growi/core';
+import { isIPageNotFoundInfo, SCOPE } from '@growi/core';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+import { query } from 'express-validator';
+
+import type Crowi from '~/server/crowi';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import loginRequiredFactory from '~/server/middlewares/login-required';
+import { findPageAndMetaDataByViewer } from '~/server/service/page/find-page-and-meta-data-by-viewer';
+import loggerFactory from '~/utils/logger';
+
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+const logger = loggerFactory('growi:routes:apiv3:page:get-page-info');
+
+interface Req extends Request {
+  user?: IUserHasId;
+  isSharedPage?: boolean;
+}
+
+/**
+ * @swagger
+ *
+ *    /page/info:
+ *      get:
+ *        tags: [Page]
+ *        summary: /page/info
+ *        description: Get summary informations for a page
+ *        parameters:
+ *          - name: pageId
+ *            in: query
+ *            required: true
+ *            description: page id
+ *            schema:
+ *              $ref: '#/components/schemas/ObjectId'
+ *          - name: shareLinkId
+ *            in: query
+ *            description: share link id for shared page access
+ *            schema:
+ *              $ref: '#/components/schemas/ObjectId'
+ *        responses:
+ *          200:
+ *            description: Successfully retrieved current page info.
+ *            content:
+ *              application/json:
+ *                schema:
+ *                  $ref: '#/components/schemas/PageInfoExt'
+ *          403:
+ *            description: Page is forbidden.
+ *          500:
+ *            description: Internal server error.
+ */
+export const getPageInfoHandlerFactory = (crowi: Crowi): RequestHandler[] => {
+  const loginRequired = loginRequiredFactory(crowi, true);
+  const certifySharedPage = require('../../../middlewares/certify-shared-page')(
+    crowi,
+  );
+  const { pageService, pageGrantService } = crowi;
+
+  // define validators for req.query
+  const validator = [
+    query('pageId').isMongoId().withMessage('pageId is required'),
+    query('shareLinkId').optional().isMongoId(),
+  ];
+
+  return [
+    accessTokenParser([SCOPE.READ.FEATURES.PAGE]),
+    certifySharedPage,
+    loginRequired,
+    ...validator,
+    apiV3FormValidator,
+    async (req: Req, res: ApiV3Response) => {
+      const { user, isSharedPage } = req;
+      const { pageId } = req.query;
+
+      try {
+        const { meta } = await findPageAndMetaDataByViewer(
+          pageService,
+          pageGrantService,
+          {
+            pageId: pageId as string,
+            path: null,
+            user: user as any,
+            isSharedPage,
+          },
+        );
+
+        if (isIPageNotFoundInfo(meta)) {
+          // Return error only when the page is forbidden
+          if (meta.isForbidden) {
+            return res.apiv3Err(
+              new ErrorV3(
+                'Page is forbidden',
+                'page-is-forbidden',
+                undefined,
+                meta,
+              ),
+              403,
+            );
+          }
+        }
+
+        // Empty pages (isEmpty: true) should return page info for UI operations
+        return res.apiv3(meta);
+      } catch (err) {
+        logger.error('get-page-info', err);
+        return res.apiv3Err(err, 500);
+      }
+    },
+  ];
+};

+ 2 - 70
apps/app/src/server/routes/apiv3/page/index.ts

@@ -53,6 +53,7 @@ import loggerFactory from '~/utils/logger';
 import type { ApiV3Response } from '../interfaces/apiv3-response';
 import { checkPageExistenceHandlersFactory } from './check-page-existence';
 import { createPageHandlersFactory } from './create-page';
+import { getPageInfoHandlerFactory } from './get-page-info';
 import { getPagePathsWithDescendantCountFactory } from './get-page-paths-with-descendant-count';
 import { getYjsDataHandlerFactory } from './get-yjs-data';
 import { publishPageHandlersFactory } from './publish-page';
@@ -108,10 +109,6 @@ module.exports = (crowi: Crowi) => {
       query('includeEmpty').optional().isBoolean(),
     ],
     likes: [body('pageId').isString(), body('bool').isBoolean()],
-    info: [
-      query('pageId').isMongoId().withMessage('pageId is required'),
-      query('shareLinkId').optional().isMongoId(),
-    ],
     getGrantData: [
       query('pageId').isMongoId().withMessage('pageId is required'),
     ],
@@ -590,72 +587,7 @@ module.exports = (crowi: Crowi) => {
     },
   );
 
-  /**
-   * @swagger
-   *
-   *    /page/info:
-   *      get:
-   *        tags: [Page]
-   *        summary: /page/info
-   *        description: Get summary informations for a page
-   *        parameters:
-   *          - name: pageId
-   *            in: query
-   *            required: true
-   *            description: page id
-   *            schema:
-   *              $ref: '#/components/schemas/ObjectId'
-   *        responses:
-   *          200:
-   *            description: Successfully retrieved current page info.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  $ref: '#/components/schemas/PageInfoExt'
-   *          500:
-   *            description: Internal server error.
-   */
-  router.get(
-    '/info',
-    accessTokenParser([SCOPE.READ.FEATURES.PAGE]),
-    certifySharedPage,
-    loginRequired,
-    validator.info,
-    apiV3FormValidator,
-    async (req, res) => {
-      const { user, isSharedPage } = req;
-      const { pageId } = req.query;
-
-      try {
-        const { meta } = await findPageAndMetaDataByViewer(
-          pageService,
-          pageGrantService,
-          { pageId, path: null, user, isSharedPage },
-        );
-
-        if (isIPageNotFoundInfo(meta)) {
-          // Return error only when the page is forbidden
-          if (meta.isForbidden) {
-            return res.apiv3Err(
-              new ErrorV3(
-                'Page is forbidden',
-                'page-is-forbidden',
-                undefined,
-                meta,
-              ),
-              403,
-            );
-          }
-        }
-
-        // Empty pages (isEmpty: true) should return page info for UI operations
-        return res.apiv3(meta);
-      } catch (err) {
-        logger.error('get-page-info', err);
-        return res.apiv3Err(err, 500);
-      }
-    },
-  );
+  router.get('/info', getPageInfoHandlerFactory(crowi));
 
   /**
    * @swagger