From 9b4c03b10ba4a877b5c05e65cb4c266e8c84f86d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20Ch=C3=A1vez?= Date: Mon, 4 Dec 2023 10:10:51 +0100 Subject: [PATCH] chore(content-releases): Add entries to content-releases' actions (#18918) * chore(content-releases): add entries to content releases actions * chore(content-releases): add entries relation to findMany content-release's actions * chore(content-releases): improve findOne with actions code * chore(Content-releases): remi feedback * chore(content-releases): fix getReleaseActions response type * chore(content-releases): change findOne and findActions endpoints * chore(content-releases): fix error in release's tests * chore(content-releases): use queryBuilder from strapi.db * chore(content-releases): use queryBuilder from strapi.db --- .../__tests__/release-action.test.ts | 69 +++++++++++ .../src/controllers/__tests__/release.test.ts | 112 +++++++++++++----- .../server/src/controllers/release-action.ts | 84 ++++++++++++- .../server/src/controllers/release.ts | 33 ++++-- .../server/src/routes/release-action.ts | 16 +++ .../src/services/__tests__/release.test.ts | 16 +++ .../server/src/services/release.ts | 82 +++++++++++-- .../server/src/utils/index.ts | 29 +++++ .../shared/contracts/release-actions.ts | 24 +++- .../shared/contracts/releases.ts | 3 +- 10 files changed, 415 insertions(+), 53 deletions(-) diff --git a/packages/core/content-releases/server/src/controllers/__tests__/release-action.test.ts b/packages/core/content-releases/server/src/controllers/__tests__/release-action.test.ts index 0e6a7b554c..0061ffa54a 100644 --- a/packages/core/content-releases/server/src/controllers/__tests__/release-action.test.ts +++ b/packages/core/content-releases/server/src/controllers/__tests__/release-action.test.ts @@ -1,5 +1,33 @@ import releaseActionController from '../release-action'; +const mockSanitizedQueryRead = jest.fn().mockResolvedValue({}); +const mockFindActions = jest.fn().mockResolvedValue({ results: [], pagination: {} }); +const mockSanitizeOutput = jest.fn((entry: { id: number; name: string }) => ({ id: entry.id })); + +jest.mock('../../utils', () => ({ + getService: jest.fn(() => ({ + create: jest.fn(), + findActions: mockFindActions, + findReleaseContentTypesMainFields: jest.fn(() => ({ + 'api::contentTypeA.contentTypeA': { + mainField: 'name', + }, + 'api::contentTypeB.contentTypeB': { + mainField: 'name', + }, + })), + })), + getAllowedContentTypes: jest + .fn() + .mockReturnValue(['api::contentTypeA.contentTypeA', 'api::contentTypeB.contentTypeB']), + getPermissionsChecker: jest.fn(() => ({ + sanitizedQuery: { + read: mockSanitizedQueryRead, + }, + sanitizeOutput: mockSanitizeOutput, + })), +})); + describe('Release Action controller', () => { describe('create', () => { beforeEach(() => { @@ -66,4 +94,45 @@ describe('Release Action controller', () => { expect(() => releaseActionController.create(ctx)).rejects.toThrow('type is a required field'); }); }); + + describe('findMany', () => { + const ctx = { + state: { + userAbility: { + can: jest.fn(), + cannot: jest.fn(), + }, + }, + params: { + releaseId: 1, + }, + }; + + it('should call sanitizedQueryRead once for each contentType', async () => { + // @ts-expect-error Ignore missing properties + await releaseActionController.findMany(ctx); + + expect(mockSanitizedQueryRead).toHaveBeenCalledTimes(2); + }); + + it('should call findActions with the right params', async () => { + // @ts-expect-error Ignore missing properties + await releaseActionController.findMany(ctx); + + expect(mockFindActions).toHaveBeenCalledWith( + 1, + ['api::contentTypeA.contentTypeA', 'api::contentTypeB.contentTypeB'], + { + populate: { + entry: { + on: { + 'api::contentTypeA.contentTypeA': {}, + 'api::contentTypeB.contentTypeB': {}, + }, + }, + }, + } + ); + }); + }); }); diff --git a/packages/core/content-releases/server/src/controllers/__tests__/release.test.ts b/packages/core/content-releases/server/src/controllers/__tests__/release.test.ts index ba045f3d22..be2c2a1491 100644 --- a/packages/core/content-releases/server/src/controllers/__tests__/release.test.ts +++ b/packages/core/content-releases/server/src/controllers/__tests__/release.test.ts @@ -1,10 +1,25 @@ import releaseController from '../release'; +const mockFindPage = jest.fn(); +const mockFindMany = jest.fn(); +const mockCountActions = jest.fn(); + +jest.mock('../../utils', () => ({ + getService: jest.fn(() => ({ + findOne: jest.fn(() => ({ id: 1 })), + findPage: mockFindPage, + findMany: mockFindMany, + countActions: mockCountActions, + findReleaseContentTypesMainFields: jest.fn(), + })), + getAllowedContentTypes: jest.fn(() => ['contentTypeA', 'contentTypeB']), +})); + describe('Release controller', () => { describe('findMany', () => { it('should call findPage', async () => { - const findPage = jest.fn().mockResolvedValue({ results: [], pagination: {} }); - const findMany = jest.fn().mockResolvedValue([]); + mockFindPage.mockResolvedValue({ results: [], pagination: {} }); + mockFindMany.mockResolvedValue([]); const userAbility = { can: jest.fn(), }; @@ -30,28 +45,17 @@ describe('Release controller', () => { }, }, }, - plugins: { - // @ts-expect-error Ignore missing properties - 'content-releases': { - services: { - release: { - findPage, - findMany, - }, - }, - }, - }, }; // @ts-expect-error partial context await releaseController.findMany(ctx); - expect(findPage).toHaveBeenCalled(); + expect(mockFindPage).toHaveBeenCalled(); }); it('should call findMany', async () => { - const findPage = jest.fn().mockResolvedValue({ results: [], pagination: {} }); - const findMany = jest.fn().mockResolvedValue([]); + mockFindPage.mockResolvedValue({ results: [], pagination: {} }); + mockFindMany.mockResolvedValue([]); const userAbility = { can: jest.fn(), }; @@ -74,23 +78,12 @@ describe('Release controller', () => { }, }, }, - plugins: { - // @ts-expect-error Ignore missing properties - 'content-releases': { - services: { - release: { - findPage, - findMany, - }, - }, - }, - }, }; // @ts-expect-error partial context await releaseController.findMany(ctx); - expect(findMany).toHaveBeenCalled(); + expect(mockFindMany).toHaveBeenCalled(); }); }); describe('create', () => { @@ -154,4 +147,67 @@ describe('Release controller', () => { ); }); }); + + describe('findOne', () => { + global.strapi = { + ...global.strapi, + plugins: { + // @ts-expect-error incomplete plugin + 'content-manager': { + services: { + 'content-types': { + findConfiguration: () => ({ + settings: { + mainField: 'name', + }, + }), + }, + }, + }, + }, + }; + + const ctx = { + state: { + userAbility: { + can: jest.fn(() => true), + }, + }, + params: { + id: 1, + }, + user: {}, + body: { + data: { + actions: { + meta: { + total: 0, + totalHidden: 0, + }, + }, + meta: {}, + }, + }, + }; + + it('throws an error if the release does not exists', async () => { + // @ts-expect-error partial context + expect(() => releaseController.findOne(ctx).rejects.toThrow('Release not found for id: 1')); + }); + + it('return the right meta object', async () => { + // We mock the count all actions + mockCountActions.mockResolvedValueOnce(2); + + // We mock the count hidden actions + mockCountActions.mockResolvedValueOnce(1); + + // @ts-expect-error partial context + await releaseController.findOne(ctx); + expect(ctx.body.data.actions.meta).toEqual({ + total: 2, + totalHidden: 1, + }); + }); + }); }); diff --git a/packages/core/content-releases/server/src/controllers/release-action.ts b/packages/core/content-releases/server/src/controllers/release-action.ts index ae2ca3a478..6905389106 100644 --- a/packages/core/content-releases/server/src/controllers/release-action.ts +++ b/packages/core/content-releases/server/src/controllers/release-action.ts @@ -1,7 +1,13 @@ import type Koa from 'koa'; +import { UID } from '@strapi/types'; +import { mapAsync } from '@strapi/utils'; import { validateReleaseAction } from './validation/release-action'; -import type { CreateReleaseAction } from '../../../shared/contracts/release-actions'; -import { getService } from '../utils'; +import type { + CreateReleaseAction, + GetReleaseActions, + ReleaseAction, +} from '../../../shared/contracts/release-actions'; +import { getAllowedContentTypes, getService, getPermissionsChecker } from '../utils'; const releaseActionController = { async create(ctx: Koa.Context) { @@ -17,6 +23,80 @@ const releaseActionController = { data: releaseAction, }; }, + async findMany(ctx: Koa.Context) { + const releaseId: GetReleaseActions.Request['params']['releaseId'] = ctx.params.releaseId; + const allowedContentTypes = getAllowedContentTypes({ + strapi, + userAbility: ctx.state.userAbility, + }); + + // We create an object with the permissionsChecker for each contentType, then we can reuse it for sanitization + const permissionsChecker: Record = {}; + // We create a populate object for polymorphic relations, so we considered custom conditions on permissions + const morphSanitizedPopulate: Record = {}; + + for (const contentTypeUid of allowedContentTypes) { + const permissionChecker = await getPermissionsChecker({ + strapi, + userAbility: ctx.state.userAbility, + model: contentTypeUid, + }); + permissionsChecker[contentTypeUid] = permissionChecker; + morphSanitizedPopulate[contentTypeUid] = await permissionChecker.sanitizedQuery.read({}); + } + + const releaseService = getService('release', { strapi }); + + const { results, pagination } = await releaseService.findActions( + releaseId, + allowedContentTypes, + { + populate: { + entry: { + on: morphSanitizedPopulate, + }, + }, + } + ); + + const contentTypesMainFields = await releaseService.findReleaseContentTypesMainFields( + releaseId + ); + // We loop over all the contentTypes mainfields to sanitize each mainField + // By default, if user doesn't have permission to read the field, we return null as fallback + for (const contentTypeUid of Object.keys(contentTypesMainFields)) { + if ( + ctx.state.userAbility.cannot( + 'plugin::content-manager.explorer.read', + contentTypeUid, + contentTypesMainFields[contentTypeUid].mainField + ) + ) { + contentTypesMainFields[contentTypeUid].mainField = null; + } + } + + // Because this is a morphTo relation, we need to sanitize each entry separately based on its contentType + const sanitizedResults = await mapAsync(results, async (action: ReleaseAction) => { + const mainField = contentTypesMainFields[action.contentType].mainField; + + return { + ...action, + entry: action.entry && { + id: action.entry.id, + mainField: mainField ? action.entry[mainField] : null, + locale: action.entry.locale, + }, + }; + }); + + ctx.body = { + data: sanitizedResults, + meta: { + pagination, + }, + }; + }, }; export default releaseActionController; diff --git a/packages/core/content-releases/server/src/controllers/release.ts b/packages/core/content-releases/server/src/controllers/release.ts index 518f3968e1..0199c3a2cd 100644 --- a/packages/core/content-releases/server/src/controllers/release.ts +++ b/packages/core/content-releases/server/src/controllers/release.ts @@ -9,7 +9,7 @@ import type { Release, } from '../../../shared/contracts/releases'; import type { UserInfo } from '../../../shared/types'; -import { getService } from '../utils'; +import { getAllowedContentTypes, getService } from '../utils'; type ReleaseWithPopulatedActions = Release & { actions: { count: number } }; @@ -56,22 +56,39 @@ const releaseController = { async findOne(ctx: Koa.Context) { const id: GetRelease.Request['params']['id'] = ctx.params.id; - const result = (await getService('release', { strapi }).findOne( - Number(id) - )) as ReleaseWithPopulatedActions | null; + const releaseService = getService('release', { strapi }); - if (!result) { + const allowedContentTypes = getAllowedContentTypes({ + strapi, + userAbility: ctx.state.userAbility, + }); + + const release = await releaseService.findOne(id); + const total = await releaseService.countActions({ + filters: { + release: id, + }, + }); + const totalHidden = await releaseService.countActions({ + filters: { + release: id, + contentType: { + $notIn: allowedContentTypes, + }, + }, + }); + + if (!release) { throw new errors.NotFoundError(`Release not found for id: ${id}`); } - const { actions, ...release } = result; - // Format the data object const data = { ...release, actions: { meta: { - count: actions.count, + total, + totalHidden, }, }, }; diff --git a/packages/core/content-releases/server/src/routes/release-action.ts b/packages/core/content-releases/server/src/routes/release-action.ts index 798a1568fc..665da43aeb 100644 --- a/packages/core/content-releases/server/src/routes/release-action.ts +++ b/packages/core/content-releases/server/src/routes/release-action.ts @@ -17,5 +17,21 @@ export default { ], }, }, + { + method: 'GET', + path: '/:releaseId/actions', + handler: 'release-action.findMany', + config: { + policies: [ + 'admin::isAuthenticatedAdmin', + { + name: 'admin::hasPermissions', + config: { + actions: ['plugin::content-releases.read'] + } + } + ] + } + } ], }; diff --git a/packages/core/content-releases/server/src/services/__tests__/release.test.ts b/packages/core/content-releases/server/src/services/__tests__/release.test.ts index fe2ab4fc1e..ee1e751ce6 100644 --- a/packages/core/content-releases/server/src/services/__tests__/release.test.ts +++ b/packages/core/content-releases/server/src/services/__tests__/release.test.ts @@ -42,4 +42,20 @@ describe('release service', () => { ); }); }); + + describe('findActions', () => { + it('throws an error if the release does not exist', () => { + const strapiMock = { + ...baseStrapiMock, + entityService: { + findOne: jest.fn().mockReturnValue(null), + }, + }; + + // @ts-expect-error Ignore missing properties + const releaseService = createReleaseService({ strapi: strapiMock }); + + expect(() => releaseService.findActions(1, ['api::contentType.contentType'], {})).rejects.toThrow('No release found for id 1'); + }); + }); }); diff --git a/packages/core/content-releases/server/src/services/release.ts b/packages/core/content-releases/server/src/services/release.ts index 7a89a77f58..bb3af34d9d 100644 --- a/packages/core/content-releases/server/src/services/release.ts +++ b/packages/core/content-releases/server/src/services/release.ts @@ -1,13 +1,17 @@ import { setCreatorFields, errors } from '@strapi/utils'; -import type { LoadedStrapi } from '@strapi/types'; +import type { LoadedStrapi, Common, EntityService, UID } from '@strapi/types'; import { RELEASE_ACTION_MODEL_UID, RELEASE_MODEL_UID } from '../constants'; import type { GetReleases, CreateRelease, UpdateRelease, GetRelease, + Release, } from '../../../shared/contracts/releases'; -import type { CreateReleaseAction } from '../../../shared/contracts/release-actions'; +import type { + CreateReleaseAction, + GetReleaseActions, +} from '../../../shared/contracts/release-actions'; import type { UserInfo } from '../../../shared/types'; import { getService } from '../utils'; @@ -30,6 +34,9 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({ }, }); }, + findOne(id: GetRelease.Request['params']['id'], query = {}) { + return strapi.entityService.findOne(RELEASE_MODEL_UID, id, query); + }, findMany(query?: GetReleases.Request['query']) { return strapi.entityService.findMany(RELEASE_MODEL_UID, { ...query, @@ -41,16 +48,6 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({ }, }); }, - findOne(id: GetRelease.Request['params']['id']) { - return strapi.entityService.findOne(RELEASE_MODEL_UID, id, { - populate: { - actions: { - // @ts-expect-error TS error on populate, is not considering count - count: true, - }, - }, - }); - }, async update( id: number, releaseData: UpdateRelease.Request['body'], @@ -102,6 +99,67 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({ populate: { release: { fields: ['id'] }, entry: { fields: ['id'] } }, }); }, + async findActions( + releaseId: GetReleaseActions.Request['params']['releaseId'], + contentTypes: Common.UID.ContentType[], + query?: GetReleaseActions.Request['query'] + ) { + const result = await strapi.entityService.findOne(RELEASE_MODEL_UID, releaseId); + + if (!result) { + throw new errors.NotFoundError(`No release found for id ${releaseId}`); + } + + return strapi.entityService.findPage(RELEASE_ACTION_MODEL_UID, { + ...query, + filters: { + release: releaseId, + contentType: { + $in: contentTypes, + }, + }, + }); + }, + async countActions(query: EntityService.Params.Pick) { + return strapi.entityService.count(RELEASE_ACTION_MODEL_UID, query); + }, + async findReleaseContentTypesMainFields(releaseId: Release['id']) { + const contentTypesFromReleaseActions: { contentType: UID.ContentType }[] = await strapi.db + .queryBuilder(RELEASE_ACTION_MODEL_UID) + .select('content_type') + .where({ + $and: [ + { + release: releaseId, + }, + ], + }) + .groupBy('content_type') + .execute(); + + const contentTypesUids = contentTypesFromReleaseActions.map( + ({ contentType: contentTypeUid }) => contentTypeUid + ); + + const contentManagerContentTypeService = strapi + .plugin('content-manager') + .service('content-types'); + const contentTypesMeta: Record = {}; + + for (const contentTypeUid of contentTypesUids) { + const contentTypeConfig = await contentManagerContentTypeService.findConfiguration({ + uid: contentTypeUid, + }); + + if (contentTypeConfig) { + contentTypesMeta[contentTypeUid] = { + mainField: contentTypeConfig.settings.mainField, + }; + } + } + + return contentTypesMeta; + }, }); export default createReleaseService; diff --git a/packages/core/content-releases/server/src/utils/index.ts b/packages/core/content-releases/server/src/utils/index.ts index ae5e97e9cd..b4be835818 100644 --- a/packages/core/content-releases/server/src/utils/index.ts +++ b/packages/core/content-releases/server/src/utils/index.ts @@ -1,6 +1,35 @@ +import type { LoadedStrapi, UID } from '@strapi/types'; + export const getService = ( name: 'release' | 'release-validation', { strapi } = { strapi: global.strapi } ) => { return strapi.plugin('content-releases').service(name); }; + +/** + * Gets the content types that have draft and publish enabled and that the user can read + */ +export const getAllowedContentTypes = ({ strapi, userAbility }: { strapi: LoadedStrapi, userAbility: any }) => { + const { contentTypes } = strapi; + const contentTypesWithDraftAndPublish = (Object.keys(contentTypes) as UID.ContentType[]).filter( + (contentTypeUid) => contentTypes[contentTypeUid].options?.draftAndPublish + ); + const allowedContentTypes = contentTypesWithDraftAndPublish.filter( + (contentTypeUid) => { + return userAbility.can('plugin::content-manager.explorer.read', contentTypeUid); + } + ); + + return allowedContentTypes; +}; + +/** + * Gets the permissions checker for a given content type using the permission checker from content-manager + */ +export const getPermissionsChecker = ({ strapi, userAbility, model }: { strapi: LoadedStrapi, userAbility: any, model: UID.ContentType }) => { + return strapi + .plugin('content-manager') + .service('permission-checker') + .create({ userAbility, model }); +}; diff --git a/packages/core/content-releases/shared/contracts/release-actions.ts b/packages/core/content-releases/shared/contracts/release-actions.ts index 1e38940fb2..27d7ee13e3 100644 --- a/packages/core/content-releases/shared/contracts/release-actions.ts +++ b/packages/core/content-releases/shared/contracts/release-actions.ts @@ -1,5 +1,5 @@ import { Attribute, Common } from '@strapi/types'; -import type { Release } from './releases'; +import type { Release, Pagination } from './releases'; import type { Entity } from '../types'; import type { errors } from '@strapi/utils'; @@ -9,7 +9,7 @@ type ReleaseActionEntry = Entity & { [key: string]: Attribute.Any; }; -export interface ReleaseAction { +export interface ReleaseAction extends Entity { type: 'publish' | 'unpublish'; entry: ReleaseActionEntry; contentType: Common.UID.ContentType; @@ -38,3 +38,23 @@ export declare namespace CreateReleaseAction { error?: errors.ApplicationError | errors.ValidationError | errors.NotFoundError; } } + +/** + * GET /content-releases/:id/actions - Get all release actions + */ +export declare namespace GetReleaseActions { + export interface Request { + params: { + releaseId: Release['id']; + }; + query?: Partial>; + } + + export interface Response { + data: ReleaseAction[]; + meta: { + pagination: Pagination; + }; + error?: errors.ApplicationError | errors.NotFoundError; + } +} diff --git a/packages/core/content-releases/shared/contracts/releases.ts b/packages/core/content-releases/shared/contracts/releases.ts index 78c2b5bb37..845b697957 100644 --- a/packages/core/content-releases/shared/contracts/releases.ts +++ b/packages/core/content-releases/shared/contracts/releases.ts @@ -2,6 +2,7 @@ import type { Entity } from '../types'; import type { ReleaseAction } from './release-actions'; import type { UserInfo } from '../types'; import { errors } from '@strapi/utils'; +import { UID } from '@strapi/types'; export interface Release extends Entity { name: string; @@ -9,7 +10,7 @@ export interface Release extends Entity { actions: ReleaseAction[]; } -type Pagination = { +export type Pagination = { page: number; pageSize: number; pageCount: number;