Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions packages/datasource-customizer/src/decorators/binary/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.

/**
* As the transport layer between the forest admin agent and the frontend is JSON-API, binary data
* is not supported.
Expand Down Expand Up @@ -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<unknown> {
if (toBackend) {
Comment thread
Tonours marked this conversation as resolved.
const string = value as string;
if (Buffer.isBuffer(value)) return value;

if (typeof value !== 'string') {
Comment thread
Tonours marked this conversation as resolved.
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;
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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 }],
}),
);
});
Expand Down Expand Up @@ -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', [
Expand Down
Loading