Skip to content

Commit 1490cee

Browse files
committed
Handle int overflow in provided userID
1 parent bb644fb commit 1490cee

5 files changed

Lines changed: 88 additions & 22 deletions

File tree

src/api/project-member/dto/create-member.dto.spec.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,32 @@ describe('CreateMemberDto', () => {
1414
).toThrow(BadRequestException);
1515
});
1616

17-
it('accepts numeric-string user ids', () => {
17+
it('accepts numeric-string user ids without coercing them to numbers', () => {
1818
const dto = plainToInstance(CreateMemberDto, {
1919
userId: '456',
2020
role: ProjectMemberRole.customer,
2121
});
2222

23-
expect(dto.userId).toBe(456);
23+
expect(dto.userId).toBe('456');
2424
expect(validateSync(dto)).toEqual([]);
2525
});
26+
27+
it('preserves numeric-string user ids above Number.MAX_SAFE_INTEGER', () => {
28+
const dto = plainToInstance(CreateMemberDto, {
29+
userId: '9007199254740993',
30+
role: ProjectMemberRole.customer,
31+
});
32+
33+
expect(dto.userId).toBe('9007199254740993');
34+
expect(validateSync(dto)).toEqual([]);
35+
});
36+
37+
it('rejects user ids outside the supported bigint range', () => {
38+
expect(() =>
39+
plainToInstance(CreateMemberDto, {
40+
userId: '9223372036854775808',
41+
role: ProjectMemberRole.customer,
42+
}),
43+
).toThrow(BadRequestException);
44+
});
2645
});

src/api/project-member/dto/create-member.dto.ts

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,54 @@
1+
import { BadRequestException } from '@nestjs/common';
12
import { ProjectMemberRole } from '@prisma/client';
23
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
34
import { Transform } from 'class-transformer';
4-
import { IsEnum, IsNumber, IsOptional } from 'class-validator';
5+
import { IsEnum, IsOptional, IsString } from 'class-validator';
56
import { parseNumericStringId } from 'src/shared/utils/service.utils';
67

78
/**
8-
* Parses optional integer-like input values from query/body payloads.
9+
* Parses optional user ids from query/body payloads while preserving exact
10+
* digits.
911
*
1012
* @param value Raw unknown value from the incoming payload.
11-
* @returns A truncated number when parseable, otherwise `undefined`.
13+
* @returns A normalized numeric-string id when parseable, otherwise
14+
* `undefined`.
1215
* @throws {BadRequestException} If a provided value is not a numeric string or
13-
* finite number.
16+
* finite number within the supported bigint range.
1417
*/
15-
function parseOptionalInteger(value: unknown): number | undefined {
18+
function parseOptionalUserId(value: unknown): string | undefined {
1619
if (typeof value === 'undefined' || value === null || value === '') {
1720
return undefined;
1821
}
1922

2023
if (typeof value === 'number') {
21-
return Number(
22-
parseNumericStringId(String(Math.trunc(value)), 'User id'),
23-
);
24+
return parseNumericStringId(
25+
String(Math.trunc(value)),
26+
'User id',
27+
).toString();
2428
}
2529

26-
return Number(parseNumericStringId(String(value), 'User id'));
30+
if (typeof value === 'string' || typeof value === 'bigint') {
31+
return parseNumericStringId(String(value), 'User id').toString();
32+
}
33+
34+
throw new BadRequestException('User id must be a numeric string.');
2735
}
2836

2937
/**
3038
* DTO for creating a project member.
3139
*
32-
* Validation requires `role`, while the service still defensively falls back
33-
* to `getDefaultProjectRole` when role is missing in runtime payloads.
40+
* Validation requires `role`. `userId` is normalized to a numeric string so
41+
* values beyond JavaScript's safe integer range are preserved exactly.
3442
*/
3543
export class CreateMemberDto {
36-
@ApiPropertyOptional({ description: 'User id. Defaults to current user.' })
44+
@ApiPropertyOptional({
45+
description: 'User id. Defaults to current user.',
46+
type: String,
47+
})
3748
@IsOptional()
38-
@Transform(({ value }) => parseOptionalInteger(value))
39-
@IsNumber()
40-
userId?: number;
49+
@Transform(({ value }) => parseOptionalUserId(value))
50+
@IsString()
51+
userId?: string;
4152

4253
@ApiProperty({ enum: ProjectMemberRole, enumName: 'ProjectMemberRole' })
4354
@IsEnum(ProjectMemberRole)

src/api/project-member/project-member.service.spec.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ describe('ProjectMemberService', () => {
151151
await service.addMember(
152152
'1001',
153153
{
154-
userId: 456,
154+
userId: '456',
155155
role: ProjectMemberRole.customer,
156156
},
157157
{
@@ -182,7 +182,28 @@ describe('ProjectMemberService', () => {
182182
service.addMember(
183183
'1001',
184184
{
185-
userId: 'invalid' as unknown as number,
185+
userId: 'invalid' as unknown as string,
186+
role: ProjectMemberRole.customer,
187+
},
188+
{
189+
userId: '123',
190+
roles: ['Topcoder User'],
191+
isMachine: false,
192+
},
193+
undefined,
194+
),
195+
).rejects.toBeInstanceOf(BadRequestException);
196+
197+
expect(prismaMock.project.findFirst).not.toHaveBeenCalled();
198+
expect(prismaMock.$transaction).not.toHaveBeenCalled();
199+
});
200+
201+
it('rejects out-of-range target user ids before querying the project', async () => {
202+
await expect(
203+
service.addMember(
204+
'1001',
205+
{
206+
userId: '10000000000000011111',
186207
role: ProjectMemberRole.customer,
187208
},
188209
{

src/api/project-member/project-member.service.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -820,12 +820,14 @@ export class ProjectMemberService {
820820
* Resolves the target project-member user id from request payload state.
821821
*
822822
* Defaults to the authenticated actor when `dto.userId` is omitted, and
823-
* rejects provided values that are not numeric.
823+
* rejects provided values that are not numeric or exceed the supported
824+
* signed 64-bit bigint range.
824825
*
825826
* @param userId Raw `CreateMemberDto.userId` payload value.
826827
* @param actorUserId Authenticated caller id used as the default target.
827828
* @returns Normalized target user id string.
828-
* @throws {BadRequestException} If a provided `userId` is not numeric.
829+
* @throws {BadRequestException} If a provided `userId` is not numeric or is
830+
* outside the supported bigint range.
829831
*/
830832
private resolveTargetUserId(
831833
userId: CreateMemberDto['userId'] | string | bigint | null | undefined,

src/shared/utils/service.utils.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import { PrismaService } from 'src/shared/modules/global/prisma.service';
1515
import { PermissionService } from 'src/shared/services/permission.service';
1616
import { hasAdminRole } from 'src/shared/utils/permission.utils';
1717

18+
const MAX_SIGNED_BIGINT_ID = 9223372036854775807n;
19+
1820
/**
1921
* Parses an id using `BigInt(...)` semantics and throws on invalid input.
2022
*/
@@ -28,6 +30,9 @@ export function parseBigIntId(value: string, entityName: string): bigint {
2830

2931
/**
3032
* Parses a strictly numeric-string id (`/^[0-9]+$/`) as bigint.
33+
*
34+
* Rejects values that exceed the signed 64-bit bigint range supported by the
35+
* backing database schema.
3136
*/
3237
export function parseNumericStringId(value: string, fieldName: string): bigint {
3338
const normalized = String(value || '').trim();
@@ -36,7 +41,15 @@ export function parseNumericStringId(value: string, fieldName: string): bigint {
3641
throw new BadRequestException(`${fieldName} must be a numeric string.`);
3742
}
3843

39-
return BigInt(normalized);
44+
const parsed = BigInt(normalized);
45+
46+
if (parsed > MAX_SIGNED_BIGINT_ID) {
47+
throw new BadRequestException(
48+
`${fieldName} must be less than or equal to ${MAX_SIGNED_BIGINT_ID.toString()}.`,
49+
);
50+
}
51+
52+
return parsed;
4053
}
4154

4255
/**

0 commit comments

Comments
 (0)