mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
back date validator
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import { validate } from 'class-validator';
|
||||
import { IsISO8601, IsOptional } from 'class-validator';
|
||||
|
||||
import { CLOCK_SKEW_TOLERANCE_MS, IsNotBackdated } from './is-not-backdated.validator';
|
||||
|
||||
class Subject {
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
@IsNotBackdated()
|
||||
occurredAt?: string;
|
||||
}
|
||||
|
||||
const subjectWith = (occurredAt?: string) => {
|
||||
const subject = new Subject();
|
||||
subject.occurredAt = occurredAt;
|
||||
return subject;
|
||||
};
|
||||
|
||||
const errorsFor = async (occurredAt?: string) => validate(subjectWith(occurredAt));
|
||||
|
||||
const backdatedErrors = (errors: Awaited<ReturnType<typeof errorsFor>>) =>
|
||||
errors.filter((error) => Object.keys(error.constraints ?? {}).includes('IsNotBackdated'));
|
||||
|
||||
describe('IsNotBackdated', () => {
|
||||
it('rejects a timestamp from the past', async () => {
|
||||
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
const errors = await errorsFor(yesterday);
|
||||
|
||||
expect(backdatedErrors(errors)).toHaveLength(1);
|
||||
expect(errors[0].constraints?.IsNotBackdated).toBe(
|
||||
'occurredAt cannot be backdated — it must be now or later',
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts now', async () => {
|
||||
const errors = await errorsFor(new Date().toISOString());
|
||||
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('accepts a value stale only by transit and clock skew', async () => {
|
||||
// What an honest caller sends: "now" as of when the request was built.
|
||||
const almostNow = new Date(Date.now() - (CLOCK_SKEW_TOLERANCE_MS - 5_000)).toISOString();
|
||||
|
||||
const errors = await errorsFor(almostNow);
|
||||
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects a value staler than the skew allowance', async () => {
|
||||
const tooStale = new Date(Date.now() - (CLOCK_SKEW_TOLERANCE_MS + 5_000)).toISOString();
|
||||
|
||||
const errors = await errorsFor(tooStale);
|
||||
|
||||
expect(backdatedErrors(errors)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ignores an absent value so @IsOptional decides', async () => {
|
||||
const errors = await errorsFor(undefined);
|
||||
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('leaves an unparseable value to the format validator', async () => {
|
||||
const errors = await errorsFor('not-a-date');
|
||||
|
||||
// Reported as a format problem, not as a backdate.
|
||||
expect(backdatedErrors(errors)).toHaveLength(0);
|
||||
expect(errors[0].constraints).toHaveProperty('isIso8601');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
registerDecorator,
|
||||
ValidationArguments,
|
||||
ValidationOptions,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* A caller may not stamp an event as having happened before now.
|
||||
*
|
||||
* A request cannot reach the server at the instant it was built, and a caller's
|
||||
* clock is not the server's, so a timestamp that honestly means "now" always
|
||||
* arrives a little stale. Comparing straight against `Date.now()` would reject
|
||||
* it. The skew allowance below is what makes an honest "now" pass — it is not a
|
||||
* window for backdating, and it is deliberately far too small to reach any
|
||||
* earlier event worth backdating to.
|
||||
*/
|
||||
export const CLOCK_SKEW_TOLERANCE_MS = 60_000;
|
||||
|
||||
@ValidatorConstraint({ name: 'IsNotBackdated', async: false })
|
||||
export class IsNotBackdatedConstraint implements ValidatorConstraintInterface {
|
||||
validate(value: unknown, args: ValidationArguments): boolean {
|
||||
// Absence is not this validator's business; pair with @IsOptional.
|
||||
if (value === undefined || value === null || value === '') return true;
|
||||
const parsed = new Date(value as string | Date);
|
||||
// An unparseable value is a format error — let @IsISO8601/@IsDateString own
|
||||
// that message rather than reporting it as a backdate.
|
||||
if (Number.isNaN(parsed.getTime())) return true;
|
||||
const toleranceMs = (args.constraints?.[0] as number | undefined) ?? CLOCK_SKEW_TOLERANCE_MS;
|
||||
return parsed.getTime() >= Date.now() - toleranceMs;
|
||||
}
|
||||
|
||||
defaultMessage(args: ValidationArguments): string {
|
||||
return `${args.property} cannot be backdated — it must be now or later`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects a timestamp earlier than now, give or take {@link CLOCK_SKEW_TOLERANCE_MS}.
|
||||
* Pass a different tolerance only with a reason.
|
||||
*/
|
||||
export function IsNotBackdated(
|
||||
toleranceMs: number = CLOCK_SKEW_TOLERANCE_MS,
|
||||
validationOptions?: ValidationOptions,
|
||||
) {
|
||||
return function (object: object, propertyName: string) {
|
||||
registerDecorator({
|
||||
target: object.constructor,
|
||||
propertyName,
|
||||
options: validationOptions,
|
||||
constraints: [toleranceMs],
|
||||
validator: IsNotBackdatedConstraint,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
import { IsNotBackdated } from '../../../common/validators/is-not-backdated.validator';
|
||||
|
||||
export class RecordCheckpointDto {
|
||||
@ApiProperty({ description: 'Station position along the route (0 = origin).' })
|
||||
@IsInt()
|
||||
@@ -21,9 +23,18 @@ export class RecordCheckpointDto {
|
||||
@IsEnum(TrainCheckpointKind)
|
||||
kind?: TrainCheckpointKind;
|
||||
|
||||
@ApiProperty({ required: false, description: 'ISO timestamp; defaults to now.' })
|
||||
/**
|
||||
* A checkpoint records where the train is as staff observe it, and the final
|
||||
* one arrives the schedule — so a backdated value rewrites the journey after
|
||||
* the fact. Only "now" is accepted; omit the field and the service stamps it.
|
||||
*/
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description: 'ISO timestamp; defaults to now. Cannot be earlier than now.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
@IsNotBackdated()
|
||||
occurredAt?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
|
||||
Reference in New Issue
Block a user