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

Merge pull request #6970 from weseek/support/cherry-pick-6910

support: Cherry pick #6910
Haku Mizuki 3 лет назад
Родитель
Сommit
9701591cc4

+ 2 - 0
packages/app/src/interfaces/page-operation.ts

@@ -1,4 +1,6 @@
 export const PageActionType = {
+  Create: 'Create',
+  Update: 'Update',
   Rename: 'Rename',
   Duplicate: 'Duplicate',
   Delete: 'Delete',

+ 18 - 1
packages/app/src/server/models/interfaces/page-operation.ts

@@ -1,3 +1,5 @@
+import { PageGrant } from '~/interfaces/page';
+
 import { ObjectIdLike } from '../../interfaces/mongoose-utils';
 
 export type IPageForResuming = {
@@ -19,8 +21,23 @@ export type IUserForResuming = {
   _id: ObjectIdLike,
 };
 
+export type IOptionsForUpdate = {
+  grant?: PageGrant,
+  grantUserGroupId?: ObjectIdLike,
+  isSyncRevisionToHackmd?: boolean,
+  overwriteScopesOfDescendants?: boolean,
+};
+
+export type IOptionsForCreate = {
+  format?: string,
+  grantUserGroupId?: ObjectIdLike,
+  grant?: PageGrant,
+  overwriteScopesOfDescendants?: boolean,
+  isSynchronously?: boolean,
+};
+
 export type IOptionsForResuming = {
   updateMetadata?: boolean,
   createRedirectPage?: boolean,
   prevDescendantCount?: number,
-};
+} & IOptionsForUpdate & IOptionsForCreate;

+ 14 - 117
packages/app/src/server/models/obsolete-page.js

@@ -1,5 +1,6 @@
 import { templateChecker, pagePathUtils, pathUtils } from '@growi/core';
 
+import { PageGrant } from '~/interfaces/page';
 import loggerFactory from '~/utils/logger';
 
 
@@ -647,132 +648,28 @@ export const getPageSchema = (crowi) => {
     return { templateBody, templateTags };
   };
 
-  async function pushRevision(pageData, newRevision, user) {
-    await newRevision.save();
-    debug('Successfully saved new revision', newRevision);
-
-    pageData.revision = newRevision;
-    pageData.lastUpdateUser = user;
-    pageData.updatedAt = Date.now();
-
-    return pageData.save();
-  }
-
-  async function validateAppliedScope(user, grant, grantUserGroupId) {
-    if (grant === GRANT_USER_GROUP && grantUserGroupId == null) {
-      throw new Error('grant userGroupId is not specified');
-    }
-
-    if (grant === GRANT_USER_GROUP) {
-      const UserGroupRelation = crowi.model('UserGroupRelation');
-      const count = await UserGroupRelation.countByGroupIdAndUser(grantUserGroupId, user);
-
-      if (count === 0) {
-        throw new Error('no relations were exist for group and user.');
-      }
-    }
-  }
-
-  pageSchema.statics.createV4 = async function(path, body, user, options = {}) {
-    /*
-     * v4 compatible process
-     */
-    validateCrowi();
-
-    const Page = this;
-    const Revision = crowi.model('Revision');
-    const format = options.format || 'markdown';
-    const grantUserGroupId = options.grantUserGroupId || null;
-    const expandContentWidth = crowi.configManager.getConfig('crowi', 'customize:isContainerFluid');
-
-    // sanitize path
-    path = crowi.xss.process(path); // eslint-disable-line no-param-reassign
-
-    let grant = options.grant;
-    // force public
-    if (isTopPage(path)) {
-      grant = GRANT_PUBLIC;
-    }
-
-    const isExist = await this.count({ path });
-
-    if (isExist) {
-      throw new Error('Cannot create new page to existed path');
-    }
+  pageSchema.statics.applyScopesToDescendantsAsyncronously = async function(parentPage, user, isV4 = false) {
+    const builder = new this.PageQueryBuilder(this.find());
+    builder.addConditionToListOnlyDescendants(parentPage.path);
 
-    const page = new Page();
-    page.path = path;
-    page.creator = user;
-    page.lastUpdateUser = user;
-    page.status = STATUS_PUBLISHED;
-    if (expandContentWidth != null) {
-      page.expandContentWidth = expandContentWidth;
+    if (isV4) {
+      builder.addConditionAsRootOrNotOnTree();
     }
-    await validateAppliedScope(user, grant, grantUserGroupId);
-    page.applyScope(user, grant, grantUserGroupId);
-
-    let savedPage = await page.save();
-    const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
-    savedPage = await pushRevision(savedPage, newRevision, user);
-    await savedPage.populateDataToShowRevision();
-
-    pageEvent.emit('create', savedPage, user);
-
-    return savedPage;
-  };
-
-  pageSchema.statics.updatePageV4 = async function(pageData, body, previousBody, user, options = {}) {
-    validateCrowi();
-
-    const Revision = crowi.model('Revision');
-    const grant = options.grant || pageData.grant; //                                  use the previous data if absence
-    const grantUserGroupId = options.grantUserGroupId || pageData.grantUserGroupId; // use the previous data if absence
-    const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
-
-    await validateAppliedScope(user, grant, grantUserGroupId);
-    pageData.applyScope(user, grant, grantUserGroupId);
-
-    // update existing page
-    let savedPage = await pageData.save();
-
-    // Update revision
-    const isBodyPresent = body != null && previousBody != null;
-    const shouldUpdateBody = isBodyPresent;
-    if (shouldUpdateBody) {
-      const newRevision = await Revision.prepareRevision(pageData, body, previousBody, user);
-      savedPage = await pushRevision(savedPage, newRevision, user);
-      await savedPage.populateDataToShowRevision();
-
-      if (isSyncRevisionToHackmd) {
-        savedPage = await this.syncRevisionToHackmd(savedPage);
-      }
+    else {
+      builder.addConditionAsOnTree();
     }
 
-
-    pageEvent.emit('update', savedPage, user);
-
-    return savedPage;
-  };
-
-  pageSchema.statics.applyScopesToDescendantsAsyncronously = async function(parentPage, user) {
-    const builder = new this.PageQueryBuilder(this.find());
-    builder.addConditionToListWithDescendants(parentPage.path);
-
     // add grant conditions
     await addConditionToFilteringByViewerToEdit(builder, user);
 
-    // get all pages that the specified user can update
-    const pages = await builder.query.exec();
+    const grant = parentPage.grant;
 
-    for (const page of pages) {
-      // skip parentPage
-      if (page.id === parentPage.id) {
-        continue;
-      }
+    await builder.query.updateMany({}, {
+      grant,
+      grantedGroup: grant === PageGrant.GRANT_USER_GROUP ? parentPage.grantedGroup : null,
+      grantedUsers: grant === PageGrant.GRANT_OWNER ? [user._id] : null,
+    });
 
-      page.applyScope(user, parentPage.grant, parentPage.grantedGroup);
-      page.save();
-    }
   };
 
   pageSchema.statics.removeByPath = function(path) {

+ 7 - 0
packages/app/src/server/models/page-operation.ts

@@ -33,6 +33,7 @@ export interface IPageOperation {
   options?: IOptionsForResuming,
   incForUpdatingDescendantCount?: number,
   unprocessableExpiryDate: Date,
+  exPage?: IPageForResuming,
 
   isProcessable(): boolean
 }
@@ -71,6 +72,11 @@ const optionsSchemaForResuming = new Schema<IOptionsForResuming>({
   createRedirectPage: { type: Boolean },
   updateMetadata: { type: Boolean },
   prevDescendantCount: { type: Number },
+  grant: { type: Number },
+  grantUserGroupId: { type: ObjectId, ref: 'UserGroup' },
+  format: { type: String },
+  isSyncRevisionToHackmd: { type: Boolean },
+  overwriteScopesOfDescendants: { type: Boolean },
 }, { _id: false });
 
 const schema = new Schema<PageOperationDocument, PageOperationModel>({
@@ -89,6 +95,7 @@ const schema = new Schema<PageOperationDocument, PageOperationModel>({
   fromPath: { type: String, required: true, index: true },
   toPath: { type: String, index: true },
   page: { type: pageSchemaForResuming, required: true },
+  exPage: { type: pageSchemaForResuming, required: false },
   user: { type: userSchemaForResuming, required: true },
   options: { type: optionsSchemaForResuming },
   incForUpdatingDescendantCount: { type: Number },

+ 5 - 165
packages/app/src/server/models/page.ts

@@ -340,7 +340,7 @@ export class PageQueryBuilder {
           { grant: { $ne: GRANT_SPECIFIED } },
         ],
       });
-    this.addConditionAsNotMigrated();
+    this.addConditionAsRootOrNotOnTree();
     this.addConditionAsNonRootPage();
     this.addConditionToExcludeTrashed();
     await this.addConditionForParentNormalization(user);
@@ -384,7 +384,7 @@ export class PageQueryBuilder {
     return this;
   }
 
-  addConditionAsNotMigrated(): PageQueryBuilder {
+  addConditionAsRootOrNotOnTree(): PageQueryBuilder {
     this.query = this.query
       .and({ parent: null });
 
@@ -956,177 +956,17 @@ export type PageCreateOptions = {
   format?: string
   grantUserGroupId?: ObjectIdLike
   grant?: number
+  overwriteScopesOfDescendants?: boolean
 }
 
 /*
  * Merge obsolete page model methods and define new methods which depend on crowi instance
  */
-// remove type for crowi to prevent 'import/no-cycle'
-// eslint-disable-next-line import/no-anonymous-default-export
-export default (crowi): any => {
-  let pageEvent;
-  if (crowi != null) {
-    pageEvent = crowi.event('page');
-  }
-
-  const shouldUseUpdatePageV4 = (grant: number, isV5Compatible: boolean, isOnTree: boolean): boolean => {
-    const isRestricted = grant === GRANT_RESTRICTED;
-    return !isRestricted && (!isV5Compatible || !isOnTree);
-  };
-
-  schema.statics.emitPageEventUpdate = (page: IPageHasId, user: IUserHasId): void => {
-    pageEvent.emit('update', page, user);
-  };
-
-  /**
-   * A wrapper method of schema.statics.updatePage for updating grant only.
-   * @param {PageDocument} page
-   * @param {UserDocument} user
-   * @param options
-   */
-  schema.statics.updateGrant = async function(page, user, grantData: {grant: PageGrant, grantedGroup: ObjectIdLike}) {
-    const { grant, grantedGroup } = grantData;
-
-    const options = {
-      grant,
-      grantUserGroupId: grantedGroup,
-      isSyncRevisionToHackmd: false,
-    };
-
-    return this.updatePage(page, null, null, user, options);
-  };
-
-  schema.statics.updatePage = async function(
-      pageData,
-      body: string | null,
-      previousBody: string | null,
-      user,
-      options: {grant?: PageGrant, grantUserGroupId?: ObjectIdLike, isSyncRevisionToHackmd?: boolean} = {},
-  ) {
-    if (crowi.configManager == null || crowi.pageGrantService == null || crowi.pageService == null) {
-      throw Error('Crowi is not set up');
-    }
-
-    const wasOnTree = pageData.parent != null || isTopPage(pageData.path);
-    const exParent = pageData.parent;
-    const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
-
-    const shouldUseV4Process = shouldUseUpdatePageV4(pageData.grant, isV5Compatible, wasOnTree);
-    if (shouldUseV4Process) {
-      // v4 compatible process
-      return this.updatePageV4(pageData, body, previousBody, user, options);
-    }
-
-    const grant = options.grant ?? pageData.grant; // use the previous data if absence
-    const grantUserGroupId: undefined | ObjectIdLike = options.grantUserGroupId ?? pageData.grantedGroup?._id.toString();
-
-    const grantedUserIds = pageData.grantedUserIds || [user._id];
-    const shouldBeOnTree = grant !== GRANT_RESTRICTED;
-    const isChildrenExist = await this.count({ path: new RegExp(`^${escapeStringRegexp(addTrailingSlash(pageData.path))}`), parent: { $ne: null } });
-
-    const newPageData = pageData;
-
-    if (shouldBeOnTree) {
-      let isGrantNormalized = false;
-      try {
-        isGrantNormalized = await crowi.pageGrantService.isGrantNormalized(user, pageData.path, grant, grantedUserIds, grantUserGroupId, true);
-      }
-      catch (err) {
-        logger.error(`Failed to validate grant of page at "${pageData.path}" of grant ${grant}:`, err);
-        throw err;
-      }
-      if (!isGrantNormalized) {
-        throw Error('The selected grant or grantedGroup is not assignable to this page.');
-      }
-
-      if (!wasOnTree) {
-        const newParent = await crowi.pageService.getParentAndFillAncestorsByUser(user, newPageData.path);
-        newPageData.parent = newParent._id;
-      }
-    }
-    else {
-      if (wasOnTree && isChildrenExist) {
-        // Update children's parent with new parent
-        const newParentForChildren = await this.createEmptyPage(pageData.path, pageData.parent, pageData.descendantCount);
-        await this.updateMany(
-          { parent: pageData._id },
-          { parent: newParentForChildren._id },
-        );
-      }
-
-      newPageData.parent = null;
-      newPageData.descendantCount = 0;
-    }
-
-    newPageData.applyScope(user, grant, grantUserGroupId);
-
-    // update existing page
-    let savedPage = await newPageData.save();
-
-    // Update body
-    const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
-    const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
-    const isBodyPresent = body != null && previousBody != null;
-    const shouldUpdateBody = isBodyPresent;
-    if (shouldUpdateBody) {
-      const newRevision = await Revision.prepareRevision(newPageData, body, previousBody, user);
-      savedPage = await pushRevision(savedPage, newRevision, user);
-      await savedPage.populateDataToShowRevision();
-
-      if (isSyncRevisionToHackmd) {
-        savedPage = await this.syncRevisionToHackmd(savedPage);
-      }
-    }
-
-
-    this.emitPageEventUpdate(savedPage, user);
-
-    // Update ex children's parent
-    if (!wasOnTree && shouldBeOnTree) {
-      const emptyPageAtSamePath = await this.findOne({ path: pageData.path, isEmpty: true }); // this page is necessary to find children
-
-      if (isChildrenExist) {
-        if (emptyPageAtSamePath != null) {
-          // Update children's parent with new parent
-          await this.updateMany(
-            { parent: emptyPageAtSamePath._id },
-            { parent: savedPage._id },
-          );
-        }
-      }
-
-      await this.findOneAndDelete({ path: pageData.path, isEmpty: true }); // delete here
-    }
-
-    // Sub operation
-    // 1. Update descendantCount
-    const shouldPlusDescCount = !wasOnTree && shouldBeOnTree;
-    const shouldMinusDescCount = wasOnTree && !shouldBeOnTree;
-    if (shouldPlusDescCount) {
-      await crowi.pageService.updateDescendantCountOfAncestors(newPageData._id, 1, false);
-      const newDescendantCount = await this.recountDescendantCount(newPageData._id);
-      await this.updateOne({ _id: newPageData._id }, { descendantCount: newDescendantCount });
-    }
-    else if (shouldMinusDescCount) {
-      // Update from parent. Parent is null if newPageData.grant is RESTRECTED.
-      if (newPageData.grant === GRANT_RESTRICTED) {
-        await crowi.pageService.updateDescendantCountOfAncestors(exParent, -1, true);
-      }
-    }
-
-    // 2. Delete unnecessary empty pages
-    const shouldRemoveLeafEmpPages = wasOnTree && !isChildrenExist;
-    if (shouldRemoveLeafEmpPages) {
-      await this.removeLeafEmptyPagesRecursively(exParent);
-    }
-
-    return savedPage;
-  };
-
+export default function PageModel(crowi): any {
   // add old page schema methods
   const pageSchema = getPageSchema(crowi);
   schema.methods = { ...pageSchema.methods, ...schema.methods };
   schema.statics = { ...pageSchema.statics, ...schema.statics };
 
   return getOrCreateModel<PageDocument, PageModel>('Page', schema as any); // TODO: improve type
-};
+}

+ 9 - 0
packages/app/src/server/models/user-group-relation.js

@@ -93,6 +93,15 @@ class UserGroupRelation {
       .exec();
   }
 
+  static async findAllUserIdsForUserGroup(userGroup) {
+    const relations = await this
+      .find({ relatedGroup: userGroup })
+      .select('relatedUser')
+      .exec();
+
+    return relations.map(r => r.relatedUser);
+  }
+
   /**
    * find all user and group relation of UserGroups
    *

+ 1 - 0
packages/app/src/server/models/user-group.ts

@@ -109,6 +109,7 @@ schema.statics.findGroupsWithAncestorsRecursively = async function(group, ancest
 };
 
 /**
+ * TODO: use $graphLookup
  * Find all descendant groups starting from the UserGroups in the initial groups in "groups".
  * Set "descendants" as "[]" if the initial groups are unnecessary as result.
  * @param groups UserGroupDocument[] including at least one UserGroup

+ 1 - 1
packages/app/src/server/routes/apiv3/page.js

@@ -559,7 +559,7 @@ module.exports = (crowi) => {
     try {
       const shouldUseV4Process = false;
       const grantData = { grant, grantedGroup };
-      data = await Page.updateGrant(page, req.user, grantData, shouldUseV4Process);
+      data = await this.crowi.pageService.updateGrant(page, req.user, grantData, shouldUseV4Process);
     }
     catch (err) {
       logger.error('Error occurred while processing calcApplicableGrantData.', err);

+ 1 - 6
packages/app/src/server/routes/apiv3/pages.js

@@ -298,7 +298,7 @@ module.exports = (crowi) => {
     // check whether path starts slash
     path = pathUtils.addHeadingSlash(path);
 
-    const options = {};
+    const options = { overwriteScopesOfDescendants };
     if (grant != null) {
       options.grant = grant;
       options.grantUserGroupId = grantUserGroupId;
@@ -323,11 +323,6 @@ module.exports = (crowi) => {
       revision: serializeRevisionSecurely(createdPage.revision),
     };
 
-    // update scopes for descendants
-    if (overwriteScopesOfDescendants) {
-      Page.applyScopesToDescendantsAsyncronously(createdPage, req.user);
-    }
-
     const parameters = {
       targetModel: SupportedTargetModel.MODEL_PAGE,
       target: createdPage,

+ 3 - 13
packages/app/src/server/routes/page.js

@@ -870,7 +870,7 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('Page exists', 'already_exists'));
     }
 
-    const options = {};
+    const options = { overwriteScopesOfDescendants };
     if (grant != null) {
       options.grant = grant;
       options.grantUserGroupId = grantUserGroupId;
@@ -891,11 +891,6 @@ module.exports = function(crowi, app) {
     };
     res.json(ApiResponse.success(result));
 
-    // update scopes for descendants
-    if (overwriteScopesOfDescendants) {
-      Page.applyScopesToDescendantsAsyncronously(createdPage, req.user);
-    }
-
     // global notification
     try {
       await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, createdPage, req.user);
@@ -1014,7 +1009,7 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('Posted param "revisionId" is outdated.', 'conflict', returnLatestRevision));
     }
 
-    const options = { isSyncRevisionToHackmd };
+    const options = { isSyncRevisionToHackmd, overwriteScopesOfDescendants };
     if (grant != null) {
       options.grant = grant;
       options.grantUserGroupId = grantUserGroupId;
@@ -1022,7 +1017,7 @@ module.exports = function(crowi, app) {
 
     const previousRevision = await Revision.findById(revisionId);
     try {
-      page = await Page.updatePage(page, pageBody, previousRevision.body, req.user, options);
+      page = await crowi.pageService.updatePage(page, pageBody, previousRevision.body, req.user, options);
     }
     catch (err) {
       logger.error('error on _api/pages.update', err);
@@ -1044,11 +1039,6 @@ module.exports = function(crowi, app) {
     };
     res.json(ApiResponse.success(result));
 
-    // update scopes for descendants
-    if (overwriteScopesOfDescendants) {
-      Page.applyScopesToDescendantsAsyncronously(page, req.user);
-    }
-
     // global notification
     try {
       await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_EDIT, page, req.user);

+ 1 - 1
packages/app/src/server/routes/tag.js

@@ -159,7 +159,7 @@ module.exports = function(crowi, app) {
       }
 
       const previousRevision = await Revision.findById(revisionId);
-      result.savedPage = await Page.updatePage(page, previousRevision.body, previousRevision.body, req.user);
+      result.savedPage = await crowi.pageService.updatePage(page, previousRevision.body, previousRevision.body, req.user);
       await PageTagRelation.updatePageTags(pageId, tags);
       result.tags = await PageTagRelation.listTagNamesByPage(pageId);
 

+ 1 - 1
packages/app/src/server/service/installer.ts

@@ -52,7 +52,7 @@ export class InstallerService {
   private async createPage(filePath, pagePath, owner): Promise<IPage|undefined> {
     try {
       const markdown = fs.readFileSync(filePath);
-      return this.crowi.pageService.create(pagePath, markdown, owner, {}) as IPage;
+      return this.crowi.pageService.create(pagePath, markdown, owner, { isSynchronously: true }) as IPage;
     }
     catch (err) {
       logger.error(`Failed to create ${pagePath}`, err);

+ 187 - 16
packages/app/src/server/service/page-grant.ts

@@ -1,4 +1,7 @@
-import { pagePathUtils, pathUtils, pageUtils } from '@growi/core';
+import {
+  pagePathUtils, pathUtils, pageUtils,
+  PageGrant, PageGrantCanBeOnTree,
+} from '@growi/core';
 import escapeStringRegexp from 'escape-string-regexp';
 import mongoose from 'mongoose';
 
@@ -35,6 +38,39 @@ type ComparableDescendants = {
   grantedGroupIds: ObjectIdLike[],
 };
 
+/**
+ * @param grantedUserGroupInfo This parameter has info to calculate whether the update operation is allowed.
+ *   - See the `calcCanOverwriteDescendants` private method for detail.
+ */
+type UpdateGrantInfo = {
+  grant: typeof PageGrant.GRANT_PUBLIC,
+} | {
+  grant: typeof PageGrant.GRANT_OWNER,
+  grantedUserId: ObjectIdLike,
+} | {
+  grant: typeof PageGrant.GRANT_USER_GROUP,
+  grantedUserGroupInfo: {
+    groupId: ObjectIdLike,
+    userIds: Set<ObjectIdLike>,
+    childrenOrItselfGroupIds: Set<ObjectIdLike>,
+  },
+};
+
+type DescendantPagesGrantInfo = {
+  grantSet: Set<number>,
+  grantedUserIds: Set<ObjectIdLike>, // all only me users of descendant pages
+  grantedUserGroupIds: Set<ObjectIdLike>, // all user groups of descendant pages
+};
+
+/**
+ * @param {ObjectIdLike} userId The _id of the operator.
+ * @param {Set<ObjectIdLike>} userGroupIds The Set of the _id of the user groups that the operator belongs.
+ */
+type OperatorGrantInfo = {
+  userId: ObjectIdLike,
+  userGroupIds: Set<ObjectIdLike>,
+};
+
 class PageGrantService {
 
   crowi!: any;
@@ -260,7 +296,7 @@ class PageGrantService {
    * @param targetPath string of the target path
    * @returns ComparableDescendants
    */
-  private async generateComparableDescendants(targetPath: string, user, includeNotMigratedPages: boolean): Promise<ComparableDescendants> {
+  private async generateComparableDescendants(targetPath: string, user, includeNotMigratedPages = false): Promise<ComparableDescendants> {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
 
@@ -412,22 +448,22 @@ class PageGrantService {
     const isOnlyPublicApplicable = isTopPage(page.path);
     if (isOnlyPublicApplicable) {
       return {
-        [Page.GRANT_PUBLIC]: null,
+        [PageGrant.GRANT_PUBLIC]: null,
       };
     }
 
     // Increment an object (type IRecordApplicableGrant)
     // grant is never public, anyone with the link, nor specified
     const data: IRecordApplicableGrant = {
-      [Page.GRANT_RESTRICTED]: null, // any page can be restricted
+      [PageGrant.GRANT_RESTRICTED]: null, // any page can be restricted
     };
 
     // -- Any grant is allowed if parent is null
     const isAnyGrantApplicable = page.parent == null;
     if (isAnyGrantApplicable) {
-      data[Page.GRANT_PUBLIC] = null;
-      data[Page.GRANT_OWNER] = null;
-      data[Page.GRANT_USER_GROUP] = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+      data[PageGrant.GRANT_PUBLIC] = null;
+      data[PageGrant.GRANT_OWNER] = null;
+      data[PageGrant.GRANT_USER_GROUP] = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
       return data;
     }
 
@@ -440,21 +476,21 @@ class PageGrantService {
       grant, grantedUsers, grantedGroup,
     } = parent;
 
-    if (grant === Page.GRANT_PUBLIC) {
-      data[Page.GRANT_PUBLIC] = null;
-      data[Page.GRANT_OWNER] = null;
-      data[Page.GRANT_USER_GROUP] = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+    if (grant === PageGrant.GRANT_PUBLIC) {
+      data[PageGrant.GRANT_PUBLIC] = null;
+      data[PageGrant.GRANT_OWNER] = null;
+      data[PageGrant.GRANT_USER_GROUP] = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
     }
-    else if (grant === Page.GRANT_OWNER) {
+    else if (grant === PageGrant.GRANT_OWNER) {
       const grantedUser = grantedUsers[0];
 
       const isUserApplicable = grantedUser.toString() === user._id.toString();
 
       if (isUserApplicable) {
-        data[Page.GRANT_OWNER] = null;
+        data[PageGrant.GRANT_OWNER] = null;
       }
     }
-    else if (grant === Page.GRANT_USER_GROUP) {
+    else if (grant === PageGrant.GRANT_USER_GROUP) {
       const group = await UserGroup.findById(grantedGroup);
       if (group == null) {
         throw Error('Group not found to calculate grant data.');
@@ -465,14 +501,149 @@ class PageGrantService {
       const isUserExistInGroup = await UserGroupRelation.countByGroupIdAndUser(group, user) > 0;
 
       if (isUserExistInGroup) {
-        data[Page.GRANT_OWNER] = null;
+        data[PageGrant.GRANT_OWNER] = null;
       }
-      data[Page.GRANT_USER_GROUP] = { applicableGroups };
+      data[PageGrant.GRANT_USER_GROUP] = { applicableGroups };
     }
 
     return data;
   }
 
+  /**
+   * see: https://dev.growi.org/635a314eac6bcd85cbf359fc
+   * @param {string} targetPath
+   * @param operator
+   * @param {UpdateGrantInfo} updateGrantInfo
+   * @returns {Promise<boolean>}
+   */
+  async canOverwriteDescendants(targetPath: string, operator: { _id: ObjectIdLike }, updateGrantInfo: UpdateGrantInfo): Promise<boolean> {
+    const UserGroupRelationModel = mongoose.model('UserGroupRelation') as any; // TODO: TypeScriptize model
+
+    const relatedGroupIds = await UserGroupRelationModel.findAllUserGroupIdsRelatedToUser(operator);
+    const operatorGrantInfo = {
+      userId: operator._id,
+      userGroupIds: new Set<ObjectIdLike>(relatedGroupIds),
+    };
+
+    const comparableDescendants = await this.generateComparableDescendants(targetPath, operator);
+
+    const grantSet = new Set<PageGrant>();
+    if (comparableDescendants.isPublicExist) {
+      grantSet.add(PageGrant.GRANT_PUBLIC);
+    }
+    if (comparableDescendants.grantedUserIds.length > 0) {
+      grantSet.add(PageGrant.GRANT_OWNER);
+    }
+    if (comparableDescendants.grantedGroupIds.length > 0) {
+      grantSet.add(PageGrant.GRANT_USER_GROUP);
+    }
+    const descendantPagesGrantInfo = {
+      grantSet,
+      grantedUserIds: new Set(comparableDescendants.grantedUserIds), // all only me users of descendant pages
+      grantedUserGroupIds: new Set(comparableDescendants.grantedGroupIds), // all user groups of descendant pages
+    };
+
+    return this.calcCanOverwriteDescendants(operatorGrantInfo, updateGrantInfo, descendantPagesGrantInfo);
+  }
+
+  async generateUpdateGrantInfoToOverwriteDescendants(operator, updateGrant: PageGrantCanBeOnTree, grantUserGroupId?: ObjectIdLike): Promise<UpdateGrantInfo> {
+    let updateGrantInfo: UpdateGrantInfo | null = null;
+
+    if (updateGrant === PageGrant.GRANT_PUBLIC) {
+      updateGrantInfo = {
+        grant: PageGrant.GRANT_PUBLIC,
+      };
+    }
+    else if (updateGrant === PageGrant.GRANT_OWNER) {
+      updateGrantInfo = {
+        grant: PageGrant.GRANT_OWNER,
+        grantedUserId: operator._id,
+      };
+    }
+    else if (updateGrant === PageGrant.GRANT_USER_GROUP) {
+      if (grantUserGroupId == null) {
+        throw Error('The parameter `grantUserGroupId` is required.');
+      }
+      const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
+      const userIds = await UserGroupRelation.findAllUserIdsForUserGroup(grantUserGroupId);
+      const childrenOrItselfGroups = await UserGroup.findGroupsWithDescendantsById(grantUserGroupId);
+      const childrenOrItselfGroupIds = childrenOrItselfGroups.map(d => d._id);
+
+      updateGrantInfo = {
+        grant: PageGrant.GRANT_USER_GROUP,
+        grantedUserGroupInfo: {
+          groupId: grantUserGroupId,
+          userIds: new Set<ObjectIdLike>(userIds),
+          childrenOrItselfGroupIds: new Set<ObjectIdLike>(childrenOrItselfGroupIds),
+        },
+      };
+    }
+
+    if (updateGrantInfo == null) {
+      throw Error('The parameter `updateGrant` must be 1, 4, or 5');
+    }
+
+    return updateGrantInfo;
+  }
+
+  private calcIsAllDescendantsGrantedByOperator(operatorGrantInfo: OperatorGrantInfo, descendantPagesGrantInfo: DescendantPagesGrantInfo): boolean {
+    if (descendantPagesGrantInfo.grantSet.has(PageGrant.GRANT_OWNER)) {
+      const isNonApplicableOwnerExist = descendantPagesGrantInfo.grantedUserIds.size >= 2
+        || !isIncludesObjectId([...descendantPagesGrantInfo.grantedUserIds], operatorGrantInfo.userId);
+      if (isNonApplicableOwnerExist) {
+        return false;
+      }
+    }
+
+    if (descendantPagesGrantInfo.grantSet.has(PageGrant.GRANT_USER_GROUP)) {
+      const isNonApplicableGroupExist = excludeTestIdsFromTargetIds(
+        [...descendantPagesGrantInfo.grantedUserGroupIds], [...operatorGrantInfo.userGroupIds],
+      ).length > 0;
+
+      if (isNonApplicableGroupExist) {
+        return false;
+      }
+    }
+
+    return true;
+  }
+
+  private calcCanOverwriteDescendants(
+      operatorGrantInfo: OperatorGrantInfo, updateGrantInfo: UpdateGrantInfo, descendantPagesGrantInfo: DescendantPagesGrantInfo,
+  ): boolean {
+    // 1. check is tree GRANTED and it returns true when GRANTED
+    //   - GRANTED is the tree with all pages granted by the operator
+    const isAllDescendantsGranted = this.calcIsAllDescendantsGrantedByOperator(operatorGrantInfo, descendantPagesGrantInfo);
+    if (isAllDescendantsGranted) {
+      return true;
+    }
+
+    // 2. if not 1. then,
+    //   - when update grant is PUBLIC, return true
+    if (updateGrantInfo.grant === PageGrant.GRANT_PUBLIC) {
+      return true;
+    }
+    //   - when update grant is ONLYME, return false
+    if (updateGrantInfo.grant === PageGrant.GRANT_OWNER) {
+      return false;
+    }
+    //   - when update grant is USER_GROUP, return true if meets 2 conditions below
+    //      a. if all descendants user groups are children or itself of update user group
+    //      b. if all descendants grantedUsers belong to update user group
+    if (updateGrantInfo.grant === PageGrant.GRANT_USER_GROUP) {
+      const isAllDescendantGroupsChildrenOrItselfOfUpdateGroup = excludeTestIdsFromTargetIds(
+        [...descendantPagesGrantInfo.grantedUserGroupIds], [...updateGrantInfo.grantedUserGroupInfo.childrenOrItselfGroupIds],
+      ).length === 0; // a.
+      const isUpdateGroupUsersIncludeAllDescendantsOwners = excludeTestIdsFromTargetIds(
+        [...descendantPagesGrantInfo.grantedUserIds], [...updateGrantInfo.grantedUserGroupInfo.userIds],
+      ).length === 0; // b.
+
+      return isAllDescendantGroupsChildrenOrItselfOfUpdateGroup && isUpdateGroupUsersIncludeAllDescendantsOwners;
+    }
+
+    return false;
+  }
+
 }
 
 export default PageGrantService;

+ 2 - 1
packages/app/src/server/service/page-operation.ts

@@ -16,6 +16,7 @@ const {
 const AUTO_UPDATE_INTERVAL_SEC = 5;
 
 const {
+  Create, Update,
   Duplicate, Delete, DeleteCompletely, Revert, NormalizeParent,
 } = PageActionType;
 
@@ -29,7 +30,7 @@ class PageOperationService {
 
   async init(): Promise<void> {
     // cleanup PageOperation documents except ones with { actionType: Rename, actionStage: Sub }
-    const types = [Duplicate, Delete, DeleteCompletely, Revert, NormalizeParent];
+    const types = [Create, Update, Duplicate, Delete, DeleteCompletely, Revert, NormalizeParent];
     await PageOperation.deleteByActionTypes(types);
     await PageOperation.deleteMany({ actionType: PageActionType.Rename, actionStage: PageActionStage.Main });
   }

+ 372 - 14
packages/app/src/server/service/page.ts

@@ -3,8 +3,8 @@ import { Readable, Writable } from 'stream';
 
 import {
   pagePathUtils, pathUtils, Ref, HasObjectId,
-  IUserHasId,
-  IPage, IPageInfo, IPageInfoAll, IPageInfoForEntity, IPageWithMeta,
+  IUserHasId, PageStatus,
+  IPage, IPageInfo, IPageInfoAll, IPageInfoForEntity, IPageWithMeta, PageGrant,
 } from '@growi/core';
 import escapeStringRegexp from 'escape-string-regexp';
 import mongoose, { ObjectId, QueryCursor } from 'mongoose';
@@ -28,6 +28,7 @@ import { prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { PathAlreadyExistsError } from '../models/errors';
+import { IOptionsForCreate, IOptionsForUpdate } from '../models/interfaces/page-operation';
 import PageOperation, { PageOperationDocument } from '../models/page-operation';
 import { PageRedirectModel } from '../models/page-redirect';
 import { serializePageSecurely } from '../models/serializers/page-serializer';
@@ -340,7 +341,7 @@ class PageService {
     const { PageQueryBuilder } = Page;
 
     const builder = new PageQueryBuilder(Page.find(), true)
-      .addConditionAsNotMigrated() // to avoid affecting v5 pages
+      .addConditionAsRootOrNotOnTree() // to avoid affecting v5 pages
       .addConditionToListOnlyDescendants(targetPagePath);
 
     await Page.addConditionToFilteringByViewerToEdit(builder, userToOperate);
@@ -2376,7 +2377,7 @@ class PageService {
 
     // This validation is not 100% correct since it ignores user to count
     const builder = new PageQueryBuilder(Page.find());
-    builder.addConditionAsNotMigrated();
+    builder.addConditionAsRootOrNotOnTree();
     builder.addConditionToListWithDescendants(path);
     const nEstimatedNormalizationTarget: number = await builder.query.exec('count');
     if (nEstimatedNormalizationTarget === 0) {
@@ -3406,6 +3407,21 @@ class PageService {
     pageDocument.status = Page.STATUS_PUBLISHED;
   }
 
+  private async validateAppliedScope(user, grant, grantUserGroupId) {
+    if (grant === PageGrant.GRANT_USER_GROUP && grantUserGroupId == null) {
+      throw new Error('grant userGroupId is not specified');
+    }
+
+    if (grant === PageGrant.GRANT_USER_GROUP) {
+      const UserGroupRelation = mongoose.model('UserGroupRelation') as any;
+      const count = await UserGroupRelation.countByGroupIdAndUser(grantUserGroupId, user);
+
+      if (count === 0) {
+        throw new Error('no relations were exist for group and user.');
+      }
+    }
+  }
+
   private async canProcessCreate(
       path: string,
       grantData: {
@@ -3415,6 +3431,7 @@ class PageService {
       },
       shouldValidateGrant: boolean,
       user?,
+      options?: Partial<PageCreateOptions>,
   ): Promise<boolean> {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
@@ -3443,7 +3460,7 @@ class PageService {
       try {
         // It must check descendants as well if emptyTarget is not null
         const isEmptyPageAlreadyExist = await Page.count({ path, isEmpty: true }) > 0;
-        const shouldCheckDescendants = isEmptyPageAlreadyExist;
+        const shouldCheckDescendants = isEmptyPageAlreadyExist && !options?.overwriteScopesOfDescendants;
 
         isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(user, path, grant, grantedUserIds, grantUserGroupId, shouldCheckDescendants);
       }
@@ -3454,18 +3471,31 @@ class PageService {
       if (!isGrantNormalized) {
         throw Error('The selected grant or grantedGroup is not assignable to this page.');
       }
+
+      if (options?.overwriteScopesOfDescendants) {
+        const updateGrantInfo = await this.crowi.pageGrantService.generateUpdateGrantInfoToOverwriteDescendants(user, grant, options.grantUserGroupId);
+        const canOverwriteDescendants = await this.crowi.pageGrantService.canOverwriteDescendants(path, user, updateGrantInfo);
+
+        if (!canOverwriteDescendants) {
+          throw Error('Cannot overwrite scopes of descendants.');
+        }
+      }
     }
 
     return true;
   }
 
-  async create(path: string, body: string, user, options: PageCreateOptions = {}): Promise<PageDocument> {
+  /**
+   * Create a page
+   * Set options.isSynchronously to true to await all process when you want to run this method multiple times at short intervals.
+   */
+  async create(path: string, body: string, user, options: IOptionsForCreate = {}): Promise<PageDocument> {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
     // Switch method
     const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
     if (!isV5Compatible) {
-      return Page.createV4(path, body, user, options);
+      return this.createV4(path, body, user, options);
     }
 
     // Values
@@ -3485,7 +3515,7 @@ class PageService {
 
     // Validate
     const shouldValidateGrant = !isGrantRestricted;
-    const canProcessCreate = await this.canProcessCreate(path, grantData, shouldValidateGrant, user);
+    const canProcessCreate = await this.canProcessCreate(path, grantData, shouldValidateGrant, user, options);
     if (!canProcessCreate) {
       throw Error('Cannnot process create');
     }
@@ -3517,23 +3547,113 @@ class PageService {
     savedPage = await pushRevision(savedPage, newRevision, user);
     await savedPage.populateDataToShowRevision();
 
-    // Update descendantCount
-    await this.updateDescendantCountOfAncestors(savedPage._id, 1, false);
-
     // Emit create event
     this.pageEvent.emit('create', savedPage, user);
 
-    // Delete PageRedirect if exists
+    // Directly run sub operation for now since it might be complex to handle main operation for creating pages -- Taichi Masuyama 2022.11.08
+    let pageOp;
+    try {
+      pageOp = await PageOperation.create({
+        actionType: PageActionType.Create,
+        actionStage: PageActionStage.Sub,
+        page: savedPage,
+        user,
+        fromPath: path,
+        options,
+      });
+    }
+    catch (err) {
+      logger.error('Failed to create PageOperation document.', err);
+      throw err;
+    }
+
+    if (options.isSynchronously) {
+      await this.createSubOperation(savedPage, user, options, pageOp._id);
+    }
+    else {
+      this.createSubOperation(savedPage, user, options, pageOp._id);
+    }
+
+    return savedPage;
+  }
+
+  /**
+   * Used to run sub operation in create method
+   */
+  async createSubOperation(page, user, options: IOptionsForCreate, pageOpId: ObjectIdLike): Promise<void> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
     const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
+
+    // Update descendantCount
+    await this.updateDescendantCountOfAncestors(page._id, 1, false);
+
+    // Delete PageRedirect if exists
     try {
-      await PageRedirect.deleteOne({ fromPath: path });
-      logger.warn(`Deleted page redirect after creating a new page at path "${path}".`);
+      await PageRedirect.deleteOne({ fromPath: page.path });
+      logger.warn(`Deleted page redirect after creating a new page at path "${page.path}".`);
     }
     catch (err) {
       // no throw
       logger.error('Failed to delete PageRedirect');
     }
 
+    // update scopes for descendants
+    if (options.overwriteScopesOfDescendants) {
+      await Page.applyScopesToDescendantsAsyncronously(page, user);
+    }
+
+    await PageOperation.findByIdAndDelete(pageOpId);
+  }
+
+  /**
+   * V4 compatible create method
+   */
+  private async createV4(path, body, user, options: any = {}) {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    const Revision = mongoose.model('Revision') as any; // TODO: TypeScriptize model
+
+    const format = options.format || 'markdown';
+    const grantUserGroupId = options.grantUserGroupId || null;
+    const expandContentWidth = this.crowi.configManager.getConfig('crowi', 'customize:isContainerFluid');
+
+    // sanitize path
+    path = this.crowi.xss.process(path); // eslint-disable-line no-param-reassign
+
+    let grant = options.grant;
+    // force public
+    if (isTopPage(path)) {
+      grant = PageGrant.GRANT_PUBLIC;
+    }
+
+    const isExist = await Page.count({ path });
+
+    if (isExist) {
+      throw new Error('Cannot create new page to existed path');
+    }
+
+    const page = new Page();
+    page.path = path;
+    page.creator = user;
+    page.lastUpdateUser = user;
+    page.status = PageStatus.STATUS_PUBLISHED;
+    if (expandContentWidth != null) {
+      page.expandContentWidth = expandContentWidth;
+    }
+    await this.validateAppliedScope(user, grant, grantUserGroupId);
+    page.applyScope(user, grant, grantUserGroupId);
+
+    let savedPage = await page.save();
+    const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
+    savedPage = await pushRevision(savedPage, newRevision, user);
+    await savedPage.populateDataToShowRevision();
+
+    this.pageEvent.emit('create', savedPage, user);
+
+    // update scopes for descendants
+    if (options.overwriteScopesOfDescendants) {
+      Page.applyScopesToDescendantsAsyncronously(savedPage, user, true);
+    }
+
     return savedPage;
   }
 
@@ -3628,6 +3748,244 @@ class PageService {
     return savedPage;
   }
 
+  private shouldUseUpdatePageV4(grant: number, isV5Compatible: boolean, isOnTree: boolean): boolean {
+    const isRestricted = grant === PageGrant.GRANT_RESTRICTED;
+    return !isRestricted && (!isV5Compatible || !isOnTree);
+  }
+
+  /**
+   * A wrapper method of updatePage for updating grant only.
+   * @param {PageDocument} page
+   * @param {UserDocument} user
+   * @param options
+   */
+  async updateGrant(page, user, grantData: {grant: PageGrant, grantedGroup: ObjectIdLike}): Promise<PageDocument> {
+    const { grant, grantedGroup } = grantData;
+
+    const options = {
+      grant,
+      grantUserGroupId: grantedGroup,
+      isSyncRevisionToHackmd: false,
+    };
+
+    return this.updatePage(page, null, null, user, options);
+  }
+
+  async updatePageSubOperation(page, user, exPage, options: IOptionsForUpdate, pageOpId: ObjectIdLike): Promise<void> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    const currentPage = page;
+
+    const exParent = exPage.parent;
+    const wasOnTree = exPage.parent != null || isTopPage(exPage.path);
+    const shouldBeOnTree = currentPage.grant !== PageGrant.GRANT_RESTRICTED;
+    const isChildrenExist = await Page.count({ path: new RegExp(`^${escapeStringRegexp(addTrailingSlash(currentPage.path))}`), parent: { $ne: null } });
+
+    // 1. Update descendantCount
+    const shouldPlusDescCount = !wasOnTree && shouldBeOnTree;
+    const shouldMinusDescCount = wasOnTree && !shouldBeOnTree;
+    if (shouldPlusDescCount) {
+      await this.updateDescendantCountOfAncestors(currentPage._id, 1, false);
+      const newDescendantCount = await Page.recountDescendantCount(currentPage._id);
+      await Page.updateOne({ _id: currentPage._id }, { descendantCount: newDescendantCount });
+    }
+    else if (shouldMinusDescCount) {
+      // Update from parent. Parent is null if currentPage.grant is RESTRECTED.
+      if (currentPage.grant === PageGrant.GRANT_RESTRICTED) {
+        await this.updateDescendantCountOfAncestors(exParent, -1, true);
+      }
+    }
+
+    // 2. Delete unnecessary empty pages
+    const shouldRemoveLeafEmpPages = wasOnTree && !isChildrenExist;
+    if (shouldRemoveLeafEmpPages) {
+      await Page.removeLeafEmptyPagesRecursively(exParent);
+    }
+
+    // 3. Update scopes for descendants
+    if (options.overwriteScopesOfDescendants) {
+      await Page.applyScopesToDescendantsAsyncronously(currentPage, user);
+    }
+
+    await PageOperation.findByIdAndDelete(pageOpId);
+  }
+
+  async updatePage(
+      pageData,
+      body: string | null,
+      previousBody: string | null,
+      user,
+      options: IOptionsForUpdate = {},
+  ): Promise<PageDocument> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
+
+    const wasOnTree = pageData.parent != null || isTopPage(pageData.path);
+    const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
+
+    const shouldUseV4Process = this.shouldUseUpdatePageV4(pageData.grant, isV5Compatible, wasOnTree);
+    if (shouldUseV4Process) {
+      // v4 compatible process
+      return this.updatePageV4(pageData, body, previousBody, user, options);
+    }
+
+    // Clone page document
+    const clonedPageData = Page.hydrate(pageData.toObject());
+    const newPageData = pageData;
+
+    const grant = options.grant ?? clonedPageData.grant; // use the previous data if absence
+    const grantUserGroupId: undefined | ObjectIdLike = options.grantUserGroupId ?? clonedPageData.grantedGroup?._id.toString();
+
+    const grantedUserIds = clonedPageData.grantedUserIds || [user._id];
+    const shouldBeOnTree = grant !== PageGrant.GRANT_RESTRICTED;
+    const isChildrenExist = await Page.count({ path: new RegExp(`^${escapeStringRegexp(addTrailingSlash(clonedPageData.path))}`), parent: { $ne: null } });
+
+    const { pageService, pageGrantService } = this.crowi;
+
+    if (shouldBeOnTree) {
+      let isGrantNormalized = false;
+      try {
+        const shouldCheckDescendants = !options.overwriteScopesOfDescendants;
+        // eslint-disable-next-line max-len
+        isGrantNormalized = await pageGrantService.isGrantNormalized(user, clonedPageData.path, grant, grantedUserIds, grantUserGroupId, shouldCheckDescendants);
+      }
+      catch (err) {
+        logger.error(`Failed to validate grant of page at "${clonedPageData.path}" of grant ${grant}:`, err);
+        throw err;
+      }
+      if (!isGrantNormalized) {
+        throw Error('The selected grant or grantedGroup is not assignable to this page.');
+      }
+
+      if (options.overwriteScopesOfDescendants) {
+        const updateGrantInfo = await pageGrantService.generateUpdateGrantInfoToOverwriteDescendants(user, grant, options.grantUserGroupId);
+        const canOverwriteDescendants = await pageGrantService.canOverwriteDescendants(clonedPageData.path, user, updateGrantInfo);
+
+        if (!canOverwriteDescendants) {
+          throw Error('Cannot overwrite scopes of descendants.');
+        }
+      }
+
+      if (!wasOnTree) {
+        const newParent = await pageService.getParentAndFillAncestorsByUser(user, newPageData.path);
+        newPageData.parent = newParent._id;
+      }
+    }
+    else {
+      if (wasOnTree && isChildrenExist) {
+        // Update children's parent with new parent
+        const newParentForChildren = await Page.createEmptyPage(clonedPageData.path, clonedPageData.parent, clonedPageData.descendantCount);
+        await Page.updateMany(
+          { parent: clonedPageData._id },
+          { parent: newParentForChildren._id },
+        );
+      }
+
+      newPageData.parent = null;
+      newPageData.descendantCount = 0;
+    }
+
+    newPageData.applyScope(user, grant, grantUserGroupId);
+
+    // update existing page
+    let savedPage = await newPageData.save();
+
+    // Update body
+    const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
+    const isBodyPresent = body != null && previousBody != null;
+    const shouldUpdateBody = isBodyPresent;
+    if (shouldUpdateBody) {
+      const newRevision = await Revision.prepareRevision(newPageData, body, previousBody, user);
+      savedPage = await pushRevision(savedPage, newRevision, user);
+      await savedPage.populateDataToShowRevision();
+
+      if (isSyncRevisionToHackmd) {
+        savedPage = await Page.syncRevisionToHackmd(savedPage);
+      }
+    }
+
+
+    this.pageEvent.emit('update', savedPage, user);
+
+    // Update ex children's parent
+    if (!wasOnTree && shouldBeOnTree) {
+      const emptyPageAtSamePath = await Page.findOne({ path: clonedPageData.path, isEmpty: true }); // this page is necessary to find children
+
+      if (isChildrenExist) {
+        if (emptyPageAtSamePath != null) {
+          // Update children's parent with new parent
+          await Page.updateMany(
+            { parent: emptyPageAtSamePath._id },
+            { parent: savedPage._id },
+          );
+        }
+      }
+
+      await Page.findOneAndDelete({ path: clonedPageData.path, isEmpty: true }); // delete here
+    }
+
+    // Directly run sub operation for now since it might be complex to handle main operation for updating pages -- Taichi Masuyama 2022.11.08
+    let pageOp;
+    try {
+      pageOp = await PageOperation.create({
+        actionType: PageActionType.Update,
+        actionStage: PageActionStage.Sub,
+        page: savedPage,
+        exPage: clonedPageData,
+        user,
+        fromPath: clonedPageData.path,
+        options,
+      });
+    }
+    catch (err) {
+      logger.error('Failed to create PageOperation document.', err);
+      throw err;
+    }
+
+    this.updatePageSubOperation(savedPage, user, clonedPageData, options, pageOp._id);
+
+    return savedPage;
+  }
+
+
+  async updatePageV4(pageData, body, previousBody, user, options: any = {}): Promise<PageDocument> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    const Revision = mongoose.model('Revision') as any; // TODO: TypeScriptize model
+
+    const grant = options.grant || pageData.grant; // use the previous data if absence
+    const grantUserGroupId = options.grantUserGroupId || pageData.grantUserGroupId; // use the previous data if absence
+    const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
+
+    await this.validateAppliedScope(user, grant, grantUserGroupId);
+    pageData.applyScope(user, grant, grantUserGroupId);
+
+    // update existing page
+    let savedPage = await pageData.save();
+
+    // Update revision
+    const isBodyPresent = body != null && previousBody != null;
+    const shouldUpdateBody = isBodyPresent;
+    if (shouldUpdateBody) {
+      const newRevision = await Revision.prepareRevision(pageData, body, previousBody, user);
+      savedPage = await pushRevision(savedPage, newRevision, user);
+      await savedPage.populateDataToShowRevision();
+
+      if (isSyncRevisionToHackmd) {
+        savedPage = await Page.syncRevisionToHackmd(savedPage);
+      }
+    }
+
+    // update scopes for descendants
+    if (options.overwriteScopesOfDescendants) {
+      Page.applyScopesToDescendantsAsyncronously(savedPage, user, true);
+    }
+
+
+    this.pageEvent.emit('update', savedPage, user);
+
+    return savedPage;
+  }
+
   /*
    * Find all children by parent's path or id. Using id should be prioritized
    */

+ 2 - 1
packages/app/src/stores/page.tsx

@@ -47,7 +47,8 @@ export const useSWRxPage = (
     if (initialData !== undefined) {
       swrResponse.mutate(initialData);
     }
-  }, [initialData, swrResponse]);
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [initialData]); // Only depends on `initialData`
 
   return swrResponse;
 };

+ 554 - 68
packages/app/test/integration/models/v5.page.test.js

@@ -1,10 +1,14 @@
 import mongoose from 'mongoose';
 
+import { PageGrant } from '~/interfaces/page';
 
 import { getInstance } from '../setup-crowi';
 
 describe('Page', () => {
   let crowi;
+  let pageGrantService;
+  let pageService;
+
   let Page;
   let Revision;
   let User;
@@ -22,13 +26,313 @@ describe('Page', () => {
   let pModelUser1;
   let pModelUser2;
   let pModelUser3;
-  let groupIdIsolate;
-  let groupIdA;
-  let groupIdB;
-  let groupIdC;
+  let userGroupIdPModelIsolate;
+  let userGroupIdPModelA;
+  let userGroupIdPModelB;
+  let userGroupIdPModelC;
+
+  // To test updatePage overwriting descendants (prefix `upod`)
+  let upodUserA;
+  let upodUserB;
+  let upodUserC;
+  let upodGroupAB;
+  let upodGroupA;
+  let upodGroupAIsolated;
+  let upodGroupB;
+  let upodGroupC;
+  const upodUserGroupIdA = new mongoose.Types.ObjectId();
+  const upodUserGroupIdAIsolated = new mongoose.Types.ObjectId();
+  const upodUserGroupIdB = new mongoose.Types.ObjectId();
+  const upodUserGroupIdC = new mongoose.Types.ObjectId();
+  const upodUserGroupIdAB = new mongoose.Types.ObjectId();
+  const upodPageIdgAB1 = new mongoose.Types.ObjectId();
+  const upodPageIdPublic2 = new mongoose.Types.ObjectId();
+  const upodPageIdPublic3 = new mongoose.Types.ObjectId();
+  const upodPageIdPublic4 = new mongoose.Types.ObjectId();
+  const upodPageIdPublic5 = new mongoose.Types.ObjectId();
+  const upodPageIdPublic6 = new mongoose.Types.ObjectId();
+
+  const updatePage = async(page, newRevisionBody, oldRevisionBody, user, options = {}) => {
+    const mockedUpdatePageSubOperation = jest.spyOn(pageService, 'updatePageSubOperation').mockReturnValue(null);
+
+    const savedPage = await pageService.updatePage(page, newRevisionBody, oldRevisionBody, user, options);
+
+    const argsForUpdatePageSubOperation = mockedUpdatePageSubOperation.mock.calls[0];
+
+    mockedUpdatePageSubOperation.mockRestore();
+
+    await pageService.updatePageSubOperation(...argsForUpdatePageSubOperation);
+
+    return savedPage;
+  };
+
+  const createDocumentsToTestUpdatePageOverwritingDescendants = async() => {
+    // Users
+    await User.insertMany([
+      { name: 'upodUserA', username: 'upodUserA', email: 'upoduserA@example.com' },
+      { name: 'upodUserB', username: 'upodUserB', email: 'upoduserB@example.com' },
+      { name: 'upodUserC', username: 'upodUserC', email: 'upodUserC@example.com' },
+    ]);
+
+    upodUserA = await User.findOne({ username: 'upodUserA' });
+    upodUserB = await User.findOne({ username: 'upodUserB' });
+    upodUserC = await User.findOne({ username: 'upodUserC' });
+
+    await UserGroup.insertMany([
+      {
+        _id: upodUserGroupIdAB,
+        name: 'upodGroupAB',
+        parent: null,
+      },
+      {
+        _id: upodUserGroupIdA,
+        name: 'upodGroupA',
+        parent: upodUserGroupIdAB,
+      },
+      {
+        _id: upodUserGroupIdAIsolated,
+        name: 'upodGroupAIsolated',
+        parent: null,
+      },
+      {
+        _id: upodUserGroupIdB,
+        name: 'upodGroupB',
+        parent: upodUserGroupIdAB,
+      },
+      {
+        _id: upodUserGroupIdC,
+        name: 'upodGroupC',
+        parent: null,
+      },
+    ]);
+
+    upodGroupAB = await UserGroup.findOne({ name: 'upodGroupAB' });
+    upodGroupA = await UserGroup.findOne({ name: 'upodGroupA' });
+    upodGroupAIsolated = await UserGroup.findOne({ name: 'upodGroupAIsolated' });
+    upodGroupB = await UserGroup.findOne({ name: 'upodGroupB' });
+    upodGroupC = await UserGroup.findOne({ name: 'upodGroupC' });
+
+    // UserGroupRelations
+    await UserGroupRelation.insertMany([
+      {
+        relatedGroup: upodUserGroupIdAB,
+        relatedUser: upodUserA._id,
+      },
+      {
+        relatedGroup: upodUserGroupIdAB,
+        relatedUser: upodUserB._id,
+      },
+      {
+        relatedGroup: upodUserGroupIdA,
+        relatedUser: upodUserA._id,
+      },
+      {
+        relatedGroup: upodUserGroupIdAIsolated,
+        relatedUser: upodUserA._id,
+      },
+      {
+        relatedGroup: upodUserGroupIdB,
+        relatedUser: upodUserB._id,
+      },
+      {
+        relatedGroup: upodUserGroupIdC,
+        relatedUser: upodUserC._id,
+      },
+    ]);
+
+    // Pages
+    await Page.insertMany([
+      // case 1
+      {
+        _id: upodPageIdgAB1,
+        path: '/gAB_upod_1', // to GRANT_PUBLIC
+        grant: PageGrant.GRANT_USER_GROUP,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroup: upodUserGroupIdAB,
+        parent: rootPage._id,
+      },
+      {
+        path: '/gAB_upod_1/gB_upod_1',
+        grant: PageGrant.GRANT_USER_GROUP,
+        creator: upodUserB,
+        lastUpdateUser: upodUserB,
+        grantedUsers: null,
+        grantedGroup: upodUserGroupIdB,
+        parent: upodPageIdgAB1,
+      },
+      {
+        path: '/gAB_upod_1/onlyB_upod_1',
+        grant: PageGrant.GRANT_OWNER,
+        creator: upodUserB,
+        lastUpdateUser: upodUserB,
+        grantedUsers: [upodUserB._id],
+        grantedGroup: null,
+        parent: upodPageIdgAB1,
+      },
+      // case 2
+      {
+        _id: upodPageIdPublic2,
+        path: '/public_upod_2', // to Anything
+        grant: PageGrant.GRANT_PUBLIC,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroup: null,
+        parent: rootPage._id,
+      },
+      {
+        path: '/public_upod_2/gA_upod_2',
+        grant: PageGrant.GRANT_USER_GROUP,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroup: upodUserGroupIdA,
+        parent: upodPageIdPublic2,
+      },
+      {
+        path: '/public_upod_2/gAIsolated_upod_2',
+        grant: PageGrant.GRANT_USER_GROUP,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroup: upodUserGroupIdAIsolated,
+        parent: upodPageIdPublic2,
+      },
+      {
+        path: '/public_upod_2/onlyA_upod_2',
+        grant: PageGrant.GRANT_OWNER,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: [upodUserA._id],
+        grantedGroup: null,
+        parent: upodPageIdPublic2,
+      },
+      // case 3
+      {
+        _id: upodPageIdPublic3,
+        path: '/public_upod_3', // to GRANT_USER_GROUP with upodGroupAB
+        grant: PageGrant.GRANT_PUBLIC,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroup: null,
+        parent: rootPage._id,
+      },
+      {
+        path: '/public_upod_3/gAB_upod_3',
+        grant: PageGrant.GRANT_USER_GROUP,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroup: upodUserGroupIdAB,
+        parent: upodPageIdPublic3,
+      },
+      {
+        path: '/public_upod_3/gB_upod_3',
+        grant: PageGrant.GRANT_USER_GROUP,
+        creator: upodUserB,
+        lastUpdateUser: upodUserB,
+        grantedUsers: null,
+        grantedGroup: upodUserGroupIdB,
+        parent: upodPageIdPublic3,
+      },
+      {
+        path: '/public_upod_3/onlyB_upod_3',
+        grant: PageGrant.GRANT_OWNER,
+        creator: upodUserB,
+        lastUpdateUser: upodUserB,
+        grantedUsers: [upodUserB._id],
+        grantedGroup: null,
+        parent: upodPageIdPublic3,
+      },
+      // case 4
+      {
+        _id: upodPageIdPublic4,
+        path: '/public_upod_4', // to GRANT_USER_GROUP with upodGroupAB
+        grant: PageGrant.GRANT_PUBLIC,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroup: null,
+        parent: rootPage._id,
+      },
+      {
+        path: '/public_upod_4/gA_upod_4',
+        grant: PageGrant.GRANT_USER_GROUP,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroup: upodUserGroupIdA,
+        parent: upodPageIdPublic4,
+      },
+      {
+        path: '/public_upod_4/gC_upod_4',
+        grant: PageGrant.GRANT_USER_GROUP,
+        creator: upodUserC,
+        lastUpdateUser: upodUserC,
+        grantedUsers: null,
+        grantedGroup: upodUserGroupIdC,
+        parent: upodPageIdPublic4,
+      },
+      // case 5
+      {
+        _id: upodPageIdPublic5,
+        path: '/public_upod_5', // to GRANT_USER_GROUP with upodGroupAB
+        grant: PageGrant.GRANT_PUBLIC,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroup: null,
+        parent: rootPage._id,
+      },
+      {
+        path: '/public_upod_5/gA_upod_5',
+        grant: PageGrant.GRANT_USER_GROUP,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroup: upodUserGroupIdA,
+        parent: upodPageIdPublic5,
+      },
+      {
+        path: '/public_upod_5/onlyC_upod_5',
+        grant: PageGrant.GRANT_OWNER,
+        creator: upodUserC,
+        lastUpdateUser: upodUserC,
+        grantedUsers: [upodUserC._id],
+        grantedGroup: null,
+        parent: upodPageIdPublic5,
+      },
+      // case 6
+      {
+        _id: upodPageIdPublic6,
+        path: '/public_upod_6', // to GRANT_USER_GROUP with upodGroupAB
+        grant: PageGrant.GRANT_PUBLIC,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroup: null,
+        parent: rootPage._id,
+      },
+      {
+        path: '/public_upod_6/onlyC_upod_6',
+        grant: PageGrant.GRANT_OWNER,
+        creator: upodUserC,
+        lastUpdateUser: upodUserC,
+        grantedUsers: [upodUserC._id],
+        grantedGroup: null,
+        parent: upodPageIdPublic6,
+      },
+    ]);
+  };
 
   beforeAll(async() => {
     crowi = await getInstance();
+    pageGrantService = crowi.pageGrantService;
+    pageService = crowi.pageService;
+
     await crowi.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': true });
 
     jest.restoreAllMocks();
@@ -75,69 +379,69 @@ describe('Page', () => {
     pModelUser3 = await User.findOne({ _id: pModelUserId3 });
 
 
-    groupIdIsolate = new mongoose.Types.ObjectId();
-    groupIdA = new mongoose.Types.ObjectId();
-    groupIdB = new mongoose.Types.ObjectId();
-    groupIdC = new mongoose.Types.ObjectId();
+    userGroupIdPModelIsolate = new mongoose.Types.ObjectId();
+    userGroupIdPModelA = new mongoose.Types.ObjectId();
+    userGroupIdPModelB = new mongoose.Types.ObjectId();
+    userGroupIdPModelC = new mongoose.Types.ObjectId();
     await UserGroup.insertMany([
       {
-        _id: groupIdIsolate,
+        _id: userGroupIdPModelIsolate,
         name: 'pModel_groupIsolate',
       },
       {
-        _id: groupIdA,
+        _id: userGroupIdPModelA,
         name: 'pModel_groupA',
       },
       {
-        _id: groupIdB,
+        _id: userGroupIdPModelB,
         name: 'pModel_groupB',
-        parent: groupIdA,
+        parent: userGroupIdPModelA,
       },
       {
-        _id: groupIdC,
+        _id: userGroupIdPModelC,
         name: 'pModel_groupC',
-        parent: groupIdB,
+        parent: userGroupIdPModelB,
       },
     ]);
 
     await UserGroupRelation.insertMany([
       {
-        relatedGroup: groupIdIsolate,
+        relatedGroup: userGroupIdPModelIsolate,
         relatedUser: pModelUserId1,
         createdAt: new Date(),
       },
       {
-        relatedGroup: groupIdIsolate,
+        relatedGroup: userGroupIdPModelIsolate,
         relatedUser: pModelUserId2,
         createdAt: new Date(),
       },
       {
-        relatedGroup: groupIdA,
+        relatedGroup: userGroupIdPModelA,
         relatedUser: pModelUserId1,
         createdAt: new Date(),
       },
       {
-        relatedGroup: groupIdA,
+        relatedGroup: userGroupIdPModelA,
         relatedUser: pModelUserId2,
         createdAt: new Date(),
       },
       {
-        relatedGroup: groupIdA,
+        relatedGroup: userGroupIdPModelA,
         relatedUser: pModelUserId3,
         createdAt: new Date(),
       },
       {
-        relatedGroup: groupIdB,
+        relatedGroup: userGroupIdPModelB,
         relatedUser: pModelUserId2,
         createdAt: new Date(),
       },
       {
-        relatedGroup: groupIdB,
+        relatedGroup: userGroupIdPModelB,
         relatedUser: pModelUserId3,
         createdAt: new Date(),
       },
       {
-        relatedGroup: groupIdC,
+        relatedGroup: userGroupIdPModelC,
         relatedUser: pModelUserId3,
         createdAt: new Date(),
       },
@@ -307,7 +611,7 @@ describe('Page', () => {
       {
         path: '/mup20',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdA,
+        grantedGroup: userGroupIdPModelA,
         creator: pModelUserId1,
         lastUpdateUser: pModelUserId1,
         isEmpty: false,
@@ -335,7 +639,7 @@ describe('Page', () => {
       {
         path: '/mup22/mup23',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdA,
+        grantedGroup: userGroupIdPModelA,
         creator: pModelUserId1,
         lastUpdateUser: pModelUserId1,
         isEmpty: false,
@@ -393,7 +697,7 @@ describe('Page', () => {
         _id: pageIdUpd16,
         path: '/mup29_A',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdA,
+        grantedGroup: userGroupIdPModelA,
         creator: pModelUserId1,
         lastUpdateUser: pModelUserId1,
         isEmpty: false,
@@ -414,7 +718,7 @@ describe('Page', () => {
         _id: pageIdUpd17,
         path: '/mup31_A',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdA,
+        grantedGroup: userGroupIdPModelA,
         creator: pModelUserId1,
         lastUpdateUser: pModelUserId1,
         isEmpty: false,
@@ -435,7 +739,7 @@ describe('Page', () => {
         _id: pageIdUpd18,
         path: '/mup33_C',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdC,
+        grantedGroup: userGroupIdPModelC,
         creator: pModelUserId3,
         lastUpdateUser: pModelUserId3,
         isEmpty: false,
@@ -485,17 +789,10 @@ describe('Page', () => {
       },
     ]);
 
+    await createDocumentsToTestUpdatePageOverwritingDescendants();
   });
 
   describe('update', () => {
-
-    const updatePage = async(page, newRevisionBody, oldRevisionBody, user, options = {}) => {
-      const mockedRenameSubOperation = jest.spyOn(Page, 'emitPageEventUpdate').mockReturnValue(null);
-      const savedPage = await Page.updatePage(page, newRevisionBody, oldRevisionBody, user, options);
-      mockedRenameSubOperation.mockRestore();
-      return savedPage;
-    };
-
     describe('Changing grant from PUBLIC to RESTRICTED of', () => {
       test('an only-child page will delete its empty parent page', async() => {
         const pathT = '/mup13_top';
@@ -509,7 +806,7 @@ describe('Page', () => {
         expect(page2).toBeTruthy();
 
         const options = { grant: Page.GRANT_RESTRICTED, grantUserGroupId: null };
-        await Page.updatePage(page2, 'newRevisionBody', 'oldRevisionBody', dummyUser1, options);
+        await updatePage(page2, 'newRevisionBody', 'oldRevisionBody', dummyUser1, options);
 
         const _pageT = await Page.findOne({ path: pathT });
         const _page1 = await Page.findOne({ path: path1 });
@@ -530,7 +827,7 @@ describe('Page', () => {
         expect(page1).toBeTruthy();
         expect(page2).toBeTruthy();
 
-        await Page.updatePage(page1, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_RESTRICTED });
+        await updatePage(page1, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_RESTRICTED });
 
         const _top = await Page.findOne({ path: pathT });
         const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED });
@@ -557,7 +854,7 @@ describe('Page', () => {
         expect(page1).toBeTruthy();
         expect(count).toBe(1);
 
-        await Page.updatePage(page1, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_RESTRICTED });
+        await updatePage(page1, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_RESTRICTED });
 
         const _pageT = await Page.findOne({ path: pathT });
         const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED });
@@ -599,7 +896,7 @@ describe('Page', () => {
         expect(page1).toBeNull();
         expect(page2).toBeNull();
 
-        await Page.updatePage(page3, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_PUBLIC });
+        await updatePage(page3, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_PUBLIC });
 
         const _pageT = await Page.findOne({ path: pathT });
         const _page1 = await Page.findOne({ path: path1, isEmpty: true });
@@ -626,7 +923,7 @@ describe('Page', () => {
         expect(page2).toBeTruthy();
         expect(page3).toBeTruthy();
 
-        await Page.updatePage(page2, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_PUBLIC });
+        await updatePage(page2, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_PUBLIC });
 
         const _pageT = await Page.findOne({ path: pathT });
         const _page1 = await Page.findOne({ path: path1, isEmpty: true }); // should be replaced
@@ -658,7 +955,7 @@ describe('Page', () => {
       });
       test('successfully change to GRANT_OWNER from GRANT_USER_GROUP', async() => {
         const path = '/mup20';
-        const _page = await Page.findOne({ path, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA });
+        const _page = await Page.findOne({ path, grant: Page.GRANT_USER_GROUP, grantedGroup: userGroupIdPModelA });
         expect(_page).toBeTruthy();
 
         await updatePage(_page, 'newRevisionBody', 'oldRevisionBody', pModelUser1, { grant: Page.GRANT_OWNER });
@@ -683,7 +980,7 @@ describe('Page', () => {
         const path1 = '/mup22';
         const path2 = '/mup22/mup23';
         const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC });
-        const _page2 = await Page.findOne({ path: path2, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA });
+        const _page2 = await Page.findOne({ path: path2, grant: Page.GRANT_USER_GROUP, grantedGroup: userGroupIdPModelA });
         expect(_page1).toBeTruthy();
         expect(_page2).toBeTruthy();
 
@@ -708,8 +1005,8 @@ describe('Page', () => {
           expect(_page1).toBeTruthy();
           expect(_page2).toBeTruthy();
 
-          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdA };
-          const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser1, options); // from GRANT_PUBLIC to GRANT_USER_GROUP(groupIdA)
+          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: userGroupIdPModelA };
+          const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser1, options); // from GRANT_PUBLIC to GRANT_USER_GROUP(userGroupIdPModelA)
 
           const page1 = await Page.findById(_page1._id);
           const page2 = await Page.findById(_page2._id);
@@ -720,7 +1017,7 @@ describe('Page', () => {
 
           // check page2 grant and group
           expect(page2.grant).toBe(Page.GRANT_USER_GROUP);
-          expect(page2.grantedGroup._id).toStrictEqual(groupIdA);
+          expect(page2.grantedGroup._id).toStrictEqual(userGroupIdPModelA);
         });
 
         test('successfully change to GRANT_USER_GROUP from GRANT_RESTRICTED if parent page is GRANT_PUBLIC', async() => {
@@ -730,8 +1027,8 @@ describe('Page', () => {
           const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_RESTRICTED });
           expect(_page1).toBeTruthy();
 
-          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdA };
-          const updatedPage = await updatePage(_page1, 'new', 'old', pModelUser1, options); // from GRANT_RESTRICTED to GRANT_USER_GROUP(groupIdA)
+          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: userGroupIdPModelA };
+          const updatedPage = await updatePage(_page1, 'new', 'old', pModelUser1, options); // from GRANT_RESTRICTED to GRANT_USER_GROUP(userGroupIdPModelA)
 
           const page1 = await Page.findById(_page1._id);
           expect(page1).toBeTruthy();
@@ -740,7 +1037,7 @@ describe('Page', () => {
 
           // updated page
           expect(page1.grant).toBe(Page.GRANT_USER_GROUP);
-          expect(page1.grantedGroup._id).toStrictEqual(groupIdA);
+          expect(page1.grantedGroup._id).toStrictEqual(userGroupIdPModelA);
 
           // parent's grant check
           const parent = await Page.findById(page1.parent);
@@ -760,8 +1057,8 @@ describe('Page', () => {
           expect(_page1).toBeTruthy();
           expect(_page2).toBeTruthy();
 
-          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdA };
-          const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser1, options); // from GRANT_OWNER to GRANT_USER_GROUP(groupIdA)
+          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: userGroupIdPModelA };
+          const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser1, options); // from GRANT_OWNER to GRANT_USER_GROUP(userGroupIdPModelA)
 
           const page1 = await Page.findById(_page1._id);
           const page2 = await Page.findById(_page2._id);
@@ -772,7 +1069,7 @@ describe('Page', () => {
 
           // grant check
           expect(page2.grant).toBe(Page.GRANT_USER_GROUP);
-          expect(page2.grantedGroup._id).toStrictEqual(groupIdA);
+          expect(page2.grantedGroup._id).toStrictEqual(userGroupIdPModelA);
           expect(page2.grantedUsers.length).toBe(0);
         });
       });
@@ -782,18 +1079,18 @@ describe('Page', () => {
           const _path1 = '/mup29_A';
           const _path2 = '/mup29_A/mup30_owner';
           // page
-          const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA }); // out of update scope
+          const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: userGroupIdPModelA }); // out of update scope
           const _page2 = await Page.findOne({ // update target
             path: _path2, grant: Page.GRANT_OWNER, grantedUsers: [pModelUser1], parent: _page1._id,
           });
           expect(_page1).toBeTruthy();
           expect(_page2).toBeTruthy();
 
-          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdB };
+          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: userGroupIdPModelB };
 
           // First round
-          // Group relation(parent -> child): groupIdA -> groupIdB -> groupIdC
-          const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser3, options); // from GRANT_OWNER to GRANT_USER_GROUP(groupIdB)
+          // Group relation(parent -> child): userGroupIdPModelA -> userGroupIdPModelB -> userGroupIdPModelC
+          const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser3, options); // from GRANT_OWNER to GRANT_USER_GROUP(userGroupIdPModelB)
 
           const page1 = await Page.findById(_page1._id);
           const page2 = await Page.findById(_page2._id);
@@ -803,24 +1100,24 @@ describe('Page', () => {
           expect(updatedPage._id).toStrictEqual(page2._id);
 
           expect(page2.grant).toBe(Page.GRANT_USER_GROUP);
-          expect(page2.grantedGroup._id).toStrictEqual(groupIdB);
+          expect(page2.grantedGroup._id).toStrictEqual(userGroupIdPModelB);
           expect(page2.grantedUsers.length).toBe(0);
 
           // Second round
           // Update group to groupC which is a grandchild from pageA's point of view
-          const secondRoundOptions = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdC }; // from GRANT_USER_GROUP(groupIdB) to GRANT_USER_GROUP(groupIdC)
+          const secondRoundOptions = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: userGroupIdPModelC }; // from GRANT_USER_GROUP(userGroupIdPModelB) to GRANT_USER_GROUP(userGroupIdPModelC)
           const secondRoundUpdatedPage = await updatePage(_page2, 'new', 'new', pModelUser3, secondRoundOptions);
 
           expect(secondRoundUpdatedPage).toBeTruthy();
           expect(secondRoundUpdatedPage.grant).toBe(Page.GRANT_USER_GROUP);
-          expect(secondRoundUpdatedPage.grantedGroup._id).toStrictEqual(groupIdC);
+          expect(secondRoundUpdatedPage.grantedGroup._id).toStrictEqual(userGroupIdPModelC);
         });
         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() => {
           // path
           const _path1 = '/mup31_A';
           const _path2 = '/mup31_A/mup32_owner';
           // page
-          const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA });
+          const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: userGroupIdPModelA });
           const _page2 = await Page.findOne({ // update target
             path: _path2, grant: Page.GRANT_OWNER, grantedUsers: [pModelUser1._id], parent: _page1._id,
           });
@@ -828,13 +1125,13 @@ describe('Page', () => {
           expect(_page2).toBeTruthy();
 
           // group
-          const _groupIsolated = await UserGroup.findById(groupIdIsolate);
+          const _groupIsolated = await UserGroup.findById(userGroupIdPModelIsolate);
           expect(_groupIsolated).toBeTruthy();
           // group parent check
           expect(_groupIsolated.parent).toBeUndefined(); // should have no parent
 
-          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdIsolate };
-          await expect(updatePage(_page2, 'new', 'old', pModelUser1, options)) // from GRANT_OWNER to GRANT_USER_GROUP(groupIdIsolate)
+          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: userGroupIdPModelIsolate };
+          await expect(updatePage(_page2, 'new', 'old', pModelUser1, options)) // from GRANT_OWNER to GRANT_USER_GROUP(userGroupIdPModelIsolate)
             .rejects.toThrow(new Error('The selected grant or grantedGroup is not assignable to this page.'));
 
           const page1 = await Page.findById(_page1._id);
@@ -851,18 +1148,18 @@ describe('Page', () => {
           const _path1 = '/mup33_C';
           const _path2 = '/mup33_C/mup34_owner';
           // page
-          const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdC }); // groupC
+          const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: userGroupIdPModelC }); // groupC
           const _page2 = await Page.findOne({ // update target
             path: _path2, grant: Page.GRANT_OWNER, grantedUsers: [pModelUser3], parent: _page1._id,
           });
           expect(_page1).toBeTruthy();
           expect(_page2).toBeTruthy();
 
-          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdA };
+          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: userGroupIdPModelA };
 
-          // Group relation(parent -> child): groupIdA -> groupIdB -> groupIdC
+          // Group relation(parent -> child): userGroupIdPModelA -> userGroupIdPModelB -> userGroupIdPModelC
           // this should fail because the groupC is a descendant of groupA
-          await expect(updatePage(_page2, 'new', 'old', pModelUser3, options)) // from GRANT_OWNER to GRANT_USER_GROUP(groupIdA)
+          await expect(updatePage(_page2, 'new', 'old', pModelUser3, options)) // from GRANT_OWNER to GRANT_USER_GROUP(userGroupIdPModelA)
             .rejects.toThrow(new Error('The selected grant or grantedGroup is not assignable to this page.'));
 
           const page1 = await Page.findById(_page1._id);
@@ -888,8 +1185,8 @@ describe('Page', () => {
           expect(_page1).toBeTruthy();
           expect(_page2).toBeTruthy();
 
-          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdA };
-          await expect(updatePage(_page2, 'new', 'old', pModelUser1, options)) // from GRANT_OWNER to GRANT_USER_GROUP(groupIdA)
+          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: userGroupIdPModelA };
+          await expect(updatePage(_page2, 'new', 'old', pModelUser1, options)) // from GRANT_OWNER to GRANT_USER_GROUP(userGroupIdPModelA)
             .rejects.toThrow(new Error('The selected grant or grantedGroup is not assignable to this page.'));
 
           const page1 = await Page.findById(_page1.id);
@@ -905,4 +1202,193 @@ 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() => {
+      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({ path: '/gAB_upod_1/onlyB_upod_1' });
+
+      expect(upodPagegAB).not.toBeNull();
+      expect(upodPagegB).not.toBeNull();
+      expect(upodPageonlyB).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);
+
+      // Update
+      const options = {
+        grant: PageGrant.GRANT_PUBLIC,
+        overwriteScopesOfDescendants: true,
+      };
+      const updatedPage = await updatePage(upodPagegAB, 'newRevisionBody', 'oldRevisionBody', upodUserA, options);
+
+      const upodPagegBUpdated = await Page.findOne({ path: '/gAB_upod_1/gB_upod_1' });
+      const upodPageonlyBUpdated = await Page.findOne({ path: '/gAB_upod_1/onlyB_upod_1' });
+
+      // Changed
+      const newGrant = PageGrant.GRANT_PUBLIC;
+      expect(updatedPage.grant).toBe(newGrant);
+      // Not changed
+      expect(upodPagegBUpdated.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPagegBUpdated.grantedGroup).toStrictEqual(upodPagegB.grantedGroup);
+      expect(upodPageonlyBUpdated.grant).toBe(PageGrant.GRANT_OWNER);
+      expect(upodPageonlyBUpdated.grantedUsers).toStrictEqual(upodPageonlyB.grantedUsers);
+    });
+    test('(case 2) it should update all granted descendant pages when all descendant pages are granted by the operator', async() => {
+      const upodPagePublic = await Page.findOne({ path: '/public_upod_2' });
+      const upodPagegA = await Page.findOne({ path: '/public_upod_2/gA_upod_2' });
+      const upodPagegAIsolated = await Page.findOne({ path: '/public_upod_2/gAIsolated_upod_2' });
+      const upodPageonlyA = await Page.findOne({ path: '/public_upod_2/onlyA_upod_2' });
+
+      expect(upodPagePublic).not.toBeNull();
+      expect(upodPagegA).not.toBeNull();
+      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);
+
+      // Update
+      const options = {
+        grant: PageGrant.GRANT_OWNER,
+        overwriteScopesOfDescendants: true,
+      };
+      const updatedPage = await updatePage(upodPagePublic, 'newRevisionBody', 'oldRevisionBody', upodUserA, options);
+
+      const upodPagegAUpdated = await Page.findOne({ path: '/public_upod_2/gA_upod_2' });
+      const upodPagegAIsolatedUpdated = await Page.findOne({ path: '/public_upod_2/gAIsolated_upod_2' });
+      const upodPageonlyAUpdated = await Page.findOne({ path: '/public_upod_2/onlyA_upod_2' });
+
+      // Changed
+      const newGrant = PageGrant.GRANT_OWNER;
+      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(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
+    , 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' });
+      const upodPagegAB = await Page.findOne({ path: '/public_upod_3/gAB_upod_3' });
+      const upodPagegB = await Page.findOne({ path: '/public_upod_3/gB_upod_3' });
+      const upodPageonlyB = await Page.findOne({ path: '/public_upod_3/onlyB_upod_3' });
+
+      expect(upodPagePublic).not.toBeNull();
+      expect(upodPagegAB).not.toBeNull();
+      expect(upodPagegB).not.toBeNull();
+      expect(upodPageonlyB).not.toBeNull();
+
+      expect(upodPagePublic.grant).toBe(PageGrant.GRANT_PUBLIC);
+      expect(upodPagegAB.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPagegB.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPageonlyB.grant).toBe(PageGrant.GRANT_OWNER);
+
+      // Update
+      const options = {
+        grant: PageGrant.GRANT_USER_GROUP,
+        grantUserGroupId: upodUserGroupIdAB,
+        overwriteScopesOfDescendants: true,
+      };
+      const updatedPage = await updatePage(upodPagePublic, 'newRevisionBody', 'oldRevisionBody', upodUserA, options);
+
+      const upodPagegABUpdated = await Page.findOne({ path: '/public_upod_3/gAB_upod_3' });
+      const upodPagegBUpdated = await Page.findOne({ path: '/public_upod_3/gB_upod_3' });
+      const upodPageonlyBUpdated = await Page.findOne({ path: '/public_upod_3/onlyB_upod_3' });
+
+      // Changed
+      const newGrant = PageGrant.GRANT_USER_GROUP;
+      const newGrantedGroup = upodUserGroupIdAB;
+      expect(updatedPage.grant).toBe(newGrant);
+      expect(updatedPage.grantedGroup._id).toStrictEqual(newGrantedGroup);
+      expect(upodPagegABUpdated.grant).toBe(newGrant);
+      expect(upodPagegABUpdated.grantedGroup._id).toStrictEqual(newGrantedGroup);
+      // Not changed
+      expect(upodPagegBUpdated.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPagegBUpdated.grantedGroup._id).toStrictEqual(upodPagegB.grantedGroup);
+      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
+    , 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' });
+      const upodPagegA = await Page.findOne({ path: '/public_upod_4/gA_upod_4' });
+      const upodPagegC = await Page.findOne({ path: '/public_upod_4/gC_upod_4' });
+
+      expect(upodPagePublic).not.toBeNull();
+      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);
+
+      // Update
+      const options = {
+        grant: PageGrant.GRANT_USER_GROUP,
+        grantUserGroupId: upodUserGroupIdAB,
+        overwriteScopesOfDescendants: true,
+      };
+      const updatedPagePromise = updatePage(upodPagePublic, 'newRevisionBody', 'oldRevisionBody', upodUserA, options);
+
+      await expect(updatedPagePromise).rejects.toThrowError();
+    });
+    test(`(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' });
+      const upodPagegA = await Page.findOne({ path: '/public_upod_5/gA_upod_5' });
+      const upodPageonlyC = await Page.findOne({ path: '/public_upod_5/onlyC_upod_5' });
+
+      expect(upodPagePublic).not.toBeNull();
+      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);
+
+      // Update
+      const options = {
+        grant: PageGrant.GRANT_USER_GROUP,
+        grantUserGroupId: upodUserGroupIdAB,
+        overwriteScopesOfDescendants: true,
+      };
+      const updatedPagePromise = updatePage(upodPagePublic, 'newRevisionBody', 'oldRevisionBody', upodUserA, options);
+
+      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() => {
+      const upodPagePublic = await Page.findOne({ path: '/public_upod_6' });
+      const upodPageonlyC = await Page.findOne({ path: '/public_upod_6/onlyC_upod_6' });
+
+      expect(upodPagePublic).not.toBeNull();
+      expect(upodPageonlyC).not.toBeNull();
+
+      expect(upodPagePublic.grant).toBe(PageGrant.GRANT_PUBLIC);
+      expect(upodPageonlyC.grant).toBe(PageGrant.GRANT_OWNER);
+
+      // Update
+      const options = {
+        grant: PageGrant.GRANT_USER_GROUP,
+        grantUserGroupId: upodUserGroupIdAB,
+        overwriteScopesOfDescendants: true,
+      };
+      const updatedPagePromise = updatePage(upodPagePublic, 'newRevisionBody', 'oldRevisionBody', upodUserA, options);
+
+      await expect(updatedPagePromise).rejects.toThrowError();
+    });
+  });
 });

+ 24 - 19
packages/app/test/integration/service/page-grant.test.js

@@ -30,6 +30,8 @@ describe('PageGrantService', () => {
   let groupParent;
   let groupChild;
 
+  const userGroupIdParent = new mongoose.Types.ObjectId();
+
   let rootPage;
   let rootPublicPage;
   let rootOnlyMePage;
@@ -72,18 +74,7 @@ describe('PageGrantService', () => {
   const pageE3GroupChildPath = '/E3/GroupChild';
   const pageE3User1Path = '/E3/User1';
 
-  /*
-   * prepare before all tests
-   */
-  beforeAll(async() => {
-    crowi = await getInstance();
-
-    pageGrantService = crowi.pageGrantService;
-
-    User = mongoose.model('User');
-    Page = mongoose.model('Page');
-    UserGroupRelation = mongoose.model('UserGroupRelation');
-
+  const createDocumentsToTestIsGrantNormalized = async() => {
     // Users
     await User.insertMany([
       { name: 'User1', username: 'User1', email: 'user1@example.com' },
@@ -93,22 +84,19 @@ describe('PageGrantService', () => {
     user1 = await User.findOne({ username: 'User1' });
     user2 = await User.findOne({ username: 'User2' });
 
-    // Parent user groups
     await UserGroup.insertMany([
       {
+        _id: userGroupIdParent,
         name: 'GroupParent',
         parent: null,
       },
-    ]);
-    groupParent = await UserGroup.findOne({ name: 'GroupParent' });
-
-    // Child user groups
-    await UserGroup.insertMany([
       {
         name: 'GroupChild',
-        parent: groupParent._id,
+        parent: userGroupIdParent,
       },
     ]);
+
+    groupParent = await UserGroup.findOne({ name: 'GroupParent' });
     groupChild = await UserGroup.findOne({ name: 'GroupChild' });
 
     // UserGroupRelations
@@ -319,6 +307,23 @@ describe('PageGrantService', () => {
     pageE3GroupParent = await Page.findOne({ path: pageE3GroupParentPath });
     pageE3GroupChild = await Page.findOne({ path: pageE3GroupChildPath });
     pageE3User1 = await Page.findOne({ path: pageE3User1Path });
+  };
+
+  /*
+   * prepare before all tests
+   */
+  beforeAll(async() => {
+    crowi = await getInstance();
+
+    pageGrantService = crowi.pageGrantService;
+
+    User = mongoose.model('User');
+    Page = mongoose.model('Page');
+    UserGroupRelation = mongoose.model('UserGroupRelation');
+
+    rootPage = await Page.findOne({ path: '/' });
+
+    await createDocumentsToTestIsGrantNormalized();
 
     xssSpy = jest.spyOn(crowi.xss, 'process').mockImplementation(path => path);
   });

+ 16 - 2
packages/app/test/integration/service/v5.non-public-page.test.ts

@@ -83,6 +83,20 @@ describe('PageService page operations with non-public pages', () => {
   const tagIdRevert1 = new mongoose.Types.ObjectId();
   const tagIdRevert2 = new mongoose.Types.ObjectId();
 
+  const create = async(path, body, user, options = {}) => {
+    const mockedCreateSubOperation = jest.spyOn(crowi.pageService, 'createSubOperation').mockReturnValue(null);
+
+    const createdPage = await crowi.pageService.create(path, body, user, options);
+
+    const argsForCreateSubOperation = mockedCreateSubOperation.mock.calls[0];
+
+    mockedCreateSubOperation.mockRestore();
+
+    await crowi.pageService.createSubOperation(...argsForCreateSubOperation);
+
+    return createdPage;
+  };
+
   beforeAll(async() => {
     crowi = await getInstance();
     await crowi.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': true });
@@ -746,7 +760,7 @@ describe('PageService page operations with non-public pages', () => {
         expect(page3).toBeNull();
 
         // use existing path
-        await crowi.pageService.create(path1, 'new body', dummyUser1, { grant: Page.GRANT_RESTRICTED });
+        await create(path1, 'new body', dummyUser1, { grant: Page.GRANT_RESTRICTED });
 
         const _pageT = await Page.findOne({ path: pathT });
         const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC });
@@ -774,7 +788,7 @@ describe('PageService page operations with non-public pages', () => {
         expect(page1).toBeTruthy();
         expect(page2).toBeNull();
 
-        await crowi.pageService.create(pathN, 'new body', dummyUser1, { grant: Page.GRANT_PUBLIC });
+        await create(pathN, 'new body', dummyUser1, { grant: Page.GRANT_PUBLIC });
 
         const _pageT = await Page.findOne({ path: pathT });
         const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED });

+ 17 - 3
packages/app/test/integration/service/v5.public-page.test.ts

@@ -28,6 +28,20 @@ describe('PageService page operations with only public pages', () => {
   // page operation ids
   let pageOpId1;
 
+  const create = async(path, body, user, options = {}) => {
+    const mockedCreateSubOperation = jest.spyOn(crowi.pageService, 'createSubOperation').mockReturnValue(null);
+
+    const createdPage = await crowi.pageService.create(path, body, user, options);
+
+    const argsForCreateSubOperation = mockedCreateSubOperation.mock.calls[0];
+
+    mockedCreateSubOperation.mockRestore();
+
+    await crowi.pageService.createSubOperation(...argsForCreateSubOperation);
+
+    return createdPage;
+  };
+
   beforeAll(async() => {
     crowi = await getInstance();
     await crowi.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': true });
@@ -1055,7 +1069,7 @@ describe('PageService page operations with only public pages', () => {
 
     test('Should create single page', async() => {
       const isGrantNormalizedSpy = jest.spyOn(crowi.pageGrantService, 'isGrantNormalized');
-      const page = await crowi.pageService.create('/v5_create1', 'create1', dummyUser1, {});
+      const page = await create('/v5_create1', 'create1', dummyUser1, {});
       expect(page).toBeTruthy();
       expect(page.parent).toStrictEqual(rootPage._id);
       // isGrantNormalized is called when GRANT PUBLIC
@@ -1064,7 +1078,7 @@ describe('PageService page operations with only public pages', () => {
 
     test('Should create empty-child and non-empty grandchild', async() => {
       const isGrantNormalizedSpy = jest.spyOn(crowi.pageGrantService, 'isGrantNormalized');
-      const grandchildPage = await crowi.pageService.create('/v5_empty_create2/v5_create_3', 'grandchild', dummyUser1, {});
+      const grandchildPage = await create('/v5_empty_create2/v5_create_3', 'grandchild', dummyUser1, {});
       const childPage = await Page.findOne({ path: '/v5_empty_create2' });
 
       expect(childPage.isEmpty).toBe(true);
@@ -1081,7 +1095,7 @@ describe('PageService page operations with only public pages', () => {
       const beforeCreatePage = await Page.findOne({ path: '/v5_empty_create_4' });
       expect(beforeCreatePage.isEmpty).toBe(true);
 
-      const childPage = await crowi.pageService.create('/v5_empty_create_4', 'body', dummyUser1, {});
+      const childPage = await create('/v5_empty_create_4', 'body', dummyUser1, {});
       const grandchildPage = await Page.findOne({ parent: childPage._id });
 
       expect(childPage).toBeTruthy();

+ 13 - 1
packages/core/src/interfaces/page.ts

@@ -54,7 +54,19 @@ export const PageGrant = {
   GRANT_OWNER: 4,
   GRANT_USER_GROUP: 5,
 } as const;
-export type PageGrant = typeof PageGrant[keyof typeof PageGrant];
+type UnionPageGrantKeys = keyof typeof PageGrant;
+export type PageGrant = typeof PageGrant[UnionPageGrantKeys];
+
+/**
+ * Neither pages with grant `GRANT_RESTRICTED` nor `GRANT_SPECIFIED` can be on a page tree.
+ */
+export type PageGrantCanBeOnTree = typeof PageGrant[Exclude<UnionPageGrantKeys, 'GRANT_RESTRICTED' | 'GRANT_SPECIFIED'>];
+
+export const PageStatus = {
+  STATUS_PUBLISHED: 'published',
+  STATUS_DELETED: 'deleted',
+} as const;
+export type PageStatus = typeof PageStatus[keyof typeof PageStatus];
 
 export type IPageHasId = IPage & HasObjectId;