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:
ian
2022-08-10 23:19:04 +08:00
committed by GitHub
parent 273e0edfa9
commit 3586409a0b
5 changed files with 378 additions and 11 deletions

View File

@@ -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,
};

View File

@@ -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(

View File

@@ -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;

View 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;
};

View 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);
});
});
});
});
});