Slave/upload api #5

Merged
nobswebdev merged 9 commits from slave/upload-api into master 2026-10-12 20:12:16 +00:00
8 changed files with 483 additions and 0 deletions
Showing only changes of commit fa36b27d1b - Show all commits
@@ -0,0 +1,3 @@
export interface IsBase64Options {
byteLength?: number;
}
@@ -0,0 +1,4 @@
export type NullOrIntOptions = {
min?: number;
max?: number;
};
+39
View File
@@ -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);
});
});
+2
View File
@@ -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));