mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
Only allow null filter operators for fields with "conceal" special (#15010)
* Only allow null filter operators for fields with "conceal" special * Add tests * Use GraphQLHash type for fields with "conceal" special Co-authored-by: Rijk van Zanten <rijkvanzanten@me.com>
This commit is contained in:
@@ -401,7 +401,10 @@ export class GraphQLService {
|
||||
CollectionTypes[collection.collection] = schemaComposer.createObjectTC({
|
||||
name: action === 'read' ? collection.collection : `${action}_${collection.collection}`,
|
||||
fields: Object.values(collection.fields).reduce((acc, field) => {
|
||||
let type: GraphQLScalarType | GraphQLNonNull<GraphQLNullableType> = getGraphQLType(field.type);
|
||||
let type: GraphQLScalarType | GraphQLNonNull<GraphQLNullableType> = getGraphQLType(
|
||||
field.type,
|
||||
field.special
|
||||
);
|
||||
|
||||
// GraphQL doesn't differentiate between not-null and has-to-be-submitted. We
|
||||
// can't non-null in update, as that would require every not-nullable field to be
|
||||
@@ -792,7 +795,7 @@ export class GraphQLService {
|
||||
ReadableCollectionFilterTypes[collection.collection] = schemaComposer.createInputTC({
|
||||
name: `${collection.collection}_filter`,
|
||||
fields: Object.values(collection.fields).reduce((acc, field) => {
|
||||
const graphqlType = getGraphQLType(field.type);
|
||||
const graphqlType = getGraphQLType(field.type, field.special);
|
||||
|
||||
let filterOperatorType: InputTypeComposer;
|
||||
|
||||
@@ -855,7 +858,7 @@ export class GraphQLService {
|
||||
AggregatedFields[collection.collection] = schemaComposer.createObjectTC({
|
||||
name: `${collection.collection}_aggregated_fields`,
|
||||
fields: Object.values(collection.fields).reduce((acc, field) => {
|
||||
const graphqlType = getGraphQLType(field.type);
|
||||
const graphqlType = getGraphQLType(field.type, field.special);
|
||||
|
||||
switch (graphqlType) {
|
||||
case GraphQLInt:
|
||||
@@ -905,7 +908,7 @@ export class GraphQLService {
|
||||
};
|
||||
|
||||
const hasNumericAggregates = Object.values(collection.fields).some((field) => {
|
||||
const graphqlType = getGraphQLType(field.type);
|
||||
const graphqlType = getGraphQLType(field.type, field.special);
|
||||
|
||||
if (graphqlType === GraphQLInt || graphqlType === GraphQLFloat) {
|
||||
return true;
|
||||
@@ -2242,7 +2245,9 @@ export class GraphQLService {
|
||||
name: 'directus_collections_meta',
|
||||
fields: Object.values(schema.read.collections['directus_collections'].fields).reduce((acc, field) => {
|
||||
acc[field.field] = {
|
||||
type: field.nullable ? getGraphQLType(field.type) : GraphQLNonNull(getGraphQLType(field.type)),
|
||||
type: field.nullable
|
||||
? getGraphQLType(field.type, field.special)
|
||||
: GraphQLNonNull(getGraphQLType(field.type, field.special)),
|
||||
description: field.note,
|
||||
};
|
||||
|
||||
@@ -2297,7 +2302,9 @@ export class GraphQLService {
|
||||
name: 'directus_fields_meta',
|
||||
fields: Object.values(schema.read.collections['directus_fields'].fields).reduce((acc, field) => {
|
||||
acc[field.field] = {
|
||||
type: field.nullable ? getGraphQLType(field.type) : GraphQLNonNull(getGraphQLType(field.type)),
|
||||
type: field.nullable
|
||||
? getGraphQLType(field.type, field.special)
|
||||
: GraphQLNonNull(getGraphQLType(field.type, field.special)),
|
||||
description: field.note,
|
||||
};
|
||||
|
||||
@@ -2388,7 +2395,7 @@ export class GraphQLService {
|
||||
name: 'directus_relations_meta',
|
||||
fields: Object.values(schema.read.collections['directus_relations'].fields).reduce((acc, field) => {
|
||||
acc[field.field] = {
|
||||
type: getGraphQLType(field.type),
|
||||
type: getGraphQLType(field.type, field.special),
|
||||
description: field.note,
|
||||
};
|
||||
|
||||
|
||||
@@ -369,14 +369,16 @@ export function applyFilter(
|
||||
|
||||
validateFilterOperator(
|
||||
schema.collections[targetCollection].fields[stripFunction(filterPath[filterPath.length - 1])].type,
|
||||
filterOperator
|
||||
filterOperator,
|
||||
schema.collections[targetCollection].fields[stripFunction(filterPath[filterPath.length - 1])].special
|
||||
);
|
||||
|
||||
applyFilterToQuery(columnPath, filterOperator, filterValue, logical, targetCollection);
|
||||
} else {
|
||||
validateFilterOperator(
|
||||
schema.collections[collection].fields[stripFunction(filterPath[0])].type,
|
||||
filterOperator
|
||||
filterOperator,
|
||||
schema.collections[collection].fields[stripFunction(filterPath[0])].special
|
||||
);
|
||||
|
||||
applyFilterToQuery(`${collection}.${filterPath[0]}`, filterOperator, filterValue, logical);
|
||||
@@ -415,7 +417,7 @@ export function applyFilter(
|
||||
}
|
||||
}
|
||||
|
||||
function validateFilterOperator(type: Type, filterOperator: string) {
|
||||
function validateFilterOperator(type: Type, filterOperator: string, special: string[]) {
|
||||
if (filterOperator.startsWith('_')) {
|
||||
filterOperator = filterOperator.slice(1);
|
||||
}
|
||||
@@ -425,6 +427,15 @@ export function applyFilter(
|
||||
`"${type}" field type does not contain the "_${filterOperator}" filter operator`
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
special.includes('conceal') &&
|
||||
!getFilterOperatorsForType('hash').includes(filterOperator as ClientFilterOperator)
|
||||
) {
|
||||
throw new InvalidQueryException(
|
||||
`Field with "conceal" special does not allow the "_${filterOperator}" filter operator`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilterToQuery(
|
||||
|
||||
@@ -13,7 +13,14 @@ import { GraphQLGeoJSON } from '../services/graphql/types/geojson';
|
||||
import { Type } from '@directus/shared/types';
|
||||
import { GraphQLHash } from '../services/graphql/types/hash';
|
||||
|
||||
export function getGraphQLType(localType: Type | 'alias' | 'unknown'): GraphQLScalarType | GraphQLList<GraphQLType> {
|
||||
export function getGraphQLType(
|
||||
localType: Type | 'alias' | 'unknown',
|
||||
special: string[]
|
||||
): GraphQLScalarType | GraphQLList<GraphQLType> {
|
||||
if (special.includes('conceal')) {
|
||||
return GraphQLHash;
|
||||
}
|
||||
|
||||
switch (localType) {
|
||||
case 'boolean':
|
||||
return GraphQLBoolean;
|
||||
|
||||
132
tests-blackbox/routes/items/conceal-filter.seed.ts
Normal file
132
tests-blackbox/routes/items/conceal-filter.seed.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import vendors from '@common/get-dbs-to-test';
|
||||
import {
|
||||
CreateCollection,
|
||||
CreateField,
|
||||
CreateFieldO2M,
|
||||
CreateItem,
|
||||
DeleteCollection,
|
||||
PRIMARY_KEY_TYPES,
|
||||
} from '@common/index';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
export const collectionFirst = 'test_items_conceal_filter_first';
|
||||
export const collectionSecond = 'test_items_conceal_filter_second';
|
||||
|
||||
export type First = {
|
||||
id?: number | string;
|
||||
string_field?: string;
|
||||
};
|
||||
|
||||
export type Second = {
|
||||
id?: number | string;
|
||||
string_field?: string;
|
||||
first_id?: number | string | null;
|
||||
};
|
||||
|
||||
export const seedDBStructure = () => {
|
||||
it.each(vendors)(
|
||||
'%s',
|
||||
async (vendor) => {
|
||||
for (const pkType of PRIMARY_KEY_TYPES) {
|
||||
try {
|
||||
const localCollectionFirst = `${collectionFirst}_${pkType}`;
|
||||
const localCollectionSecond = `${collectionSecond}_${pkType}`;
|
||||
|
||||
// Delete existing collections
|
||||
await DeleteCollection(vendor, { collection: localCollectionSecond });
|
||||
await DeleteCollection(vendor, { collection: localCollectionFirst });
|
||||
|
||||
// Create first collection
|
||||
await CreateCollection(vendor, {
|
||||
collection: localCollectionFirst,
|
||||
primaryKeyType: pkType,
|
||||
});
|
||||
|
||||
await CreateField(vendor, {
|
||||
collection: localCollectionFirst,
|
||||
field: 'string_field',
|
||||
type: 'string',
|
||||
meta: {
|
||||
special: ['conceal'],
|
||||
},
|
||||
});
|
||||
|
||||
// Create seconds collection
|
||||
await CreateCollection(vendor, {
|
||||
collection: localCollectionSecond,
|
||||
primaryKeyType: pkType,
|
||||
});
|
||||
|
||||
await CreateField(vendor, {
|
||||
collection: localCollectionSecond,
|
||||
field: 'string_field',
|
||||
type: 'string',
|
||||
meta: {
|
||||
special: ['conceal'],
|
||||
},
|
||||
});
|
||||
|
||||
await CreateFieldO2M(vendor, {
|
||||
collection: localCollectionFirst,
|
||||
field: 'second_ids',
|
||||
primaryKeyType: pkType,
|
||||
otherCollection: localCollectionSecond,
|
||||
otherField: 'first_id',
|
||||
});
|
||||
|
||||
expect(true).toBeTruthy();
|
||||
} catch (error) {
|
||||
expect(error).toBeFalsy();
|
||||
}
|
||||
}
|
||||
},
|
||||
300000
|
||||
);
|
||||
};
|
||||
|
||||
export const seedDBValues = async () => {
|
||||
let isSeeded = true;
|
||||
await Promise.all(
|
||||
vendors.map(async (vendor) => {
|
||||
for (const pkType of PRIMARY_KEY_TYPES) {
|
||||
const localCollectionFirst = `${collectionFirst}_${pkType}`;
|
||||
|
||||
// Create nested items with string value
|
||||
await CreateItem(vendor, {
|
||||
collection: localCollectionFirst,
|
||||
item: {
|
||||
id: pkType === 'string' ? uuid() : undefined,
|
||||
string_field: uuid(),
|
||||
second_ids: [
|
||||
{
|
||||
id: pkType === 'string' ? uuid() : undefined,
|
||||
string_field: uuid(),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Create nested items without string value
|
||||
await CreateItem(vendor, {
|
||||
collection: localCollectionFirst,
|
||||
item: {
|
||||
id: pkType === 'string' ? uuid() : undefined,
|
||||
second_ids: [
|
||||
{
|
||||
id: pkType === 'string' ? uuid() : undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
})
|
||||
)
|
||||
.then(() => {
|
||||
isSeeded = true;
|
||||
})
|
||||
.catch(() => {
|
||||
isSeeded = false;
|
||||
});
|
||||
|
||||
return isSeeded;
|
||||
};
|
||||
210
tests-blackbox/routes/items/conceal-filter.test.ts
Normal file
210
tests-blackbox/routes/items/conceal-filter.test.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import request from 'supertest';
|
||||
import { getUrl } from '@common/config';
|
||||
import vendors from '@common/get-dbs-to-test';
|
||||
import * as common from '@common/index';
|
||||
import { collectionFirst, collectionSecond, seedDBValues } from './conceal-filter.seed';
|
||||
|
||||
let isSeeded = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
isSeeded = await seedDBValues();
|
||||
}, 300000);
|
||||
|
||||
test('Seed Database Values', () => {
|
||||
expect(isSeeded).toStrictEqual(true);
|
||||
});
|
||||
|
||||
describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
const localCollectionFirst = `${collectionFirst}_${pkType}`;
|
||||
const localCollectionSecond = `${collectionSecond}_${pkType}`;
|
||||
|
||||
describe(`pkType: ${pkType}`, () => {
|
||||
describe(`GET /${localCollectionFirst}`, () => {
|
||||
describe('retrieves items without filters', () => {
|
||||
it.each(vendors)('%s', async (vendor) => {
|
||||
// Action
|
||||
const response = await request(getUrl(vendor))
|
||||
.get(`/items/${localCollectionFirst}`)
|
||||
.set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`);
|
||||
const response2 = await request(getUrl(vendor))
|
||||
.get(`/items/${localCollectionSecond}`)
|
||||
.set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`);
|
||||
|
||||
// Assert
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(response.body.data.length).toBe(2);
|
||||
expect(response2.statusCode).toEqual(200);
|
||||
expect(response2.body.data.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retrieves items with filters (non-relational)', () => {
|
||||
it.each(vendors)('%s', async (vendor) => {
|
||||
// Action
|
||||
const response = await request(getUrl(vendor))
|
||||
.get(`/items/${localCollectionFirst}`)
|
||||
.query({
|
||||
filter: JSON.stringify({ string_field: { _null: true } }),
|
||||
})
|
||||
.set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`);
|
||||
const response2 = await request(getUrl(vendor))
|
||||
.get(`/items/${localCollectionFirst}`)
|
||||
.query({
|
||||
filter: JSON.stringify({ string_field: { _nnull: true } }),
|
||||
})
|
||||
.set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`);
|
||||
const response3 = await request(getUrl(vendor))
|
||||
.get(`/items/${localCollectionSecond}`)
|
||||
.query({
|
||||
filter: JSON.stringify({ string_field: { _null: true } }),
|
||||
})
|
||||
.set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`);
|
||||
const response4 = await request(getUrl(vendor))
|
||||
.get(`/items/${localCollectionSecond}`)
|
||||
.query({
|
||||
filter: JSON.stringify({ string_field: { _nnull: true } }),
|
||||
})
|
||||
.set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`);
|
||||
|
||||
// Assert
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(response.body.data.length).toBe(1);
|
||||
expect(response2.statusCode).toEqual(200);
|
||||
expect(response2.body.data.length).toBe(1);
|
||||
expect(response3.statusCode).toEqual(200);
|
||||
expect(response3.body.data.length).toBe(1);
|
||||
expect(response4.statusCode).toEqual(200);
|
||||
expect(response4.body.data.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('errors with invalid filters (non-relational)', () => {
|
||||
it.each(vendors)('%s', async (vendor) => {
|
||||
// Action
|
||||
const response = await request(getUrl(vendor))
|
||||
.get(`/items/${localCollectionFirst}`)
|
||||
.query({
|
||||
filter: JSON.stringify({ string_field: { _contains: 'a' } }),
|
||||
})
|
||||
.set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`);
|
||||
const response2 = await request(getUrl(vendor))
|
||||
.get(`/items/${localCollectionFirst}`)
|
||||
.query({
|
||||
filter: JSON.stringify({ string_field: { _eq: 'b' } }),
|
||||
})
|
||||
.set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`);
|
||||
const response3 = await request(getUrl(vendor))
|
||||
.get(`/items/${localCollectionSecond}`)
|
||||
.query({
|
||||
filter: JSON.stringify({ string_field: { _starts_with: 'c' } }),
|
||||
})
|
||||
.set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`);
|
||||
const response4 = await request(getUrl(vendor))
|
||||
.get(`/items/${localCollectionSecond}`)
|
||||
.query({
|
||||
filter: JSON.stringify({ string_field: { _ends_with: 'd' } }),
|
||||
})
|
||||
.set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`);
|
||||
|
||||
// Assert
|
||||
expect(response.statusCode).toEqual(400);
|
||||
expect(response2.statusCode).toEqual(400);
|
||||
expect(response3.statusCode).toEqual(400);
|
||||
expect(response4.statusCode).toEqual(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retrieves items with filters (relational)', () => {
|
||||
it.each(vendors)('%s', async (vendor) => {
|
||||
// Action
|
||||
const response = await request(getUrl(vendor))
|
||||
.get(`/items/${localCollectionFirst}`)
|
||||
.query({
|
||||
filter: JSON.stringify({
|
||||
second_ids: { string_field: { _null: true } },
|
||||
}),
|
||||
})
|
||||
.set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`);
|
||||
const response2 = await request(getUrl(vendor))
|
||||
.get(`/items/${localCollectionFirst}`)
|
||||
.query({
|
||||
filter: JSON.stringify({
|
||||
second_ids: { string_field: { _null: true } },
|
||||
}),
|
||||
})
|
||||
.set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`);
|
||||
const response3 = await request(getUrl(vendor))
|
||||
.get(`/items/${localCollectionSecond}`)
|
||||
.query({
|
||||
filter: JSON.stringify({
|
||||
first_id: { string_field: { _null: true } },
|
||||
}),
|
||||
})
|
||||
.set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`);
|
||||
const response4 = await request(getUrl(vendor))
|
||||
.get(`/items/${localCollectionSecond}`)
|
||||
.query({
|
||||
filter: JSON.stringify({
|
||||
first_id: { string_field: { _null: true } },
|
||||
}),
|
||||
})
|
||||
.set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`);
|
||||
|
||||
// Assert
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(response.body.data.length).toBe(1);
|
||||
expect(response2.statusCode).toEqual(200);
|
||||
expect(response2.body.data.length).toBe(1);
|
||||
expect(response3.statusCode).toEqual(200);
|
||||
expect(response3.body.data.length).toBe(1);
|
||||
expect(response4.statusCode).toEqual(200);
|
||||
expect(response4.body.data.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('errors with invalid filters (relational)', () => {
|
||||
it.each(vendors)('%s', async (vendor) => {
|
||||
// Action
|
||||
const response = await request(getUrl(vendor))
|
||||
.get(`/items/${localCollectionFirst}`)
|
||||
.query({
|
||||
filter: JSON.stringify({
|
||||
second_ids: { string_field: { _contains: 'a' } },
|
||||
}),
|
||||
})
|
||||
.set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`);
|
||||
const response2 = await request(getUrl(vendor))
|
||||
.get(`/items/${localCollectionFirst}`)
|
||||
.query({
|
||||
filter: JSON.stringify({
|
||||
second_ids: { string_field: { _eq: 'b' } },
|
||||
}),
|
||||
})
|
||||
.set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`);
|
||||
const response3 = await request(getUrl(vendor))
|
||||
.get(`/items/${localCollectionSecond}`)
|
||||
.query({
|
||||
filter: JSON.stringify({
|
||||
first_id: { string_field: { _starts_with: 'c' } },
|
||||
}),
|
||||
})
|
||||
.set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`);
|
||||
const response4 = await request(getUrl(vendor))
|
||||
.get(`/items/${localCollectionSecond}`)
|
||||
.query({
|
||||
filter: JSON.stringify({
|
||||
first_id: { string_field: { _ends_with: 'd' } },
|
||||
}),
|
||||
})
|
||||
.set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`);
|
||||
|
||||
// Assert
|
||||
expect(response.statusCode).toEqual(400);
|
||||
expect(response2.statusCode).toEqual(400);
|
||||
expect(response3.statusCode).toEqual(400);
|
||||
expect(response4.statusCode).toEqual(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user