Skip to content

Commit bc09d83

Browse files
feat(auth): secure profile and certification validation (#1615)
Closes #1547 Closes #1548 Closes #1549 Closes #1550 Co-authored-by: mftee <falademaryam78@gmail.com>
1 parent 9812060 commit bc09d83

32 files changed

Lines changed: 1866 additions & 214 deletions

‎backend/package-lock.json‎

Lines changed: 45 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎backend/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"@nestjs/passport": "^11.0.5",
3636
"@nestjs/platform-express": "^11.0.1",
3737
"@nestjs/platform-socket.io": "^11.1.12",
38+
"@nestjs/schedule": "^6.1.3",
3839
"@nestjs/swagger": "^11.1.5",
3940
"@nestjs/terminus": "^11.0.0",
4041
"@nestjs/throttler": "^6.4.0",

‎backend/src/app.module.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
77
import { TypeOrmModule } from '@nestjs/typeorm';
88
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
99
import { EventEmitterModule } from '@nestjs/event-emitter';
10+
import { ScheduleModule } from '@nestjs/schedule';
1011
import { UsersModule } from './users/users.module';
1112
import { AuthModule } from './auth/auth.module';
1213
import { ShipmentsModule } from './shipments/shipments.module';
@@ -56,6 +57,7 @@ const throttlerErrorMessage = (context: ExecutionContext): string => {
5657
},
5758
}),
5859
EventEmitterModule.forRoot({ wildcard: false, delimiter: '.' }),
60+
ScheduleModule.forRoot(),
5961
ThrottlerModule.forRoot({
6062
errorMessage: throttlerErrorMessage,
6163
throttlers: [

‎backend/src/auth/auth.service.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import * as bcrypt from 'bcrypt';
1212
import { v4 as uuidv4 } from 'uuid';
1313
import { UsersService } from '../users/users.service';
1414
import { RegisterDto } from './dto/register.dto';
15+
import { UpdateProfileDto } from './dto/update-profile.dto';
1516
import { User } from '../users/entities/user.entity';
1617
import { JwtPayload } from './strategies/jwt.strategy';
1718

@@ -200,7 +201,7 @@ export class AuthService {
200201

201202
async updateProfile(
202203
userId: string,
203-
dto: { firstName?: string; lastName?: string; walletAddress?: string },
204+
dto: UpdateProfileDto,
204205
): Promise<Omit<User, 'passwordHash' | 'refreshToken'>> {
205206
await this.usersService.update(userId, dto);
206207
const updated = await this.usersService.findOne(userId);
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { validate } from 'class-validator';
2+
import { UpdateProfileDto } from './update-profile.dto';
3+
4+
const VALID_PUBLIC_KEY =
5+
'GAGD6MFNQ5IZQQQW6MOS6JWZALYR5P7ITCZP3NUQPNYLHWYBQVC6NNXB';
6+
7+
async function getValidationErrors(dto: object): Promise<string[]> {
8+
const errors = await validate(dto, {
9+
whitelist: true,
10+
forbidNonWhitelisted: false,
11+
});
12+
return errors.flatMap((error) => Object.values(error.constraints ?? {}));
13+
}
14+
15+
describe('UpdateProfileDto', () => {
16+
it('accepts a valid Stellar Ed25519 public key', async () => {
17+
const dto = new UpdateProfileDto();
18+
dto.walletAddress = VALID_PUBLIC_KEY;
19+
20+
expect(await getValidationErrors(dto)).toHaveLength(0);
21+
});
22+
23+
it.each([
24+
'not-a-stellar-key',
25+
'SAGD6MFNQ5IZQQQW6MOS6JWZALYR5P7ITCZP3NUQPNYLHWYBQVC6NNXB',
26+
'GAGD6MFNQ5IZQQQW6MOS6JWZALYR5P7ITCZP3NUQPNYLHWYBQVC6NNX',
27+
// Same length/prefix as the valid key, but with a bad checksum.
28+
'GAGD6MFNQ5IZQQQW6MOS6JWZALYR5P7ITCZP3NUQPNYLHWYBQVC6NNXC',
29+
])('rejects invalid Stellar public key %s', async (walletAddress) => {
30+
const dto = new UpdateProfileDto();
31+
dto.walletAddress = walletAddress;
32+
33+
const messages = await getValidationErrors(dto);
34+
expect(messages).toEqual(
35+
expect.arrayContaining([
36+
expect.stringContaining('valid Stellar Ed25519 public key'),
37+
]),
38+
);
39+
});
40+
41+
it('keeps the field optional when it is omitted or null', async () => {
42+
const omitted = new UpdateProfileDto();
43+
const cleared = new UpdateProfileDto();
44+
cleared.walletAddress = null;
45+
46+
expect(await getValidationErrors(omitted)).toHaveLength(0);
47+
expect(await getValidationErrors(cleared)).toHaveLength(0);
48+
});
49+
50+
it('rejects non-string values when the field is supplied', async () => {
51+
const dto = new UpdateProfileDto();
52+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
53+
(dto as any).walletAddress = 123;
54+
55+
expect(await getValidationErrors(dto)).not.toHaveLength(0);
56+
});
57+
});

‎backend/src/auth/dto/update-profile.dto.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { IsString, IsOptional, MinLength, MaxLength } from 'class-validator';
22
import { ApiPropertyOptional } from '@nestjs/swagger';
3+
import { IsStellarEd25519PublicKey } from '../../common/validators/is-stellar-ed25519-public-key.decorator';
34

45
export class UpdateProfileDto {
56
@ApiPropertyOptional({ example: 'John' })
@@ -16,8 +17,12 @@ export class UpdateProfileDto {
1617
@MaxLength(50)
1718
lastName?: string;
1819

19-
@ApiPropertyOptional({ example: 'GABCDE...XYZ' })
20+
@ApiPropertyOptional({
21+
example: 'GAGD6MFNQ5IZQQQW6MOS6JWZALYR5P7ITCZP3NUQPNYLHWYBQVC6NNXB',
22+
description: 'Optional Stellar Ed25519 account public key (StrKey)',
23+
})
2024
@IsOptional()
2125
@IsString()
22-
walletAddress?: string;
26+
@IsStellarEd25519PublicKey()
27+
walletAddress?: string | null;
2328
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { CarrierCertificationExpiryScheduler } from './carrier-certification-expiry.scheduler';
2+
import { CarrierCertificationsService } from './carrier-certifications.service';
3+
4+
describe('CarrierCertificationExpiryScheduler', () => {
5+
it('runs the expiry sweep when the module starts and on schedule', async () => {
6+
const certificationsService = {
7+
revokeExpiredCertifications: jest.fn().mockResolvedValue(2),
8+
} as unknown as CarrierCertificationsService;
9+
const scheduler = new CarrierCertificationExpiryScheduler(
10+
certificationsService,
11+
);
12+
13+
await scheduler.onModuleInit();
14+
await scheduler.sweep();
15+
16+
expect(
17+
certificationsService.revokeExpiredCertifications,
18+
).toHaveBeenCalledTimes(2);
19+
});
20+
21+
it('contains sweep failures so the scheduler remains alive', async () => {
22+
const certificationsService = {
23+
revokeExpiredCertifications: jest
24+
.fn()
25+
.mockRejectedValue(new Error('db down')),
26+
} as unknown as CarrierCertificationsService;
27+
const scheduler = new CarrierCertificationExpiryScheduler(
28+
certificationsService,
29+
);
30+
31+
await expect(scheduler.sweep()).resolves.toBeUndefined();
32+
});
33+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
2+
import { Cron, CronExpression } from '@nestjs/schedule';
3+
import { CarrierCertificationsService } from './carrier-certifications.service';
4+
5+
/**
6+
* Runs the expiry cleanup independently of request traffic. The service method
7+
* is atomic, so multiple application instances can safely run this job.
8+
*/
9+
@Injectable()
10+
export class CarrierCertificationExpiryScheduler implements OnModuleInit {
11+
private readonly logger = new Logger(
12+
CarrierCertificationExpiryScheduler.name,
13+
);
14+
15+
constructor(
16+
private readonly certificationsService: CarrierCertificationsService,
17+
) {}
18+
19+
async onModuleInit(): Promise<void> {
20+
await this.sweep();
21+
}
22+
23+
@Cron(CronExpression.EVERY_MINUTE)
24+
async sweep(): Promise<void> {
25+
try {
26+
const revoked =
27+
await this.certificationsService.revokeExpiredCertifications();
28+
if (revoked > 0) {
29+
this.logger.log(`Revoked ${revoked} expired carrier certification(s)`);
30+
}
31+
} catch (error: unknown) {
32+
const message = error instanceof Error ? error.message : String(error);
33+
this.logger.error(`Certification expiry sweep failed: ${message}`);
34+
}
35+
}
36+
}

0 commit comments

Comments
 (0)