Преглед изворни кода

Merge pull request #5095 from weseek/imprv/create-page-user-validation

Imprv/create page user validation
Haku Mizuki пре 4 година
родитељ
комит
0d9b1b039d

+ 4 - 0
packages/app/src/server/crowi/index.js

@@ -20,6 +20,7 @@ import AppService from '../service/app';
 import AclService from '../service/acl';
 import SearchService from '../service/search';
 import AttachmentService from '../service/attachment';
+import PageGrantService from '../service/page-grant';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { UserNotificationService } from '../service/user-notification';
 
@@ -682,6 +683,9 @@ Crowi.prototype.setupPageService = async function() {
   if (this.pageService == null) {
     this.pageService = new PageEventService(this);
   }
+  if (this.pageGrantService == null) {
+    this.pageGrantService = new PageGrantService(this);
+  }
 };
 
 Crowi.prototype.setupInAppNotificationService = async function() {

+ 25 - 26
packages/app/src/server/models/obsolete-page.js

@@ -156,6 +156,21 @@ export class PageQueryBuilder {
 
   }
 
+  addConditionToListOnlyAncestors(path) {
+    const pathNormalized = pathUtils.normalizePath(path);
+    const ancestorsPaths = extractToAncestorsPaths(pathNormalized);
+
+    this.query = this.query
+      .and({
+        path: {
+          $in: ancestorsPaths,
+        },
+      });
+
+    return this;
+
+  }
+
   /**
    * generate the query to find pages that start with `path`
    *
@@ -970,14 +985,17 @@ export const getPageSchema = (crowi) => {
     }
   }
 
-  pageSchema.statics.create = async function(path, body, user, options = {}) {
+  pageSchema.statics.createV4 = async function(path, body, user, options = {}) {
+    /*
+     * v4 compatible process
+     */
     validateCrowi();
 
     const Page = this;
     const Revision = crowi.model('Revision');
-    const {
-      format = 'markdown', redirectTo, grantUserGroupId, parentId,
-    } = options;
+    const format = options.format || 'markdown';
+    const redirectTo = options.redirectTo || null;
+    const grantUserGroupId = options.grantUserGroupId || null;
 
     // sanitize path
     path = crowi.xss.process(path); // eslint-disable-line no-param-reassign
@@ -988,37 +1006,18 @@ export const getPageSchema = (crowi) => {
       grant = GRANT_PUBLIC;
     }
 
-    const isExist = await this.count({ path, isEmpty: false }); // not validate empty page
+    const isExist = await this.count({ path });
+
     if (isExist) {
       throw new Error('Cannot create new page to existed path');
     }
 
-    /*
-     * update empty page if exists, if not, create a new page
-     */
-    let page;
-    const emptyPage = await Page.findOne({ path, isEmpty: true });
-    if (emptyPage != null) {
-      page = emptyPage;
-      page.isEmpty = false;
-    }
-    else {
-      page = new Page();
-    }
-
-    const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
-
-    let parent = parentId;
-    if (isV5Compatible && parent == null && !isTopPage(path)) {
-      parent = await Page.getParentIdAndFillAncestors(path);
-    }
-
+    const page = new Page();
     page.path = path;
     page.creator = user;
     page.lastUpdateUser = user;
     page.redirectTo = redirectTo;
     page.status = STATUS_PUBLISHED;
-    page.parent = parent;
 
     await validateAppliedScope(user, grant, grantUserGroupId);
     page.applyScope(user, grant, grantUserGroupId);

+ 113 - 4
packages/app/src/server/models/page.ts

@@ -39,7 +39,7 @@ type TargetAndAncestorsResult = {
 export interface PageModel extends Model<PageDocument> {
   [x: string]: any; // for obsolete methods
   createEmptyPagesByPaths(paths: string[], publicOnly?: boolean): Promise<void>
-  getParentIdAndFillAncestors(path: string): Promise<string | null>
+  getParentIdAndFillAncestors(path: string, parent: (PageDocument & { _id: any }) | null): Promise<string | null>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: boolean, includeEmpty?: boolean): Promise<PageDocument[]>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>
@@ -158,10 +158,9 @@ schema.statics.createEmptyPagesByPaths = async function(paths: string[], publicO
  *   - second  update ancestor pages' parent
  *   - finally return the target's parent page id
  */
-schema.statics.getParentIdAndFillAncestors = async function(path: string): Promise<Schema.Types.ObjectId> {
+schema.statics.getParentIdAndFillAncestors = async function(path: string, parent: PageDocument | null): Promise<Schema.Types.ObjectId> {
   const parentPath = nodePath.dirname(path);
 
-  const parent = await this.findOne({ path: parentPath }); // find the oldest parent which must always be the true parent
   if (parent != null) {
     return parent._id;
   }
@@ -184,7 +183,7 @@ schema.statics.getParentIdAndFillAncestors = async function(path: string): Promi
     .exec();
 
   const ancestorsMap = new Map(); // Map<path, _id>
-  ancestors.forEach(page => ancestorsMap.set(page.path, page._id));
+  ancestors.forEach(page => !ancestorsMap.has(page.path) && ancestorsMap.set(page.path, page._id)); // the earlier element should be the true ancestor
 
   // bulkWrite to update ancestors
   const nonRootAncestors = ancestors.filter(page => !isTopPage(page.path));
@@ -341,11 +340,121 @@ schema.statics.findAncestorsChildrenByPathAndViewer = async function(path: strin
   return pathToChildren;
 };
 
+/*
+ * Utils from obsolete-page.js
+ */
+async function pushRevision(pageData, newRevision, user) {
+  await newRevision.save();
+
+  pageData.revision = newRevision;
+  pageData.lastUpdateUser = user;
+  pageData.updatedAt = Date.now();
+
+  return pageData.save();
+}
+
 
 /*
  * Merge obsolete page model methods and define new methods which depend on crowi instance
  */
 export default (crowi: Crowi): any => {
+  let pageEvent;
+  if (crowi != null) {
+    pageEvent = crowi.event('page');
+  }
+
+  schema.statics.create = async function(path, body, user, options = {}) {
+    if (crowi.pageGrantService == null || crowi.configManager == null) {
+      throw Error('Crowi is not setup');
+    }
+
+    const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
+    if (!isV5Compatible) {
+      // v4 compatible process
+      return this.createV4(path, body, user, options);
+    }
+
+    const Page = this;
+    const Revision = crowi.model('Revision');
+    const {
+      format = 'markdown', redirectTo, grantedUserIds = [user._id], grantUserGroupId,
+    } = options;
+
+    // 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, isEmpty: false })) > 0; // not validate empty page
+    if (isExist) {
+      throw new Error('Cannot create new page to existed path');
+    }
+
+    // find existing empty page at target path
+    const emptyPage = await Page.findOne({ path, isEmpty: true });
+
+    /*
+     * UserGroup & Owner validation
+     */
+    let isGrantNormalized = false;
+    try {
+      // It must check descendants as well if emptyTarget is not null
+      const shouldCheckDescendants = emptyPage != null;
+
+      isGrantNormalized = await crowi.pageGrantService.isGrantNormalized(path, grant, grantedUserIds, grantUserGroupId, shouldCheckDescendants);
+    }
+    catch (err) {
+      logger.error(`Failed to validate grant of page at "${path}" of grant ${grant}:`, err);
+      throw err;
+    }
+    if (!isGrantNormalized) {
+      throw Error('The selected grant or grantedGroup is not assignable to this page.');
+    }
+
+
+    /*
+     * update empty page if exists, if not, create a new page
+     */
+    let page;
+    if (emptyPage != null) {
+      page = emptyPage;
+      page.isEmpty = false;
+    }
+    else {
+      page = new Page();
+    }
+
+    let parentId: string | null = null;
+    const parentPath = nodePath.dirname(path);
+    const parent = await this.findOne({ path: parentPath }); // find the oldest parent which must always be the true parent
+    if (!isTopPage(path)) {
+      parentId = await Page.getParentIdAndFillAncestors(path, parent);
+    }
+
+    page.path = path;
+    page.creator = user;
+    page.lastUpdateUser = user;
+    page.redirectTo = redirectTo;
+    page.status = STATUS_PUBLISHED;
+    page.parent = options.grant === GRANT_RESTRICTED ? null : parentId;
+
+    page.applyScope(user, grant, grantUserGroupId);
+
+    let savedPage = await page.save();
+    const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
+    const revision = await pushRevision(savedPage, newRevision, user);
+    savedPage = await this.findByPath(revision.path);
+    await savedPage.populateDataToShowRevision();
+
+    pageEvent.emit('create', savedPage, user);
+
+    return savedPage;
+  };
+
   // add old page schema methods
   const pageSchema = getPageSchema(crowi);
   schema.methods = { ...pageSchema.methods, ...schema.methods };

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

@@ -84,6 +84,13 @@ schema.statics.createGroup = async function(name, description, parentId) {
   return this.create({ name, description, parent });
 };
 
+/**
+ * Find all ancestor groups starting from the UserGroup of the initial "group".
+ * Set "ancestors" as "[]" if the initial group is unnecessary as result.
+ * @param groups UserGroupDocument
+ * @param ancestors UserGroupDocument[]
+ * @returns UserGroupDocument[]
+ */
 schema.statics.findGroupsWithAncestorsRecursively = async function(group, ancestors = [group]) {
   if (group == null) {
     return ancestors;
@@ -99,6 +106,13 @@ schema.statics.findGroupsWithAncestorsRecursively = async function(group, ancest
   return this.findGroupsWithAncestorsRecursively(parent, ancestors);
 };
 
+/**
+ * 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
+ * @param descendants UserGroupDocument[]
+ * @returns UserGroupDocument[]
+ */
 schema.statics.findGroupsWithDescendantsRecursively = async function(groups, descendants = groups) {
   const nextGroups = await this.find({ parent: { $in: groups.map(g => g._id) } });
 
@@ -109,4 +123,12 @@ schema.statics.findGroupsWithDescendantsRecursively = async function(groups, des
   return this.findGroupsWithDescendantsRecursively(nextGroups, descendants.concat(nextGroups));
 };
 
+schema.statics.findGroupsWithDescendantsById = async function(groupId) {
+  const root = await this.findOne({ _id: groupId });
+  if (root == null) {
+    throw Error('The root user group does not exist');
+  }
+  return this.findGroupsWithDescendantsRecursively([root]);
+};
+
 export default getOrCreateModel<UserGroupDocument, UserGroupModel>('UserGroup', schema);

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

@@ -1,4 +1,5 @@
 import loggerFactory from '~/utils/logger';
+import UserGroup from '~/server/models/user-group';
 
 const logger = loggerFactory('growi:routes:admin');
 const debug = require('debug')('growi:routes:admin');
@@ -7,7 +8,6 @@ const debug = require('debug')('growi:routes:admin');
 module.exports = function(crowi, app) {
 
   const models = crowi.models;
-  const UserGroup = models.UserGroup;
   const UserGroupRelation = models.UserGroupRelation;
   const GlobalNotificationSetting = models.GlobalNotificationSetting;
 

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

@@ -284,6 +284,7 @@ module.exports = (crowi) => {
       });
     }
     catch (err) {
+      logger.error('Error occurred while creating a page.', err);
       return res.apiv3Err(err);
     }
 

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

@@ -1,5 +1,6 @@
 import loggerFactory from '~/utils/logger';
 import { excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
+import UserGroup from '~/server/models/user-group';
 
 const logger = loggerFactory('growi:routes:apiv3:user-group'); // eslint-disable-line no-unused-vars
 
@@ -35,7 +36,6 @@ module.exports = (crowi) => {
   const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   const {
-    UserGroup,
     UserGroupRelation,
     User,
     Page,

+ 269 - 0
packages/app/src/server/service/page-grant.ts

@@ -0,0 +1,269 @@
+import mongoose from 'mongoose';
+import { pagePathUtils } from '@growi/core';
+
+import UserGroup from '~/server/models/user-group';
+import { PageModel } from '~/server/models/page';
+import { PageQueryBuilder } from '../models/obsolete-page';
+import { isIncludesObjectId, removeDuplicates, excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
+
+const { isTopPage } = pagePathUtils;
+
+type ObjectId = mongoose.Types.ObjectId;
+
+type ComparableTarget = {
+  grant: number,
+  grantedUserIds: ObjectId[],
+  grantedGroupId: ObjectId,
+  applicableGroupIds?: ObjectId[],
+};
+
+type ComparableAncestor = {
+  grant: number,
+  grantedUserIds: ObjectId[],
+  applicableUserIds?: ObjectId[],
+  applicableGroupIds?: ObjectId[],
+};
+
+type ComparableDescendants = {
+  grantedUserIds: ObjectId[],
+  descendantGroupIds: ObjectId[],
+};
+
+class PageGrantService {
+
+  crowi!: any;
+
+  constructor(crowi: any) {
+    this.crowi = crowi;
+  }
+
+  private validateComparableTarget(comparable: ComparableTarget) {
+    const Page = mongoose.model('Page') as PageModel;
+
+    const { grant, grantedUserIds, grantedGroupId } = comparable;
+
+    if (grant === Page.GRANT_OWNER && (grantedUserIds == null || grantedUserIds.length !== 1)) {
+      throw Error('grantedUserIds must not be null and must have 1 length');
+    }
+    if (grant === Page.GRANT_USER_GROUP && grantedGroupId == null) {
+      throw Error('grantedGroupId is not specified');
+    }
+  }
+
+  /**
+   * About the rule of validation, see: https://dev.growi.org/61b2cdabaa330ce7d8152844
+   * @returns boolean
+   */
+  private processValidation(target: ComparableTarget, ancestor: ComparableAncestor, descendants?: ComparableDescendants): boolean {
+    this.validateComparableTarget(target);
+
+    const Page = mongoose.model('Page') as PageModel;
+
+    /*
+     * ancestor side
+     */
+    // GRANT_PUBLIC
+    if (ancestor.grant === Page.GRANT_PUBLIC) {
+      // DO NOTHING
+    }
+    // GRANT_OWNER
+    else if (ancestor.grant === Page.GRANT_OWNER) {
+      if (target.grant !== Page.GRANT_OWNER) {
+        return false;
+      }
+
+      if (!ancestor.grantedUserIds[0].equals(target.grantedUserIds[0])) {
+        return false;
+      }
+    }
+    // GRANT_USER_GROUP
+    else if (ancestor.grant === Page.GRANT_USER_GROUP) {
+      if (ancestor.applicableGroupIds == null || ancestor.applicableUserIds == null) {
+        throw Error('applicableGroupIds and applicableUserIds are not specified');
+      }
+
+      if (target.grant === Page.GRANT_PUBLIC) {
+        return false;
+      }
+
+      if (target.grant === Page.GRANT_OWNER) {
+        if (!isIncludesObjectId(ancestor.applicableUserIds, target.grantedUserIds[0])) {
+          return false;
+        }
+      }
+
+      if (target.grant === Page.GRANT_USER_GROUP) {
+        if (!isIncludesObjectId(ancestor.applicableGroupIds, target.grantedGroupId)) {
+          return false;
+        }
+      }
+    }
+
+    if (descendants == null) {
+      return true;
+    }
+    /*
+     * descendant side
+     */
+
+    if (target.applicableGroupIds == null) {
+      throw Error('applicableGroupIds must not be null');
+    }
+
+    // GRANT_PUBLIC
+    if (target.grant === Page.GRANT_PUBLIC) {
+      if (descendants.descendantGroupIds.length !== 0 || descendants.descendantGroupIds.length !== 0) {
+        return false;
+      }
+    }
+    // GRANT_OWNER
+    else if (target.grant === Page.GRANT_OWNER) {
+      if (descendants.descendantGroupIds.length !== 0 || descendants.grantedUserIds.length > 1) {
+        return false;
+      }
+
+      if (descendants.grantedUserIds.length === 1 && descendants.grantedUserIds[0].equals(target.grantedGroupId)) {
+        return false;
+      }
+    }
+    // GRANT_USER_GROUP
+    else if (target.grant === Page.GRANT_USER_GROUP) {
+      const shouldNotExistIds = excludeTestIdsFromTargetIds(descendants.descendantGroupIds, target.applicableGroupIds);
+      if (shouldNotExistIds.length !== 0) {
+        return false;
+      }
+    }
+
+    return true;
+  }
+
+  /**
+   * Prepare ComparableTarget
+   * @returns Promise<ComparableAncestor>
+   */
+  private async generateComparableTarget(
+      grant, grantedUserIds: ObjectId[], grantedGroupId: ObjectId, includeApplicable: boolean,
+  ): Promise<ComparableTarget> {
+    if (includeApplicable) {
+      const applicableGroups = await UserGroup.findGroupsWithDescendantsById(grantedGroupId);
+      const applicableGroupIds = applicableGroups.map(g => g._id);
+
+
+      return {
+        grant,
+        grantedUserIds,
+        grantedGroupId,
+        applicableGroupIds,
+      };
+    }
+
+    return {
+      grant,
+      grantedUserIds,
+      grantedGroupId,
+    };
+  }
+
+  /**
+   * Prepare ComparableAncestor
+   * @param targetPath string of the target path
+   * @returns Promise<ComparableAncestor>
+   */
+  private async generateComparableAncestor(targetPath: string): Promise<ComparableAncestor> {
+    const Page = mongoose.model('Page') as PageModel;
+    const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
+
+    let applicableUserIds: ObjectId[] | undefined;
+    let applicableGroupIds: ObjectId[] | undefined;
+
+    /*
+     * make granted users list of ancestor's
+     */
+    const builderForAncestors = new PageQueryBuilder(Page.find(), false);
+    const ancestors = await builderForAncestors
+      .addConditionToListOnlyAncestors(targetPath)
+      .addConditionToSortPagesByDescPath()
+      .query
+      .exec();
+    const testAncestor = ancestors[0];
+    if (testAncestor == null) {
+      throw Error('testAncestor must exist');
+    }
+
+    if (testAncestor.grant === Page.GRANT_USER_GROUP) {
+      // make a set of all users
+      const grantedRelations = await UserGroupRelation.find({ relatedGroup: testAncestor.grantedGroup }, { _id: 0, relatedUser: 1 });
+      const grantedGroups = await UserGroup.findGroupsWithDescendantsById(testAncestor.grantedGroup);
+      applicableGroupIds = grantedGroups.map(g => g._id);
+      applicableUserIds = Array.from(new Set(grantedRelations.map(r => r.relatedUser))) as ObjectId[];
+    }
+
+    return {
+      grant: testAncestor.grant,
+      grantedUserIds: testAncestor.grantedUsers,
+      applicableUserIds,
+      applicableGroupIds,
+    };
+  }
+
+  /**
+   * Prepare ComparableDescendants
+   * @param targetPath string of the target path
+   * @returns ComparableDescendants
+   */
+  private async generateComparableDescendants(targetPath: string): Promise<ComparableDescendants> {
+    const Page = mongoose.model('Page') as PageModel;
+
+    /*
+     * make granted users list of descendant's
+     */
+    // find all descendants excluding empty pages
+    const builderForDescendants = new PageQueryBuilder(Page.find({}, { _id: 0, grantedUsers: 1, grantedGroup: 1 }), false);
+    const descendants = await builderForDescendants
+      .addConditionToListOnlyDescendants(targetPath)
+      .query
+      .exec();
+
+    let grantedUsersOfGrantOwner: ObjectId[] = []; // users of GRANT_OWNER
+    const grantedGroups: ObjectId[] = [];
+    descendants.forEach((d) => {
+      if (d.grantedUsers != null) {
+        grantedUsersOfGrantOwner = grantedUsersOfGrantOwner.concat(d.grantedUsers);
+      }
+      if (d.grantedGroup != null) {
+        grantedGroups.push(d.grantedGroup);
+      }
+    });
+
+    const descendantGroupIds = removeDuplicates(grantedGroups);
+    return {
+      grantedUserIds: grantedUsersOfGrantOwner,
+      descendantGroupIds,
+    };
+  }
+
+  /**
+   * About the rule of validation, see: https://dev.growi.org/61b2cdabaa330ce7d8152844
+   * @returns Promise<boolean>
+   */
+  async isGrantNormalized(targetPath: string, grant, grantedUserIds: ObjectId[], grantedGroupId: ObjectId, shouldCheckDescendants = false): Promise<boolean> {
+    if (isTopPage(targetPath)) {
+      return true;
+    }
+
+    const comparableAncestor = await this.generateComparableAncestor(targetPath);
+
+    if (!shouldCheckDescendants) { // checking the parent is enough
+      const comparableTarget = await this.generateComparableTarget(grant, grantedUserIds, grantedGroupId, false);
+      return this.processValidation(comparableTarget, comparableAncestor);
+    }
+
+    const comparableTarget = await this.generateComparableTarget(grant, grantedUserIds, grantedGroupId, true);
+    const comparableDescendants = await this.generateComparableDescendants(targetPath);
+
+    return this.processValidation(comparableTarget, comparableAncestor, comparableDescendants);
+  }
+
+}
+
+export default PageGrantService;

+ 1 - 1
packages/app/src/server/service/page.js

@@ -367,7 +367,7 @@ class PageService {
     const options = { page };
     options.grant = page.grant;
     options.grantUserGroupId = page.grantedGroup;
-    options.grantedUsers = page.grantedUsers;
+    options.grantedUserIds = page.grantedUsers;
 
     newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
 

+ 3 - 3
packages/app/src/server/service/user-group.ts

@@ -1,8 +1,8 @@
 import mongoose from 'mongoose';
 
 import loggerFactory from '~/utils/logger';
-import UserGroup from '~/server/models/user-group';
-import { compareObjectId, isIncludesObjectId } from '~/server/util/compare-objectId';
+import UserGroup, { UserGroupDocument } from '~/server/models/user-group';
+import { isIncludesObjectId } from '~/server/util/compare-objectId';
 
 const logger = loggerFactory('growi:service:UserGroupService'); // eslint-disable-line no-unused-vars
 
@@ -61,7 +61,7 @@ class UserGroupService {
 
     // throw if parent was in its descendants
     const descendantsWithTarget = await UserGroup.findGroupsWithDescendantsRecursively([userGroup]);
-    const descendants = descendantsWithTarget.filter(d => compareObjectId(d._id, userGroup._id));
+    const descendants = descendantsWithTarget.filter(d => d._id.equals(userGroup._id));
     if (isIncludesObjectId(descendants, parent._id)) {
       throw Error('It is not allowed to choose parent from descendant groups.');
     }

+ 19 - 8
packages/app/src/server/util/compare-objectId.ts

@@ -2,12 +2,9 @@ import mongoose from 'mongoose';
 
 type IObjectId = mongoose.Types.ObjectId;
 const ObjectId = mongoose.Types.ObjectId;
+type ObjectIdLike = IObjectId | string;
 
-export const compareObjectId = (id1: IObjectId, id2: IObjectId): boolean => {
-  return id1.toString() === id2.toString();
-};
-
-export const isIncludesObjectId = (arr: IObjectId[], id: IObjectId): boolean => {
+export const isIncludesObjectId = (arr: (IObjectId | string)[], id: IObjectId | string): boolean => {
   const _arr = arr.map(i => i.toString());
   const _id = id.toString();
 
@@ -20,14 +17,28 @@ export const isIncludesObjectId = (arr: IObjectId[], id: IObjectId): boolean =>
  * @param testIds Array of mongoose.Types.ObjectId
  * @returns Array of mongoose.Types.ObjectId
  */
-export const excludeTestIdsFromTargetIds = (targetIds: IObjectId[], testIds: IObjectId[]): IObjectId[] => {
+export const excludeTestIdsFromTargetIds = <T extends { toString: any } = IObjectId>(
+  targetIds: T[], testIds: (IObjectId | string)[],
+): T[] => {
   // cast to string
   const arr1 = targetIds.map(e => e.toString());
   const arr2 = testIds.map(e => e.toString());
 
   // filter
-  const excluded = arr2.filter(e => !arr1.includes(e));
+  const excluded = arr1.filter(e => !arr2.includes(e));
+  // cast to ObjectId
+  const shouldReturnString = (arr: any[]): arr is string[] => {
+    return typeof arr[0] === 'string';
+  };
+
+  return shouldReturnString(targetIds) ? excluded : excluded.map(e => new ObjectId(e));
+};
+
+export const removeDuplicates = (objectIds: (IObjectId | string)[]): IObjectId[] => {
+  // cast to string
+  const strs = objectIds.map(id => id.toString());
+  const uniqueArr = Array.from(new Set(strs));
 
   // cast to ObjectId
-  return excluded.map(e => new ObjectId(e));
+  return uniqueArr.map(str => new ObjectId(str));
 };