diff --git a/packages/datasource-customizer/src/decorators/binary/collection.ts b/packages/datasource-customizer/src/decorators/binary/collection.ts index 2f8652acbc..85a253c720 100644 --- a/packages/datasource-customizer/src/decorators/binary/collection.ts +++ b/packages/datasource-customizer/src/decorators/binary/collection.ts @@ -16,9 +16,17 @@ import type { RecordData, } from '@forestadmin/datasource-toolkit'; -import { CollectionDecorator, SchemaUtils, TypeGetter } from '@forestadmin/datasource-toolkit'; +import { + CollectionDecorator, + SchemaUtils, + TypeGetter, + ValidationError, + parseDataUri, +} from '@forestadmin/datasource-toolkit'; import { filetypemime } from 'magic-bytes.js'; +const HEX_BYTES = /^([0-9a-f]{2})+$/i; + /** * As the transport layer between the forest admin agent and the frontend is JSON-API, binary data * is not supported. @@ -237,15 +245,35 @@ export default class BinaryCollectionDecorator extends CollectionDecorator { return value; } + private parseHex(value: string): Buffer { + if (!HEX_BYTES.test(value)) { + throw new ValidationError( + `Expected a hex string of full bytes for a binary field, got "${value.slice(0, 32)}"`, + ); + } + + return Buffer.from(value, 'hex'); + } + private async convertScalar( toBackend: boolean, useHex: boolean, value: unknown, ): Promise { if (toBackend) { - const string = value as string; + if (Buffer.isBuffer(value)) return value; + + if (typeof value !== 'string') { + throw new ValidationError( + `Expected a string for a binary field, got ${typeof value}: ${JSON.stringify( + value, + )?.slice(0, 32)}`, + ); + } + + if (useHex) return this.parseHex(value); - return useHex ? Buffer.from(string, 'hex') : Buffer.from(string.split(',')[1], 'base64'); + return parseDataUri(value).buffer; } const buffer = value as Buffer; @@ -282,7 +310,7 @@ export default class BinaryCollectionDecorator extends CollectionDecorator { const maxLength = schema.validation?.find(v => v.operator === 'ShorterThan')?.value as number; if (this.shouldUseHex(name)) { - validation.push({ operator: 'Match', value: /^[0-9a-f]+$/ }); + validation.push({ operator: 'Match', value: HEX_BYTES }); if (minLength) validation.push({ operator: 'LongerThan', value: minLength * 2 + 1 }); if (maxLength) validation.push({ operator: 'ShorterThan', value: maxLength * 2 - 1 }); } else { diff --git a/packages/datasource-customizer/test/decorators/binary/collection.test.ts b/packages/datasource-customizer/test/decorators/binary/collection.test.ts index 5c773ec2a3..01dc87a1d6 100644 --- a/packages/datasource-customizer/test/decorators/binary/collection.test.ts +++ b/packages/datasource-customizer/test/decorators/binary/collection.test.ts @@ -109,7 +109,7 @@ describe('BinaryCollectionDecorator', () => { isPrimaryKey: true, columnType: 'String', validation: [ - { operator: 'Match', value: /^[0-9a-f]+$/ }, + { operator: 'Match', value: /^([0-9a-f]{2})+$/i }, { operator: 'LongerThan', value: 31 }, { operator: 'ShorterThan', value: 33 }, { operator: 'Present' }, @@ -140,7 +140,7 @@ describe('BinaryCollectionDecorator', () => { expect(decoratedBook.schema.fields.cover).toEqual( expect.objectContaining({ columnType: 'String', - validation: [{ operator: 'Match', value: /^[0-9a-f]+$/ }], + validation: [{ operator: 'Match', value: /^([0-9a-f]{2})+$/i }], }), ); }); @@ -238,6 +238,61 @@ describe('BinaryCollectionDecorator', () => { }); }); + describe('list filtering a binary column with a malformed value', () => { + it.each([ + ['cover', 'Anthony', /must be a data uri/], + ['cover', 'data:text/plain,hello', /must be a data uri/], + ['id', 'Anthony', /hex string of full bytes/], + ['id', '303', /hex string of full bytes/], + ])( + 'should reject %s = %p instead of querying the collection', + async (field, value, message) => { + const caller = factories.caller.build(); + const filter = new PaginatedFilter({ + conditionTree: new ConditionTreeLeaf(field, 'Equal', value), + }); + + await expect(decoratedBook.list(caller, filter, new Projection('id'))).rejects.toThrow( + message, + ); + expect(books.list).not.toHaveBeenCalled(); + }, + ); + + it('should truncate a long value in the error message', async () => { + const caller = factories.caller.build(); + const filter = new PaginatedFilter({ + conditionTree: new ConditionTreeLeaf('id', 'Equal', 'z'.repeat(5000)), + }); + + await expect(decoratedBook.list(caller, filter, new Projection('id'))).rejects.toThrow( + `Expected a hex string of full bytes for a binary field, got "${'z'.repeat(32)}"`, + ); + expect(books.list).not.toHaveBeenCalled(); + }); + }); + + describe('writing a non-string value into a binary column', () => { + it('should reject it with a validation error rather than an opaque TypeError', async () => { + const caller = factories.caller.build(); + + await expect(decoratedBook.create(caller, [{ cover: 42 }])).rejects.toThrow( + 'Expected a string for a binary field, got number: 42', + ); + expect(books.create).not.toHaveBeenCalled(); + }); + + it('should let a buffer through, as it needs no conversion', async () => { + const caller = factories.caller.build(); + const id = Buffer.from('0000', 'ascii'); + (books.create as jest.Mock).mockResolvedValue([{ id }]); + + await decoratedBook.create(caller, [{ id }]); + + expect(books.create).toHaveBeenCalledWith(caller, [{ id }]); + }); + }); + describe('list with a more complex filter', () => { // Build condition tree (30303030 is the hex representation of 0000) const conditionTree = new ConditionTreeBranch('Or', [