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
11 changes: 11 additions & 0 deletions backend/src/entities/widget/utils/validate-create-widgets-ds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,17 @@ export async function validateCreateWidgetsDs(
errors.push(Messages.WIDGET_REQUIRED_PARAMETER_MISSING('aws_secret_access_key_secret_name'));
}
}

if (widget_type && widget_type === WidgetTypeEnum.Binary) {
const rawParams = widgetDS.widget_params;
if (rawParams) {
const widget_params: Record<string, any> =
typeof rawParams === 'string' ? JSON5.parse(rawParams) : (rawParams as Record<string, any>);
if (widget_params.encoding !== undefined && !['hex', 'base64', 'ascii'].includes(widget_params.encoding)) {
errors.push(Messages.WIDGET_PARAMETER_UNSUPPORTED('encoding', WidgetTypeEnum.Binary));
}
}
Comment on lines +112 to +120
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

widget_params is JSON5-parsed here without any error handling. If a client sends an invalid JSON5 string for widget_params, JSON5.parse will throw and the endpoint will return a 500 instead of a validation error. Consider wrapping the parse in a try/catch and pushing a validation message when parsing fails (consistent with other validation errors returned from this function).

Copilot uses AI. Check for mistakes.
}
}
return errors;
}
1 change: 1 addition & 0 deletions backend/src/enums/widget-type.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@ export enum WidgetTypeEnum {
Timezone = 'Timezone',
S3 = 'S3',
Email = 'Email',
Binary = 'Binary',
}
109 changes: 109 additions & 0 deletions backend/test/ava-tests/saas-tests/table-widgets-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1699,3 +1699,112 @@ test.serial(
}
},
);

currentTest = 'POST /widget/:slug with Binary widget';

test.serial(`${currentTest} should accept a valid encoding value`, async (t) => {
const { token } = await registerUserAndReturnUserInfo(app);
const newConnection = getTestData(mockFactory).newEncryptedConnection;
const createdConnection = await request(app.getHttpServer())
.post('/connection')
.send(newConnection)
.set('Cookie', token)
.set('masterpwd', 'ahalaimahalai')
.set('Content-Type', 'application/json')
.set('Accept', 'application/json');
const connectionId = JSON.parse(createdConnection.text).id;

const binaryWidgetsDTO: CreateOrUpdateTableWidgetsDto = {
widgets: [
{
widget_type: WidgetTypeEnum.Binary,
widget_params: JSON.stringify({ encoding: 'base64' }),
field_name: 'id',
description: 'binary widget test',
name: 'binary widget',
widget_options: JSON.stringify({}),
},
],
};
const createResponse = await request(app.getHttpServer())
.post(`/widget/${connectionId}?tableName=${tableNameForWidgets}`)
.send(binaryWidgetsDTO)
.set('Cookie', token)
.set('masterpwd', 'ahalaimahalai')
.set('Content-Type', 'application/json')
.set('Accept', 'application/json');
t.is(createResponse.status, 201);
const ro = JSON.parse(createResponse.text);
t.is(ro[0].widget_type, WidgetTypeEnum.Binary);
const params = JSON5.parse(ro[0].widget_params);
t.is(params.encoding, 'base64');
});

test.serial(`${currentTest} should reject an invalid encoding value`, async (t) => {
const { token } = await registerUserAndReturnUserInfo(app);
const newConnection = getTestData(mockFactory).newEncryptedConnection;
const createdConnection = await request(app.getHttpServer())
.post('/connection')
.send(newConnection)
.set('Cookie', token)
.set('masterpwd', 'ahalaimahalai')
.set('Content-Type', 'application/json')
.set('Accept', 'application/json');
const connectionId = JSON.parse(createdConnection.text).id;

const binaryWidgetsDTO: CreateOrUpdateTableWidgetsDto = {
widgets: [
{
widget_type: WidgetTypeEnum.Binary,
widget_params: JSON.stringify({ encoding: 'utf8' }),
field_name: 'id',
description: 'binary widget test',
name: 'binary widget',
widget_options: JSON.stringify({}),
},
],
};
const createResponse = await request(app.getHttpServer())
.post(`/widget/${connectionId}?tableName=${tableNameForWidgets}`)
.send(binaryWidgetsDTO)
.set('Cookie', token)
.set('masterpwd', 'ahalaimahalai')
.set('Content-Type', 'application/json')
.set('Accept', 'application/json');
t.is(createResponse.status, 400);
t.true(createResponse.text.includes(Messages.WIDGET_PARAMETER_UNSUPPORTED('encoding', WidgetTypeEnum.Binary)));
});

test.serial(`${currentTest} should accept an absent encoding (defaults on the client)`, async (t) => {
const { token } = await registerUserAndReturnUserInfo(app);
const newConnection = getTestData(mockFactory).newEncryptedConnection;
const createdConnection = await request(app.getHttpServer())
.post('/connection')
.send(newConnection)
.set('Cookie', token)
.set('masterpwd', 'ahalaimahalai')
.set('Content-Type', 'application/json')
.set('Accept', 'application/json');
const connectionId = JSON.parse(createdConnection.text).id;

const binaryWidgetsDTO: CreateOrUpdateTableWidgetsDto = {
widgets: [
{
widget_type: WidgetTypeEnum.Binary,
widget_params: JSON.stringify({}),
field_name: 'id',
description: 'binary widget test',
name: 'binary widget',
widget_options: JSON.stringify({}),
},
],
};
const createResponse = await request(app.getHttpServer())
.post(`/widget/${connectionId}?tableName=${tableNameForWidgets}`)
.send(binaryWidgetsDTO)
.set('Cookie', token)
.set('masterpwd', 'ahalaimahalai')
.set('Content-Type', 'application/json')
.set('Accept', 'application/json');
t.is(createResponse.status, 201);
});
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,13 @@ export class DbTableWidgetsComponent implements OnInit {
};
// JSON5-formatted default params
public defaultParams = {
Binary: `// No settings required`,
Binary: `// Configure binary display/edit encoding.
// Supported: "hex" (default), "base64", "ascii"
// example:
{
"encoding": "hex"
}
`,
Boolean: `// Display "Yes/No" buttons with configurable options:
// - allow_null: Use "false" to require selection, "true" if field can be left unspecified
// - invert_colors: Swap the color scheme (typically green=Yes, red=No becomes red=Yes, green=No)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,40 @@

@if (filterMode !== 'empty') {
<mat-form-field class="value-field" appearance="outline">
<mat-label>{{normalizedLabel()}} (hex)</mat-label>
<input matInput type="text" hexValidator
name="{{label()}}-{{key()}}"
#inputElement
#hexInput="ngModel"
[required]="required()" [disabled]="disabled()" [readonly]="readonly()"
placeholder="48656c6c6f"
[(ngModel)]="hexValue" (ngModelChange)="onHexValueChange($event)">
@if (hexInput.errors?.isInvalidHex) {
<mat-error>Invalid hex.</mat-error>
<mat-label>{{normalizedLabel()}} ({{ encoding() }})</mat-label>
@switch (encoding()) {
@case ('hex') {
<input matInput type="text" hexValidator
name="{{label()}}-{{key()}}"
#inputElement
#binaryInput="ngModel"
[required]="required()" [disabled]="disabled()" [readonly]="readonly()"
placeholder="48656c6c6f"
[ngModel]="rawInput" (ngModelChange)="onInputChange($event)">
@if (binaryInput.errors?.isInvalidHex) {
<mat-error>Invalid hex.</mat-error>
}
}
@case ('base64') {
<input matInput type="text" base64Validator
name="{{label()}}-{{key()}}"
#inputElement
#binaryInput="ngModel"
[required]="required()" [disabled]="disabled()" [readonly]="readonly()"
placeholder="SGVsbG8="
[ngModel]="rawInput" (ngModelChange)="onInputChange($event)">
@if (binaryInput.errors?.isInvalidBase64) {
<mat-error>Invalid base64.</mat-error>
}
}
@case ('ascii') {
<input matInput type="text"
name="{{label()}}-{{key()}}"
#inputElement
[required]="required()" [disabled]="disabled()" [readonly]="readonly()"
placeholder="Hello"
[ngModel]="rawInput" (ngModelChange)="onInputChange($event)">
}
}
</mat-form-field>
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,44 +20,44 @@ describe('BinaryFilterComponent', () => {
expect(component).toBeTruthy();
});

it('defaults to eq filter mode with empty hex', () => {
it('defaults to eq filter mode with empty input', () => {
fixture.detectChanges();
expect(component.filterMode).toBe('eq');
expect(component.hexValue).toBe('');
expect(component.rawInput).toBe('');
});

it('normalizes the incoming hex value through bytes on init', () => {
component.value = '48656c6c6f';
component.ngOnInit();
expect(component.hexValue).toBe('48656c6c6f');
expect(component.rawInput).toBe('48656c6c6f');
});

it('drops a malformed incoming hex value to empty on init', () => {
component.value = 'zz';
component.ngOnInit();
expect(component.hexValue).toBe('');
expect(component.rawInput).toBe('');
});

it('emits the hex string and current comparator on hex change', () => {
it('emits the hex string and current comparator on input change', () => {
vi.spyOn(component.onFieldChange, 'emit');
vi.spyOn(component.onComparatorChange, 'emit');
fixture.detectChanges();

component.onHexValueChange('abcdef');
component.onInputChange('abcdef');

expect(component.onFieldChange.emit).toHaveBeenCalledWith('abcdef');
expect(component.onComparatorChange.emit).toHaveBeenCalledWith('eq');
});

it('switches to empty mode and clears hex', () => {
it('switches to empty mode and clears input', () => {
vi.spyOn(component.onFieldChange, 'emit');
vi.spyOn(component.onComparatorChange, 'emit');
fixture.detectChanges();

component.hexValue = 'abcdef';
component.rawInput = 'abcdef';
component.onFilterModeChange('empty');

expect(component.hexValue).toBe('');
expect(component.rawInput).toBe('');
expect(component.onComparatorChange.emit).toHaveBeenCalledWith('empty');
expect(component.onFieldChange.emit).toHaveBeenCalledWith('');
});
Expand All @@ -67,7 +67,7 @@ describe('BinaryFilterComponent', () => {
vi.spyOn(component.onComparatorChange, 'emit');
fixture.detectChanges();

component.hexValue = 'abcdef';
component.rawInput = 'abcdef';
component.onFilterModeChange('contains');

expect(component.onComparatorChange.emit).toHaveBeenCalledWith('contains');
Expand All @@ -81,4 +81,46 @@ describe('BinaryFilterComponent', () => {
component.onFilterModeChange('startswith');
expect(component.onComparatorChange.emit).toHaveBeenCalledWith('startswith');
});

describe('encoding param', () => {
it('seeds rawInput in the selected encoding from the incoming hex value', () => {
fixture.componentRef.setInput('widgetStructure', { widget_params: { encoding: 'base64' } });
component.value = '48656c6c6f';
component.ngOnInit();
expect(component.encoding()).toBe('base64');
expect(component.rawInput).toBe('SGVsbG8=');
});

it('emits hex to backend when the user types base64', () => {
fixture.componentRef.setInput('widgetStructure', { widget_params: { encoding: 'base64' } });
vi.spyOn(component.onFieldChange, 'emit');
fixture.detectChanges();

component.onInputChange('SGVsbG8=');

expect(component.onFieldChange.emit).toHaveBeenCalledWith('48656c6c6f');
expect(component.isInvalidInput).toBe(false);
});

it('emits empty hex and marks invalid when base64 input is malformed', () => {
fixture.componentRef.setInput('widgetStructure', { widget_params: { encoding: 'base64' } });
vi.spyOn(component.onFieldChange, 'emit');
fixture.detectChanges();

component.onInputChange('!!!');

expect(component.onFieldChange.emit).toHaveBeenCalledWith('');
expect(component.isInvalidInput).toBe(true);
});

it('emits hex to backend when the user types ascii', () => {
fixture.componentRef.setInput('widgetStructure', { widget_params: { encoding: 'ascii' } });
vi.spyOn(component.onFieldChange, 'emit');
fixture.detectChanges();

component.onInputChange('Hi');

expect(component.onFieldChange.emit).toHaveBeenCalledWith('4869');
});
});
});
Loading
Loading