diff --git a/packages/core/core/src/services/document-service/repository.ts b/packages/core/core/src/services/document-service/repository.ts index f9b9894f0d..746519fbf8 100644 --- a/packages/core/core/src/services/document-service/repository.ts +++ b/packages/core/core/src/services/document-service/repository.ts @@ -1,7 +1,8 @@ import { omit, assoc, merge, curry } from 'lodash/fp'; -import { async, contentTypes as contentTypesUtils } from '@strapi/utils'; +import { async, contentTypes as contentTypesUtils, validate } from '@strapi/utils'; +import { UID } from '@strapi/types'; import { wrapInTransaction, type RepositoryFactoryMethod } from './common'; import * as DP from './draft-and-publish'; import * as i18n from './internationalization'; @@ -15,10 +16,38 @@ import { transformParamsToQuery } from './transform/query'; import { transformParamsDocumentId } from './transform/id-transform'; import { createEventManager } from './events'; +const { validators } = validate; + +// we have to typecast to reconcile the differences between validator and database getModel +const getModel = ((schema: UID.Schema) => strapi.getModel(schema)) as (schema: string) => any; + export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => { const contentType = strapi.contentType(uid); const hasDraftAndPublish = contentTypesUtils.hasDraftAndPublish(contentType); + // Define the validations that should be performed + const sortValidations = ['nonAttributesOperators', 'dynamicZones', 'morphRelations']; + const fieldValidations = ['scalarAttributes']; + const filtersValidations = ['nonAttributesOperators', 'dynamicZones', 'morphRelations']; + const populateValidations = { + sort: sortValidations, + field: fieldValidations, + filters: filtersValidations, + populate: ['nonAttributesOperators'], + }; + + const validateParams = async (params: any) => { + const ctx = { schema: contentType, getModel }; + await validators.validateFilters(ctx, params.filters, filtersValidations); + await validators.validateSort(ctx, params.sort, sortValidations); + await validators.validateFields(ctx, params.fields, fieldValidations); + await validators.validatePopulate(ctx, params.populate, populateValidations); + + // TODO: add validate status, locale, pagination + + return params; + }; + const entries = createEntriesService(uid); const eventManager = createEventManager(strapi, uid); @@ -26,6 +55,7 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => { async function findMany(params = {} as any) { const query = await async.pipe( + validateParams, DP.defaultToDraft, DP.statusToLookup(contentType), i18n.defaultLocale(contentType), @@ -39,6 +69,7 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => { async function findFirst(params = {} as any) { const query = await async.pipe( + validateParams, DP.defaultToDraft, DP.statusToLookup(contentType), i18n.defaultLocale(contentType), @@ -55,6 +86,7 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => { const { documentId, ...params } = opts; const query = await async.pipe( + validateParams, DP.defaultToDraft, DP.statusToLookup(contentType), i18n.defaultLocale(contentType), @@ -71,6 +103,7 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => { const { documentId, ...params } = opts; const query = await async.pipe( + validateParams, omit('status'), i18n.defaultLocale(contentType), i18n.multiLocaleToLookup(contentType), @@ -98,6 +131,7 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => { const { documentId, ...params } = opts; const queryParams = await async.pipe( + validateParams, DP.filterDataPublishedAt, DP.setStatusToDraft(contentType), DP.statusToData(contentType), @@ -123,6 +157,7 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => { const { documentId, ...params } = opts; const queryParams = await async.pipe( + validateParams, DP.filterDataPublishedAt, i18n.defaultLocale(contentType), i18n.multiLocaleToLookup(contentType) @@ -143,6 +178,7 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => { const clonedEntries = await async.map( entriesToClone, async.pipe( + validateParams, omit('id'), // assign new documentId assoc('documentId', createDocumentId()), @@ -161,6 +197,7 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => { const { documentId, ...params } = opts; const queryParams = await async.pipe( + validateParams, DP.filterDataPublishedAt, DP.setStatusToDraft(contentType), DP.statusToLookup(contentType), @@ -212,6 +249,7 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => { async function count(params = {} as any) { const query = await async.pipe( + validateParams, DP.defaultStatus(contentType), DP.statusToLookup(contentType), i18n.defaultLocale(contentType), @@ -226,6 +264,7 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => { const { documentId, ...params } = opts; const queryParams = await async.pipe( + validateParams, i18n.defaultLocale(contentType), i18n.multiLocaleToLookup(contentType) )(params); @@ -266,6 +305,7 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => { const { documentId, ...params } = opts; const query = await async.pipe( + validateParams, i18n.defaultLocale(contentType), i18n.multiLocaleToLookup(contentType), transformParamsToQuery(uid), @@ -284,6 +324,7 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => { const { documentId, ...params } = opts; const queryParams = await async.pipe( + validateParams, i18n.defaultLocale(contentType), i18n.multiLocaleToLookup(contentType) )(params); diff --git a/packages/core/utils/src/traverse/factory.ts b/packages/core/utils/src/traverse/factory.ts index eb4d382f02..e72678cac9 100644 --- a/packages/core/utils/src/traverse/factory.ts +++ b/packages/core/utils/src/traverse/factory.ts @@ -79,7 +79,7 @@ interface CommonHandler { export interface TransformUtils { remove(key: string): void; - set(key: string, valeu: unknown): void; + set(key: string, value: unknown): void; recurse: Traverse; } diff --git a/packages/core/utils/src/traverse/query-fields.ts b/packages/core/utils/src/traverse/query-fields.ts index ab11bef5a5..468915dcb1 100644 --- a/packages/core/utils/src/traverse/query-fields.ts +++ b/packages/core/utils/src/traverse/query-fields.ts @@ -2,14 +2,24 @@ import { curry, isArray, isString, eq, trim, constant } from 'lodash/fp'; import traverseFactory from './factory'; -const isStringArray = (value: unknown): value is string[] => - isArray(value) && value.every(isString); +const isStringArray = (value: unknown): value is string[] => { + return isArray(value) && value.every(isString); +}; const fields = traverseFactory() // Intercept array of strings + // e.g. fields=['title', 'description'] .intercept(isStringArray, async (visitor, options, fields, { recurse }) => { return Promise.all(fields.map((field) => recurse(visitor, options, field))); }) + // Intercept comma separated fields (as string) + // e.g. fields='title,description' + .intercept( + (value): value is string => isString(value) && value.includes(','), + (visitor, options, fields, { recurse }) => { + return Promise.all(fields.split(',').map((field) => recurse(visitor, options, field))); + } + ) // Return wildcards as is .intercept((value): value is string => eq('*', value), constant('*')) // Parse string values diff --git a/packages/core/utils/src/traverse/query-populate.ts b/packages/core/utils/src/traverse/query-populate.ts index a4e32d3eb7..2bc13f9794 100644 --- a/packages/core/utils/src/traverse/query-populate.ts +++ b/packages/core/utils/src/traverse/query-populate.ts @@ -132,6 +132,8 @@ const populate = traverseFactory() }, })) .ignore(({ key, attribute }) => { + // we don't want to recurse using traversePopulate and instead let + // the visitors recurse with the appropriate traversal (sort, filters, etc...) return ['sort', 'filters', 'fields'].includes(key) && !attribute; }) .on( diff --git a/packages/core/utils/src/validate/utils.ts b/packages/core/utils/src/validate/utils.ts index 2b75f6bd5b..7f025664fa 100644 --- a/packages/core/utils/src/validate/utils.ts +++ b/packages/core/utils/src/validate/utils.ts @@ -1,6 +1,6 @@ import { ValidationError } from '../errors'; -export const throwInvalidKey = ({ key, path }: { key: string; path?: string | null }) => { +export const throwInvalidKey = ({ key, path }: { key: string; path?: string | null }): never => { const msg = path && path !== key ? `Invalid key ${key} at ${path}` : `Invalid key ${key}`; throw new ValidationError(msg, { @@ -8,3 +8,17 @@ export const throwInvalidKey = ({ key, path }: { key: string; path?: string | nu path, }); }; + +// lodash/fp curry does not detect async methods, so we'll use our own that is typed correctly +export const asyncCurry = ( + fn: (...args: A) => Promise +): ((...args: Partial) => any) => { + const curried = (...args: unknown[]): unknown => { + if (args.length >= fn.length) { + return fn(...(args as A)); + } + return (...moreArgs: unknown[]) => curried(...args, ...moreArgs); + }; + + return curried; +}; diff --git a/packages/core/utils/src/validate/validators.ts b/packages/core/utils/src/validate/validators.ts index e2b17aefd0..2ef4e37927 100644 --- a/packages/core/utils/src/validate/validators.ts +++ b/packages/core/utils/src/validate/validators.ts @@ -1,4 +1,4 @@ -import { curry, isEmpty, isNil } from 'lodash/fp'; +import { isEmpty, isNil, isObject } from 'lodash/fp'; import { pipe as pipeAsync } from '../async'; import traverseEntity from '../traverse-entity'; @@ -11,8 +11,9 @@ import { } from '../traverse'; import { throwPassword, throwPrivate, throwDynamicZones, throwMorphToRelations } from './visitors'; import { isOperator } from '../operators'; -import { throwInvalidKey } from './utils'; +import { asyncCurry, throwInvalidKey } from './utils'; import type { Model, Data } from '../types'; +import parseType from '../parse-type'; const { ID_ATTRIBUTE, DOC_ID_ATTRIBUTE } = constants; @@ -21,7 +22,7 @@ interface Context { getModel: (model: string) => Model; } -const throwPasswords = (ctx: Context) => async (entity: Data) => { +export const throwPasswords = (ctx: Context) => async (entity: Data) => { if (!ctx.schema) { throw new Error('Missing schema in throwPasswords'); } @@ -29,176 +30,386 @@ const throwPasswords = (ctx: Context) => async (entity: Data) => { return traverseEntity(throwPassword, ctx, entity); }; -const defaultValidateFilters = curry((ctx: Context, filters: unknown) => { - // TODO: schema checks should check that it is a validate schema with yup - if (!ctx.schema) { - throw new Error('Missing schema in defaultValidateFilters'); - } +type AnyFunc = (...args: any[]) => any; + +export const FILTER_TRAVERSALS = [ + 'nonAttributesOperators', + 'dynamicZones', + 'morphRelations', + 'passwords', + 'private', +]; + +export const validateFilters = asyncCurry( + async (ctx: Context, filters: unknown, include: (typeof FILTER_TRAVERSALS)[number][]) => { + // TODO: schema checks should check that it is a valid schema with yup + if (!ctx.schema) { + throw new Error('Missing schema in defaultValidateFilters'); + } + + // Build the list of functions conditionally + const functionsToApply: Array = []; - return pipeAsync( // keys that are not attributes or valid operators - traverseQueryFilters(({ key, attribute, path }) => { - // ID is not an attribute per se, so we need to make - // an extra check to ensure we're not removing it - if ([ID_ATTRIBUTE, DOC_ID_ATTRIBUTE].includes(key)) { - return; - } + if (include.includes('nonAttributesOperators')) { + functionsToApply.push( + traverseQueryFilters(({ key, attribute, path }) => { + // ID is not an attribute per se, so we need to make + // an extra check to ensure we're not removing it + if ([ID_ATTRIBUTE, DOC_ID_ATTRIBUTE].includes(key)) { + return; + } - const isAttribute = !!attribute; + const isAttribute = !!attribute; - if (!isAttribute && !isOperator(key)) { - throwInvalidKey({ key, path: path.attribute }); - } - }, ctx), - // dynamic zones from filters - traverseQueryFilters(throwDynamicZones, ctx), - // morphTo relations from filters; because you can't have deep filtering on morph relations - traverseQueryFilters(throwMorphToRelations, ctx), - // passwords from filters - traverseQueryFilters(throwPassword, ctx), - // private from filters - traverseQueryFilters(throwPrivate, ctx) - // we allow empty objects to validate and only sanitize them out, so that users may write "lazy" queries without checking their params exist - )(filters); + if (!isAttribute && !isOperator(key)) { + throwInvalidKey({ key, path: path.attribute }); + } + }, ctx) + ); + } + + if (include.includes('dynamicZones')) { + functionsToApply.push(traverseQueryFilters(throwDynamicZones, ctx)); + } + + if (include.includes('morphRelations')) { + functionsToApply.push(traverseQueryFilters(throwMorphToRelations, ctx)); + } + + if (include.includes('passwords')) { + functionsToApply.push(traverseQueryFilters(throwPassword, ctx)); + } + + if (include.includes('private')) { + functionsToApply.push(traverseQueryFilters(throwPrivate, ctx)); + } + + // Return directly if no validation functions are provided + if (functionsToApply.length === 0) { + return filters; + } + + return pipeAsync(...functionsToApply)(filters); + } +); + +export const defaultValidateFilters = asyncCurry(async (ctx: Context, filters: unknown) => { + return validateFilters(ctx, filters, FILTER_TRAVERSALS); }); -const defaultValidateSort = curry((ctx: Context, sort: unknown) => { - if (!ctx.schema) { - throw new Error('Missing schema in defaultValidateSort'); +export const SORT_TRAVERSALS = [ + 'nonAttributesOperators', + 'dynamicZones', + 'morphRelations', + 'passwords', + 'private', + 'nonScalarEmptyKeys', +]; + +export const validateSort = asyncCurry( + async (ctx: Context, sort: unknown, include: (typeof SORT_TRAVERSALS)[number][]) => { + if (!ctx.schema) { + throw new Error('Missing schema in defaultValidateSort'); + } + + // Build the list of functions conditionally based on the include array + const functionsToApply: Array = []; + + // Validate non attribute keys + if (include.includes('nonAttributesOperators')) { + functionsToApply.push( + traverseQuerySort(({ key, attribute, path }) => { + // ID is not an attribute per se, so we need to make + // an extra check to ensure we're not removing it + if ([ID_ATTRIBUTE, DOC_ID_ATTRIBUTE].includes(key)) { + return; + } + + if (!attribute) { + throwInvalidKey({ key, path: path.attribute }); + } + }, ctx) + ); + } + + // Validate dynamic zones from sort + if (include.includes('dynamicZones')) { + functionsToApply.push(traverseQuerySort(throwDynamicZones, ctx)); + } + + // Validate morphTo relations from sort + if (include.includes('morphRelations')) { + functionsToApply.push(traverseQuerySort(throwMorphToRelations, ctx)); + } + + // Validate passwords from sort + if (include.includes('passwords')) { + functionsToApply.push(traverseQuerySort(throwPassword, ctx)); + } + + // Validate private from sort + if (include.includes('private')) { + functionsToApply.push(traverseQuerySort(throwPrivate, ctx)); + } + + // Validate non-scalar empty keys + if (include.includes('nonScalarEmptyKeys')) { + functionsToApply.push( + traverseQuerySort(({ key, attribute, value, path }) => { + // ID is not an attribute per se, so we need to make + // an extra check to ensure we're not removing it + if ([ID_ATTRIBUTE, DOC_ID_ATTRIBUTE].includes(key)) { + return; + } + + if (!isScalarAttribute(attribute) && isEmpty(value)) { + throwInvalidKey({ key, path: path.attribute }); + } + }, ctx) + ); + } + + // Return directly if no validation functions are provided + if (functionsToApply.length === 0) { + return sort; + } + + return pipeAsync(...functionsToApply)(sort); } +); - return pipeAsync( - // non attribute keys - traverseQuerySort(({ key, attribute, path }) => { - // ID is not an attribute per se, so we need to make - // an extra check to ensure we're not removing it - if ([ID_ATTRIBUTE, DOC_ID_ATTRIBUTE].includes(key)) { - return; - } - - if (!attribute) { - throwInvalidKey({ key, path: path.attribute }); - } - }, ctx), - // dynamic zones from sort - traverseQuerySort(throwDynamicZones, ctx), - // morphTo relations from sort - traverseQuerySort(throwMorphToRelations, ctx), - // private from sort - traverseQuerySort(throwPrivate, ctx), - // passwords from filters - traverseQuerySort(throwPassword, ctx), - // keys for empty non-scalar values - traverseQuerySort(({ key, attribute, value, path }) => { - // ID is not an attribute per se, so we need to make - // an extra check to ensure we're not removing it - if ([ID_ATTRIBUTE, DOC_ID_ATTRIBUTE].includes(key)) { - return; - } - - if (!isScalarAttribute(attribute) && isEmpty(value)) { - throwInvalidKey({ key, path: path.attribute }); - } - }, ctx) - )(sort); +export const defaultValidateSort = asyncCurry(async (ctx: Context, sort: unknown) => { + return validateSort(ctx, sort, SORT_TRAVERSALS); }); -const defaultValidateFields = curry((ctx: Context, fields: unknown) => { - if (!ctx.schema) { - throw new Error('Missing schema in defaultValidateFields'); - } +export const FIELDS_TRAVERSALS = ['scalarAttributes', 'privateFields', 'passwordFields']; + +export const validateFields = asyncCurry( + async (ctx: Context, fields: unknown, include: (typeof FIELDS_TRAVERSALS)[number][]) => { + if (!ctx.schema) { + throw new Error('Missing schema in defaultValidateFields'); + } + // Build the list of functions conditionally based on the include array + const functionsToApply: Array = []; - return pipeAsync( // Only allow scalar attributes - traverseQueryFields(({ key, attribute, path }) => { - // ID is not an attribute per se, so we need to make - // an extra check to ensure we're not throwing because of it - if ([ID_ATTRIBUTE, DOC_ID_ATTRIBUTE].includes(key)) { - return; - } + if (include.includes('scalarAttributes')) { + functionsToApply.push( + traverseQueryFields(({ key, attribute, path }) => { + // ID is not an attribute per se, so we need to make + // an extra check to ensure we're not throwing because of it + if ([ID_ATTRIBUTE, DOC_ID_ATTRIBUTE].includes(key)) { + return; + } - if (isNil(attribute) || !isScalarAttribute(attribute)) { - throwInvalidKey({ key, path: path.attribute }); - } - }, ctx), - // private fields - traverseQueryFields(throwPrivate, ctx), - // password fields - traverseQueryFields(throwPassword, ctx) - )(fields); + if (isNil(attribute) || !isScalarAttribute(attribute)) { + throwInvalidKey({ key, path: path.attribute }); + } + }, ctx) + ); + } + + // Private fields + if (include.includes('privateFields')) { + functionsToApply.push(traverseQueryFields(throwPrivate, ctx)); + } + + // Password fields + if (include.includes('passwordFields')) { + functionsToApply.push(traverseQueryFields(throwPassword, ctx)); + } + + // Return directly if no validation functions are provided + if (functionsToApply.length === 0) { + return fields; + } + + return pipeAsync(...functionsToApply)(fields); + } +); + +export const defaultValidateFields = asyncCurry(async (ctx: Context, fields: unknown) => { + return validateFields(ctx, fields, FIELDS_TRAVERSALS); }); -const defaultValidatePopulate = curry((ctx: Context, populate: unknown) => { +export const POPULATE_TRAVERSALS = ['nonAttributesOperators', 'private']; + +export const validatePopulate = asyncCurry( + async ( + ctx: Context, + populate: unknown, + includes: { + fields?: (typeof FIELDS_TRAVERSALS)[number][]; + sort?: (typeof SORT_TRAVERSALS)[number][]; + filters?: (typeof FILTER_TRAVERSALS)[number][]; + populate?: (typeof POPULATE_TRAVERSALS)[number][]; + } + ) => { + if (!ctx.schema) { + throw new Error('Missing schema in defaultValidatePopulate'); + } + // Build the list of functions conditionally based on the include array + const functionsToApply: Array = []; + + // Always include the main traversal function + functionsToApply.push( + traverseQueryPopulate(async ({ key, path, value, schema, attribute, getModel }, { set }) => { + if (attribute) { + const isPopulatableAttribute = ['relation', 'dynamiczone', 'component', 'media'].includes( + attribute.type + ); + + // Throw on non-populate attributes + if (!isPopulatableAttribute) { + throwInvalidKey({ key, path: path.raw }); + } + + // Valid populatable attribute, so return + return; + } + + // If we're looking at a populate fragment, ensure its target is valid + if (key === 'on') { + // Populate fragment should always be an object + if (!isObject(value)) { + return throwInvalidKey({ key, path: path.raw }); + } + + const targets = Object.keys(value); + + for (const target of targets) { + const model = getModel(target); + + // If a target is invalid (no matching model), then raise an error + if (!model) { + throwInvalidKey({ key: target, path: `${path.raw}.${target}` }); + } + } + + // If the fragment's target is fine, then let it pass + return; + } + + // Ignore plain wildcards + if (key === '' && value === '*') { + return; + } + + // Ensure count is a boolean + if (key === 'count') { + try { + parseType({ type: 'boolean', value }); + return; + } catch { + throwInvalidKey({ key, path: path.attribute }); + } + } + + // Allowed boolean-like keywords should be ignored + try { + parseType({ type: 'boolean', value: key }); + // Key is an allowed boolean-like keyword, skipping validation... + return; + } catch { + // Continue, because it's not a boolean-like + } + + // Handle nested `sort` validation with custom or default traversals + if (key === 'sort') { + set( + key, + await validateSort( + { + schema, + getModel, + }, + value, // pass the sort value + includes?.sort || SORT_TRAVERSALS + ) + ); + return; + } + + // Handle nested `filters` validation with custom or default traversals + if (key === 'filters') { + set( + key, + await validateFilters( + { + schema, + getModel, + }, + value, // pass the filters value + includes?.filters || FILTER_TRAVERSALS + ) + ); + return; + } + + // Handle nested `fields` validation with custom or default traversals + if (key === 'fields') { + set( + key, + await validateFields( + { + schema, + getModel, + }, + value, // pass the fields value + includes?.fields || FIELDS_TRAVERSALS + ) + ); + return; + } + + // Handle recursive nested `populate` validation with the same include object + if (key === 'populate') { + set( + key, + await validatePopulate( + { + schema, + getModel, + }, + value, // pass the nested populate value + includes // pass down the same includes object + ) + ); + return; + } + + // Throw an error if non-attribute operators are included in the populate array + if (includes?.populate?.includes('nonAttributesOperators')) { + throwInvalidKey({ key, path: path.attribute }); + } + }, ctx) + ); + + // Conditionally traverse for private fields only if 'private' is included + if (includes?.populate?.includes('private')) { + functionsToApply.push(traverseQueryPopulate(throwPrivate, ctx)); + } + + // Return directly if no validation functions are provided + if (functionsToApply.length === 0) { + return populate; + } + + return pipeAsync(...functionsToApply)(populate); + } +); + +export const defaultValidatePopulate = asyncCurry(async (ctx: Context, populate: unknown) => { if (!ctx.schema) { throw new Error('Missing schema in defaultValidatePopulate'); } - return pipeAsync( - traverseQueryPopulate(async ({ key, value, schema, attribute, getModel }, { set }) => { - if (attribute) { - return; - } - - if (key === 'sort') { - set( - key, - await defaultValidateSort( - { - schema, - getModel, - }, - value - ) - ); - } - - if (key === 'filters') { - set( - key, - await defaultValidateFilters( - { - schema, - getModel, - }, - value - ) - ); - } - - if (key === 'fields') { - set( - key, - await defaultValidateFields( - { - schema, - getModel, - }, - value - ) - ); - } - - if (key === 'populate') { - set( - key, - await defaultValidatePopulate( - { - schema, - getModel, - }, - value - ) - ); - } - }, ctx), - // Remove private fields - traverseQueryPopulate(throwPrivate, ctx) - )(populate); + // Call validatePopulate and include all validations by passing in full traversal arrays + return validatePopulate(ctx, populate, { + filters: FILTER_TRAVERSALS, + sort: SORT_TRAVERSALS, + fields: FIELDS_TRAVERSALS, + populate: POPULATE_TRAVERSALS, + }); }); -export { - throwPasswords, - defaultValidateFilters, - defaultValidateSort, - defaultValidateFields, - defaultValidatePopulate, -}; diff --git a/packages/plugins/i18n/admin/src/components/CMHeaderActions.tsx b/packages/plugins/i18n/admin/src/components/CMHeaderActions.tsx index be3ac60e83..96fab9c5c6 100644 --- a/packages/plugins/i18n/admin/src/components/CMHeaderActions.tsx +++ b/packages/plugins/i18n/admin/src/components/CMHeaderActions.tsx @@ -551,7 +551,7 @@ const BulkLocaleAction: DocumentActionComponent = ({ } }, [isDraftRelationsError, toggleNotification, formatAPIError]); - if (!schema?.options?.draftAndPublish ?? false) { + if (!schema?.options?.draftAndPublish) { return null; } diff --git a/packages/plugins/i18n/server/src/bootstrap.ts b/packages/plugins/i18n/server/src/bootstrap.ts index 49d71b3282..e54af973ba 100644 --- a/packages/plugins/i18n/server/src/bootstrap.ts +++ b/packages/plugins/i18n/server/src/bootstrap.ts @@ -37,7 +37,8 @@ const registerModelsHooks = () => { // Use the id and populate built from non localized fields to get the full // result let resultID; - if (Array.isArray(result?.entries)) { + // TODO: fix bug where an empty array can be returned + if (Array.isArray(result?.entries) && result.entries[0]?.id) { resultID = result.entries[0].id; } else if (result?.id) { resultID = result.id; diff --git a/tests/api/core/strapi/api/validate/validate-query.test.api.js b/tests/api/core/strapi/api/validate/validate-query.test.api.js index 9a03e6f827..edf40dca0d 100644 --- a/tests/api/core/strapi/api/validate/validate-query.test.api.js +++ b/tests/api/core/strapi/api/validate/validate-query.test.api.js @@ -428,6 +428,17 @@ describe('Core API - Validate', () => { expect(res.status).toEqual(400); }); }); + + describe('invalid modifier', () => { + it.each([ + ['name_fake (asc)', { name_fake: 'asc' }, defaultDocumentsOrder], + ['name_fake (desc)', { name_fake: 'desc' }, defaultDocumentsOrder], + ])('Error with sort: %s', async (_s, sort, order) => { + const res = await rq.get('/api/documents', { qs: { sort } }); + + expect(res.status).toEqual(400); + }); + }); }); describe('Password', () => { @@ -444,12 +455,12 @@ describe('Core API - Validate', () => { }); // TODO: Nested sort returns duplicate results. Add back those tests when the issue will be fixed - describe.skip('Relation', () => { + describe('Relation', () => { describe('Scalar', () => { - describe('Basic (no modifiers)', () => { + describe.skip('Basic (no modifiers)', () => { it.each([ ['relations.name (asc)', { relations: { name: 'asc' } }, [1, 3, 2]], - ['relations.sname (desc)', { relations: { name: 'desc' } }, [2, 1, 3]], + ['relations.name (desc)', { relations: { name: 'desc' } }, [2, 1, 3]], ])('Successfully sort: %s', async (_s, sort, order) => { const res = await rq.get('/api/documents', { qs: { sort } }); @@ -538,6 +549,18 @@ describe('Core API - Validate', () => { expect(res.status).toEqual(400); }); }); + + describe('invalid modifier', () => { + it.each([ + ['name_invalid', 'name_invalid', defaultDocumentsOrder], + ['name_invalid (asc)', 'name_invalid:asc', defaultDocumentsOrder], + ['name_invalid (desc)', 'name_invalid:desc', defaultDocumentsOrder], + ])('Error with sort: %s', async (_s, sort, order) => { + const res = await rq.get('/api/documents', { qs: { sort } }); + + expect(res.status).toEqual(400); + }); + }); }); describe('Password', () => { @@ -605,6 +628,17 @@ describe('Core API - Validate', () => { expect(res.status).toEqual(400); }); }); + + describe('invalid modifier', () => { + it.each([ + ['name_invalid (asc)', [{ name_invalid: 'asc' }], defaultDocumentsOrder], + ['name_invalid (desc)', [{ name_private: 'desc' }], defaultDocumentsOrder], + ])('Error on sort: %s', async (_s, sort, order) => { + const res = await rq.get('/api/documents', { qs: { sort } }); + + expect(res.status).toEqual(400); + }); + }); }); describe('Password', () => { diff --git a/tests/api/core/strapi/document-service/resources/schemas/article.js b/tests/api/core/strapi/document-service/resources/schemas/article.js index 85ea08fb29..2880b23bb1 100644 --- a/tests/api/core/strapi/document-service/resources/schemas/article.js +++ b/tests/api/core/strapi/document-service/resources/schemas/article.js @@ -86,6 +86,12 @@ module.exports = { relation: 'manyToMany', target: 'api::category.category', }, + categories_private: { + private: true, + type: 'relation', + relation: 'manyToMany', + target: 'api::category.category', + }, dz: { pluginOptions: { i18n: { diff --git a/tests/api/core/strapi/document-service/validation/validation.test.api.ts b/tests/api/core/strapi/document-service/validation/validation.test.api.ts new file mode 100644 index 0000000000..dc6fb4c2f6 --- /dev/null +++ b/tests/api/core/strapi/document-service/validation/validation.test.api.ts @@ -0,0 +1,254 @@ +import type { Core, Modules } from '@strapi/types'; +import { errors } from '@strapi/utils'; +import { createTestSetup, destroyTestSetup } from '../../../../utils/builder-helper'; +import resources from '../resources/index'; +import { ARTICLE_UID } from '../utils'; + +let strapi: Core.Strapi; + +describe('Document Service Validations', () => { + let testUtils; + + beforeAll(async () => { + testUtils = await createTestSetup(resources); + strapi = testUtils.strapi; + }); + + afterAll(async () => { + await destroyTestSetup(testUtils); + }); + + const methods = [ + 'findMany', + 'findFirst', + 'findOne', + 'publish', + 'delete', + 'create', + 'unpublish', + 'clone', + 'update', + 'discardDraft', + 'count', + ]; + + describe.each(methods)('%s method', (methodName) => { + describe('sort', () => { + it('should not throw on existing attribute name', async () => { + await strapi.documents(ARTICLE_UID)[methodName]({ sort: 'title' }); + }); + + it('should not throw on private attribute', async () => { + await strapi.documents(ARTICLE_UID)[methodName]({ sort: 'private' }); + }); + + it('should not throw on password attribute', async () => { + await strapi.documents(ARTICLE_UID)[methodName]({ sort: 'password' }); + }); + + it('should not throw on existing nested (object) key', async () => { + await strapi.documents(ARTICLE_UID)[methodName]({ + populate: { categories: { sort: { name: 'asc' } } }, + }); + }); + + it('should not throw on existing nested (dot separated) key', async () => { + await strapi.documents(ARTICLE_UID)[methodName]({ + sort: 'categories.name', + populate: 'categories', + }); + }); + + it('should throw ValidationError on invalid key', async () => { + await expect( + strapi.documents(ARTICLE_UID)[methodName]({ sort: 'fakekey' }) + ).rejects.toThrow(errors.ValidationError); + }); + }); + + describe('filters', () => { + it('should not throw on existing attribute equality', async () => { + await strapi.documents(ARTICLE_UID)[methodName]({ + filters: { + title: 'Hello World', + }, + }); + }); + + it('should not throw on private attribute', async () => { + await strapi.documents(ARTICLE_UID)[methodName]({ + filters: { + private: 'Hello World', + }, + }); + }); + + it('should not throw on password attribute', async () => { + await strapi.documents(ARTICLE_UID)[methodName]({ + filters: { + password: 'Hello World', + }, + }); + }); + + it('should not throw on existing nested conditions', async () => { + await strapi.documents(ARTICLE_UID)[methodName]({ + filters: { + title: { + $not: { + $contains: 'Hello World', + }, + }, + }, + }); + }); + + it('should throw ValidationError on invalid key', async () => { + await expect( + strapi.documents(ARTICLE_UID)[methodName]({ + filters: { + fakekey: 'Hello World', + }, + }) + ).rejects.toThrow(errors.ValidationError); + }); + }); + + describe('fields', () => { + it('should not throw on existing attribute equality', async () => { + await strapi.documents(ARTICLE_UID)[methodName]({ + fields: ['title'], + }); + }); + + it('should not throw on private attribute', async () => { + await strapi.documents(ARTICLE_UID)[methodName]({ + fields: ['private'], + }); + }); + + it('should not throw on password attribute', async () => { + await strapi.documents(ARTICLE_UID)[methodName]({ + fields: ['password'], + }); + }); + + it('should throw ValidationError on invalid key', async () => { + await expect( + strapi.documents(ARTICLE_UID)[methodName]({ + fields: ['title', 'fakekey'], + }) + ).rejects.toThrow(errors.ValidationError); + }); + + it('should not throw on valid comma separated keys', async () => { + await strapi.documents(ARTICLE_UID)[methodName]({ fields: 'title,password,private' }); + }); + + it('should throw on invalid comma separated keys', async () => { + await expect( + strapi.documents(ARTICLE_UID)[methodName]({ fields: 'title,invalid' }) + ).rejects.toThrow(errors.ValidationError); + }); + }); + + describe('populate', () => { + it('should not throw on populatable attribute', async () => { + await strapi.documents(ARTICLE_UID)[methodName]({ + populate: ['categories'], + }); + }); + + it('should not throw on private attribute', async () => { + await strapi.documents(ARTICLE_UID)[methodName]({ + populate: ['categories_private'], + }); + }); + + it('should not throw on wildcard *', async () => { + await strapi.documents(ARTICLE_UID)[methodName]({ + populate: '*', + }); + }); + + it('should not throw on dz (boolean)', async () => { + await strapi.documents(ARTICLE_UID)[methodName]({ + populate: { + identifiersDz: true, + }, + }); + }); + + it('should not throw on dz - comp (boolean)', async () => { + await strapi.documents(ARTICLE_UID)[methodName]({ + populate: { + identifiersDz: { + on: { + 'article.compo-unique-all': true, + }, + }, + }, + }); + }); + + it('should not throw on dz', async () => { + await strapi.documents(ARTICLE_UID)[methodName]({ + populate: { + identifiersDz: { + on: { + 'article.compo-unique-all': { + fields: ['ComponentTextShort'], + }, + }, + }, + }, + }); + }); + + it('should not throw on nested wildcard populate', async () => { + await strapi.documents(ARTICLE_UID)[methodName]({ + populate: { + identifiersDz: { + on: { + 'article.compo-unique-all': { + populate: '*', + }, + }, + }, + }, + }); + }); + + // TODO: functionality is not yet implemented + it('should throw ValidationError on invalid dz component', async () => { + await expect( + strapi.documents(ARTICLE_UID)[methodName]({ + populate: { + identifiersDz: { + on: { + invalidkey: true, + }, + }, + }, + }) + ).rejects.toThrow(errors.ValidationError); + }); + + it('should throw ValidationError on non-populatable attribute', async () => { + await expect( + strapi.documents(ARTICLE_UID)[methodName]({ + populate: ['title'], + }) + ).rejects.toThrow(errors.ValidationError); + }); + + it('should throw ValidationError on invalid key', async () => { + await expect( + strapi.documents(ARTICLE_UID)[methodName]({ + populate: ['categories', 'fakekey'], + }) + ).rejects.toThrow(errors.ValidationError); + }); + }); + }); +});