Slave/upload api #5
@@ -0,0 +1,3 @@
|
||||
export interface IsBase64Options {
|
||||
byteLength?: number;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export type NullOrIntOptions = {
|
||||
min?: number;
|
||||
max?: number;
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { toBase64 } from './toBase64';
|
||||
|
||||
describe('toBase64', () => {
|
||||
it('encodes utf8 strings', () => {
|
||||
expect(toBase64('hello')).toBe('aGVsbG8=');
|
||||
});
|
||||
|
||||
it('encodes empty strings', () => {
|
||||
expect(toBase64('')).toBe('');
|
||||
});
|
||||
|
||||
it('encodes buffers', () => {
|
||||
const bytes = Buffer.from([0x01, 0x02, 0x03]);
|
||||
|
||||
expect(toBase64(bytes)).toBe(bytes.toString('base64'));
|
||||
});
|
||||
|
||||
it('encodes empty buffers', () => {
|
||||
expect(toBase64(Buffer.alloc(0))).toBe('');
|
||||
});
|
||||
|
||||
it('encodes binary buffers without utf8 interpretation', () => {
|
||||
const bytes = Buffer.from([0xff, 0xfe, 0xfd]);
|
||||
|
||||
expect(toBase64(bytes)).toBe('//79');
|
||||
});
|
||||
|
||||
it('round-trips utf8 strings through base64 decode', () => {
|
||||
const original = 'upload-test-🔒';
|
||||
|
||||
expect(Buffer.from(toBase64(original), 'base64').toString('utf8')).toBe(original);
|
||||
});
|
||||
|
||||
it('round-trips buffers through base64 decode', () => {
|
||||
const original = Buffer.alloc(12, 0xab);
|
||||
|
||||
expect(Buffer.from(toBase64(original), 'base64')).toEqual(original);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export const toBase64 = (input: Buffer | string): string =>
|
||||
typeof input === 'string' ? Buffer.from(input, 'utf8').toString('base64') : input.toString('base64');
|
||||
@@ -0,0 +1,253 @@
|
||||
import 'reflect-metadata';
|
||||
import { validateSync, type ValidationError } from 'class-validator';
|
||||
import { toBase64 } from '../../utils/toBase64';
|
||||
import { IsBase64 } from './isBase64';
|
||||
|
||||
class UnboundedBase64Dto {
|
||||
@IsBase64()
|
||||
payload: string;
|
||||
}
|
||||
|
||||
class TwelveByteBase64Dto {
|
||||
@IsBase64({ byteLength: 12 })
|
||||
iv: string;
|
||||
}
|
||||
|
||||
class ThirtyTwoByteBase64Dto {
|
||||
@IsBase64({ byteLength: 32 })
|
||||
secretKey: string;
|
||||
}
|
||||
|
||||
class DualFieldDto {
|
||||
@IsBase64({ byteLength: 12 })
|
||||
iv: string;
|
||||
|
||||
@IsBase64()
|
||||
ciphertext: string;
|
||||
}
|
||||
|
||||
const validateField = <T extends object>(DtoClass: new () => T, property: keyof T & string, value: unknown) => {
|
||||
const dto = Object.assign(new DtoClass(), { [property]: value });
|
||||
|
||||
return validateSync(dto);
|
||||
};
|
||||
|
||||
const constraintMessage = (errors: ValidationError[], property: string): string | undefined => {
|
||||
const error = errors.find(e => e.property === property);
|
||||
|
||||
return error?.constraints?.isBase64;
|
||||
};
|
||||
|
||||
const accepts = <T extends object>(DtoClass: new () => T, property: keyof T & string, value: unknown) => {
|
||||
expect(validateField(DtoClass, property, value)).toHaveLength(0);
|
||||
};
|
||||
|
||||
const rejects = <T extends object>(DtoClass: new () => T, property: keyof T & string, value: unknown) => {
|
||||
expect(validateField(DtoClass, property, value).length).toBeGreaterThan(0);
|
||||
};
|
||||
|
||||
describe('IsBase64', () => {
|
||||
describe('without byteLength', () => {
|
||||
it('accepts standard base64 for short utf8 payloads', () => {
|
||||
accepts(UnboundedBase64Dto, 'payload', toBase64('hello'));
|
||||
});
|
||||
|
||||
it('accepts base64 without padding when length allows', () => {
|
||||
accepts(UnboundedBase64Dto, 'payload', 'Zm9v');
|
||||
});
|
||||
|
||||
it('accepts base64 with single padding character', () => {
|
||||
accepts(UnboundedBase64Dto, 'payload', 'YQ==');
|
||||
});
|
||||
|
||||
it('accepts base64 with double padding', () => {
|
||||
accepts(UnboundedBase64Dto, 'payload', 'YWI=');
|
||||
});
|
||||
|
||||
it('accepts uppercase and lowercase alphabet characters', () => {
|
||||
accepts(UnboundedBase64Dto, 'payload', toBase64('Ab+/Zz09'));
|
||||
});
|
||||
|
||||
it('accepts plus and slash in the alphabet portion', () => {
|
||||
const raw = Buffer.from([0xff, 0xfe, 0xfd, 0xfc, 0xfb, 0xfa]);
|
||||
|
||||
accepts(UnboundedBase64Dto, 'payload', raw.toString('base64'));
|
||||
});
|
||||
|
||||
it('accepts large binary payloads', () => {
|
||||
accepts(UnboundedBase64Dto, 'payload', toBase64(Buffer.alloc(4096, 0xab)));
|
||||
});
|
||||
|
||||
it('accepts a one-byte decoded payload', () => {
|
||||
accepts(UnboundedBase64Dto, 'payload', Buffer.from([0x00]).toString('base64'));
|
||||
});
|
||||
|
||||
it.each([null, undefined, 0, 1, false, true, {}, [], Symbol('x')])(
|
||||
'rejects non-string value %p',
|
||||
value => {
|
||||
rejects(UnboundedBase64Dto, 'payload', value);
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects empty string', () => {
|
||||
rejects(UnboundedBase64Dto, 'payload', '');
|
||||
});
|
||||
|
||||
it.each(['@', '#', '$', '%', '-', '_', '.', ',', '!', '?', '\\', '"', "'"])(
|
||||
'rejects disallowed character %s',
|
||||
char => {
|
||||
rejects(UnboundedBase64Dto, 'payload', `YQ==${char}`);
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects whitespace before valid base64', () => {
|
||||
rejects(UnboundedBase64Dto, 'payload', ` ${toBase64('a')}`);
|
||||
});
|
||||
|
||||
it('rejects whitespace after valid base64', () => {
|
||||
rejects(UnboundedBase64Dto, 'payload', `${toBase64('a')} `);
|
||||
});
|
||||
|
||||
it('rejects embedded newline', () => {
|
||||
rejects(UnboundedBase64Dto, 'payload', 'YQ==\n');
|
||||
});
|
||||
|
||||
it('rejects embedded tab', () => {
|
||||
rejects(UnboundedBase64Dto, 'payload', 'YQ\t==');
|
||||
});
|
||||
|
||||
it('rejects unicode outside base64 alphabet', () => {
|
||||
rejects(UnboundedBase64Dto, 'payload', 'YQé==');
|
||||
});
|
||||
|
||||
it('rejects more than two padding characters', () => {
|
||||
rejects(UnboundedBase64Dto, 'payload', 'YQ===');
|
||||
});
|
||||
|
||||
it('rejects padding characters in the middle of the string', () => {
|
||||
rejects(UnboundedBase64Dto, 'payload', 'Y=Q=');
|
||||
});
|
||||
|
||||
it('rejects strings that are only padding', () => {
|
||||
rejects(UnboundedBase64Dto, 'payload', '==');
|
||||
});
|
||||
|
||||
it('returns the generic error message', () => {
|
||||
const errors = validateField(UnboundedBase64Dto, 'payload', 'not!!!valid');
|
||||
|
||||
expect(constraintMessage(errors, 'payload')).toBe('payload must be a valid base64 string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with byteLength', () => {
|
||||
const twelveBytes = Buffer.alloc(12, 0x11);
|
||||
const twelveBytesB64 = twelveBytes.toString('base64');
|
||||
|
||||
it('accepts base64 that decodes to exactly 12 bytes', () => {
|
||||
accepts(TwelveByteBase64Dto, 'iv', twelveBytesB64);
|
||||
});
|
||||
|
||||
it('accepts 12 random bytes', () => {
|
||||
const iv = Buffer.from([
|
||||
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c
|
||||
]);
|
||||
|
||||
accepts(TwelveByteBase64Dto, 'iv', iv.toString('base64'));
|
||||
});
|
||||
|
||||
it('accepts 32-byte secrets when byteLength is 32', () => {
|
||||
accepts(ThirtyTwoByteBase64Dto, 'secretKey', Buffer.alloc(32, 0x42).toString('base64'));
|
||||
});
|
||||
|
||||
it('rejects valid base64 that decodes to 11 bytes when 12 are required', () => {
|
||||
rejects(TwelveByteBase64Dto, 'iv', Buffer.alloc(11, 0x01).toString('base64'));
|
||||
});
|
||||
|
||||
it('rejects valid base64 that decodes to 13 bytes when 12 are required', () => {
|
||||
rejects(TwelveByteBase64Dto, 'iv', Buffer.alloc(13, 0x01).toString('base64'));
|
||||
});
|
||||
|
||||
it('rejects valid base64 that decodes to 1 byte when 32 are required', () => {
|
||||
rejects(ThirtyTwoByteBase64Dto, 'secretKey', toBase64('a'));
|
||||
});
|
||||
|
||||
it('rejects valid base64 that decodes to 31 bytes when 32 are required', () => {
|
||||
rejects(ThirtyTwoByteBase64Dto, 'secretKey', Buffer.alloc(31, 0x01).toString('base64'));
|
||||
});
|
||||
|
||||
it('rejects invalid base64 even when length would have matched', () => {
|
||||
rejects(TwelveByteBase64Dto, 'iv', `${twelveBytesB64}!`);
|
||||
});
|
||||
|
||||
it('rejects empty string when byteLength is set', () => {
|
||||
rejects(TwelveByteBase64Dto, 'iv', '');
|
||||
});
|
||||
|
||||
it('returns byteLength-specific error message', () => {
|
||||
const errors = validateField(TwelveByteBase64Dto, 'iv', toBase64('short'));
|
||||
|
||||
expect(constraintMessage(errors, 'iv')).toBe('iv must be a base64-encoded 12-byte value');
|
||||
});
|
||||
|
||||
it('returns byteLength-specific error message for 32-byte constraint', () => {
|
||||
const errors = validateField(ThirtyTwoByteBase64Dto, 'secretKey', toBase64('x'));
|
||||
|
||||
expect(constraintMessage(errors, 'secretKey')).toBe(
|
||||
'secretKey must be a base64-encoded 32-byte value'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple decorated properties on one DTO', () => {
|
||||
const iv = Buffer.alloc(12, 0x99).toString('base64');
|
||||
const ciphertext = toBase64(Buffer.alloc(64, 0xcd));
|
||||
|
||||
it('accepts when both iv and ciphertext are valid', () => {
|
||||
const dto = Object.assign(new DualFieldDto(), { iv, ciphertext });
|
||||
|
||||
expect(validateSync(dto)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects when only iv is invalid', () => {
|
||||
const dto = Object.assign(new DualFieldDto(), { iv: 'bad', ciphertext });
|
||||
|
||||
const errors = validateSync(dto);
|
||||
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].property).toBe('iv');
|
||||
});
|
||||
|
||||
it('rejects when only ciphertext is invalid', () => {
|
||||
const dto = Object.assign(new DualFieldDto(), { iv, ciphertext: '###' });
|
||||
|
||||
const errors = validateSync(dto);
|
||||
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].property).toBe('ciphertext');
|
||||
});
|
||||
|
||||
it('reports both properties when both are invalid', () => {
|
||||
const dto = Object.assign(new DualFieldDto(), { iv: '', ciphertext: '' });
|
||||
|
||||
const errors = validateSync(dto);
|
||||
|
||||
expect(errors.map(e => e.property).sort()).toEqual(['ciphertext', 'iv']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('default options object', () => {
|
||||
class DefaultOptionsDto {
|
||||
@IsBase64({})
|
||||
payload: string;
|
||||
}
|
||||
|
||||
it('behaves like unbounded base64 when options is an empty object', () => {
|
||||
accepts(DefaultOptionsDto, 'payload', toBase64('defaults'));
|
||||
});
|
||||
|
||||
it('rejects wrong byte length only when byteLength is explicitly set', () => {
|
||||
rejects(TwelveByteBase64Dto, 'iv', toBase64('x'));
|
||||
accepts(UnboundedBase64Dto, 'payload', toBase64('x'));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
Validate,
|
||||
ValidatorConstraint,
|
||||
type ValidationArguments,
|
||||
type ValidatorConstraintInterface
|
||||
} from 'class-validator';
|
||||
import type { IsBase64Options } from '../../types/validation/IsBase64Options';
|
||||
|
||||
const BASE64_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/;
|
||||
|
||||
@ValidatorConstraint({ name: 'isBase64' })
|
||||
class IsBase64Constraint implements ValidatorConstraintInterface {
|
||||
validate(value: unknown, args: ValidationArguments): boolean {
|
||||
if (typeof value !== 'string' || !value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!BASE64_PATTERN.test(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const decoded = Buffer.from(value, 'base64');
|
||||
|
||||
if (decoded.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [{ byteLength }] = args.constraints as [IsBase64Options];
|
||||
|
||||
if (byteLength !== undefined) {
|
||||
return decoded.length === byteLength;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
defaultMessage(args: ValidationArguments): string {
|
||||
const [{ byteLength }] = args.constraints as [IsBase64Options];
|
||||
|
||||
if (byteLength !== undefined) {
|
||||
return `$property must be a base64-encoded ${byteLength}-byte value`;
|
||||
}
|
||||
|
||||
return '$property must be a valid base64 string';
|
||||
}
|
||||
}
|
||||
|
||||
export const IsBase64 = (options: IsBase64Options = {}) => Validate(IsBase64Constraint, [options]);
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'reflect-metadata';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validateSync } from 'class-validator';
|
||||
import { NullOrDate, NullOrInt } from './nullOr';
|
||||
|
||||
class DateFieldDto {
|
||||
@NullOrDate()
|
||||
expiresAt: Date | null;
|
||||
}
|
||||
|
||||
class IntFieldDto {
|
||||
@NullOrInt({ min: 1, max: 100 })
|
||||
maxViews: number | null;
|
||||
}
|
||||
|
||||
describe('NullOrDate', () => {
|
||||
it('accepts null', () => {
|
||||
const dto = plainToInstance(DateFieldDto, { expiresAt: null });
|
||||
|
||||
expect(validateSync(dto)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('accepts an ISO string transformed to Date', () => {
|
||||
const dto = plainToInstance(DateFieldDto, { expiresAt: '2026-06-01T00:00:00.000Z' });
|
||||
|
||||
expect(validateSync(dto)).toHaveLength(0);
|
||||
expect(dto.expiresAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('rejects missing property', () => {
|
||||
expect(validateSync(new DateFieldDto()).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NullOrInt', () => {
|
||||
it('accepts null', () => {
|
||||
const dto = Object.assign(new IntFieldDto(), { maxViews: null });
|
||||
|
||||
expect(validateSync(dto)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('accepts an integer within bounds', () => {
|
||||
const dto = Object.assign(new IntFieldDto(), { maxViews: 50 });
|
||||
|
||||
expect(validateSync(dto)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects out-of-range integers', () => {
|
||||
const dto = Object.assign(new IntFieldDto(), { maxViews: 0 });
|
||||
|
||||
expect(validateSync(dto).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects missing property', () => {
|
||||
expect(validateSync(new IntFieldDto()).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { applyDecorators } from '@nestjs/common';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
Validate,
|
||||
ValidatorConstraint,
|
||||
type ValidationArguments,
|
||||
type ValidationOptions,
|
||||
type ValidatorConstraintInterface
|
||||
} from 'class-validator';
|
||||
import type { NullOrIntOptions } from '../../types/validation/NullOrIntOptions';
|
||||
|
||||
@ValidatorConstraint({ name: 'nullOrDate' })
|
||||
class NullOrDateConstraint implements ValidatorConstraintInterface {
|
||||
validate(value: unknown): boolean {
|
||||
if (value === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return value instanceof Date && !Number.isNaN(value.getTime());
|
||||
}
|
||||
|
||||
defaultMessage(): string {
|
||||
return '$property must be null or a valid date';
|
||||
}
|
||||
}
|
||||
|
||||
@ValidatorConstraint({ name: 'nullOrInt' })
|
||||
class NullOrIntConstraint implements ValidatorConstraintInterface {
|
||||
validate(value: unknown, args: ValidationArguments): boolean {
|
||||
if (value === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const [{ min, max }] = args.constraints as [NullOrIntOptions];
|
||||
|
||||
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (min !== undefined && value < min) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (max !== undefined && value > max) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
defaultMessage(args: ValidationArguments): string {
|
||||
const [{ min, max }] = args.constraints as [NullOrIntOptions];
|
||||
|
||||
if (min !== undefined && max !== undefined) {
|
||||
return `$property must be null or an integer between ${min} and ${max}`;
|
||||
}
|
||||
|
||||
if (min !== undefined) {
|
||||
return `$property must be null or an integer >= ${min}`;
|
||||
}
|
||||
|
||||
if (max !== undefined) {
|
||||
return `$property must be null or an integer <= ${max}`;
|
||||
}
|
||||
|
||||
return '$property must be null or an integer';
|
||||
}
|
||||
}
|
||||
|
||||
export const NullOrDate = (validationOptions?: ValidationOptions) =>
|
||||
applyDecorators(
|
||||
Type(() => Date),
|
||||
Validate(NullOrDateConstraint, validationOptions)
|
||||
);
|
||||
|
||||
export const NullOrInt = (options?: NullOrIntOptions, validationOptions?: ValidationOptions) =>
|
||||
applyDecorators(Validate(NullOrIntConstraint, [options ?? {}], validationOptions));
|
||||
Reference in New Issue
Block a user