Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 14 additions & 11 deletions backend/src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import { Test, TestingModule } from '@nestjs/testing';
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { MailerService } from '@nestjs-modules/mailer';
import * as bcrypt from 'bcrypt';
import { AuthService } from './auth.service';
import { UsersService } from '../users/users.service';
import { MailService } from '../mailer/mail.service';
import { User } from '../users/entities/user.entity';
import { UserRole } from '../common/enums/role.enum';

Expand All @@ -32,6 +32,9 @@ function makeUser(overrides: Partial<User> = {}): User {
resetPasswordExpiry: null,
createdAt: new Date(),
updatedAt: new Date(),
isTwoFactorEnabled: false,
twoFactorSecret: undefined as any,
recoveryCodes: [],
...overrides,
};
}
Expand All @@ -42,7 +45,7 @@ describe('AuthService', () => {
let service: AuthService;
let usersService: jest.Mocked<UsersService>;
let jwtService: jest.Mocked<JwtService>;
let mailerService: jest.Mocked<MailerService>;
let mailService: jest.Mocked<MailService>;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
Expand Down Expand Up @@ -88,16 +91,16 @@ describe('AuthService', () => {
},
},
{
provide: MailerService,
useValue: { sendMail: jest.fn() },
provide: MailService,
useValue: { send: jest.fn() },
},
],
}).compile();

service = module.get<AuthService>(AuthService);
usersService = module.get(UsersService);
jwtService = module.get(JwtService);
mailerService = module.get(MailerService);
mailService = module.get(MailService);
});

// ── register ───────────────────────────────────────────────────────────────
Expand All @@ -108,7 +111,7 @@ describe('AuthService', () => {
usersService.create.mockResolvedValue(user);
usersService.updateVerificationToken.mockResolvedValue(undefined);
usersService.updateRefreshToken.mockResolvedValue(undefined);
mailerService.sendMail.mockResolvedValue(undefined as never);
mailService.send.mockResolvedValue(undefined as never);
jwtService.signAsync
.mockResolvedValueOnce('access-token')
.mockResolvedValueOnce('refresh-token');
Expand All @@ -123,7 +126,7 @@ describe('AuthService', () => {

expect(usersService.create).toHaveBeenCalled();
expect(usersService.updateVerificationToken).toHaveBeenCalled();
expect(mailerService.sendMail).toHaveBeenCalled();
expect(mailService.send).toHaveBeenCalled();
expect(result.accessToken).toBe('access-token');
expect(result.refreshToken).toBe('refresh-token');
expect(result.user).not.toHaveProperty('passwordHash');
Expand All @@ -135,7 +138,7 @@ describe('AuthService', () => {
usersService.create.mockResolvedValue(user);
usersService.updateVerificationToken.mockResolvedValue(undefined);
usersService.updateRefreshToken.mockResolvedValue(undefined);
mailerService.sendMail.mockRejectedValue(new Error('SMTP error'));
mailService.send.mockRejectedValue(new Error('SMTP error'));
jwtService.signAsync
.mockResolvedValueOnce('access-token')
.mockResolvedValueOnce('refresh-token');
Expand Down Expand Up @@ -282,12 +285,12 @@ describe('AuthService', () => {
const user = makeUser();
usersService.findByEmail.mockResolvedValue(user);
usersService.setResetToken.mockResolvedValue(undefined);
mailerService.sendMail.mockResolvedValue(undefined as never);
mailService.send.mockResolvedValue(undefined as never);

const result = await service.forgotPassword('test@example.com');

expect(usersService.setResetToken).toHaveBeenCalled();
expect(mailerService.sendMail).toHaveBeenCalled();
expect(mailService.send).toHaveBeenCalled();
expect(result.message).toBeDefined();
});

Expand All @@ -304,7 +307,7 @@ describe('AuthService', () => {
const user = makeUser();
usersService.findByEmail.mockResolvedValue(user);
usersService.setResetToken.mockResolvedValue(undefined);
mailerService.sendMail.mockRejectedValue(new Error('SMTP error'));
mailService.send.mockRejectedValue(new Error('SMTP error'));

const result = await service.forgotPassword('test@example.com');

Expand Down
9 changes: 6 additions & 3 deletions backend/src/auth/two-factor.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@ import {
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull } from 'typeorm';
import { TOTP, generateURI } from 'otplib';
// import { authenticator } from 'otplib';
import { authenticator } from '@otplib/preset-v11';
import * as qrcode from 'qrcode';
import * as bcrypt from 'bcrypt';
import { Redis } from 'ioredis'; // Assuming BE-02 Redis instance wrapper setup
import { Redis } from 'ioredis';
import { User } from '../users/entities/user.entity';
import { TwoFactorRecovery } from '../users/entities/two-factor-recovery.entity';

const authenticator = new TOTP();

@Injectable()
export class TwoFactorService {
private readonly redisClient: Redis;
Expand All @@ -32,7 +35,7 @@ export class TwoFactorService {
async initiateSetup(userId: number, email: string) {
const secret = authenticator.generateSecret();
const appName = 'YieldLadder platform';
const otpauthUrl = authenticator.keyuri(email, appName, secret);
const otpauthUrl = generateURI({ secret, issuer: appName, label: email });
const qrCodeDataUrl = await qrcode.toDataURL(otpauthUrl);

// Cache the temporary secret safely inside Redis with a strict 10 minute TTL
Expand Down Expand Up @@ -132,7 +135,7 @@ export class TwoFactorService {

async deactivate(userId: number) {
await this.userRepository.update(userId, {
twoFactorSecret: null,
twoFactorSecret: '' as unknown as string,
isTwoFactorEnabled: false,
});
// Wipe matching system recovery database objects safely
Expand Down
15 changes: 12 additions & 3 deletions backend/src/bids/bids-notifications.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,17 @@ export class BidsNotificationsService {
private readonly shipmentRepo: Repository<Shipment>,
) {}

private async sendSafe(to: string, subject: string, html: string): Promise<void> {
private async sendSafe(
to: string,
subject: string,
html: string,
): Promise<void> {
try {
await this.mailerService.sendMail({ to, subject, html });
} catch (err: unknown) {
this.logger.warn(`Failed to send email to ${to}: ${err instanceof Error ? err.message : String(err)}`);
this.logger.warn(
`Failed to send email to ${to}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}

Expand All @@ -43,7 +49,10 @@ export class BidsNotificationsService {
</div>`;
}

private async loadRelations(bid: Bid, shipment: Shipment): Promise<{ carrier: User | null; shipper: User | null }> {
private async loadRelations(
bid: Bid,
shipment: Shipment,
): Promise<{ carrier: User | null; shipper: User | null }> {
const fullShipment = await this.shipmentRepo.findOne({
where: { id: shipment.id },
relations: ['shipper'],
Expand Down
95 changes: 75 additions & 20 deletions backend/src/bids/bids.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { BidsService } from './bids.service';
import { Bid, BidStatus } from './entities/bid.entity';
import { Shipment } from '../shipments/entities/shipment.entity';
import { ShipmentStatus } from '../common/enums/shipment-status.enum';
import { User } from '../users/entities/user.entity';

const mockBidRepo = () => ({
create: jest.fn(),
Expand All @@ -24,6 +25,11 @@ const mockShipmentRepo = () => ({
update: jest.fn(),
});

const mockUserRepo = () => ({
findOne: jest.fn(),
find: jest.fn(),
});

const mockEventEmitter = () => ({ emit: jest.fn() });

const pendingShipment = (overrides = {}): Partial<Shipment> => ({
Expand Down Expand Up @@ -58,6 +64,7 @@ describe('BidsService', () => {
BidsService,
{ provide: getRepositoryToken(Bid), useFactory: mockBidRepo },
{ provide: getRepositoryToken(Shipment), useFactory: mockShipmentRepo },
{ provide: getRepositoryToken(User), useFactory: mockUserRepo },
{ provide: EventEmitter2, useFactory: mockEventEmitter },
],
}).compile();
Expand All @@ -76,7 +83,9 @@ describe('BidsService', () => {
bidRepo.create.mockReturnValue(bid);
bidRepo.save.mockResolvedValue(bid);

const result = await service.submitBid('ship1', 'carrier1', { proposedPrice: 100 });
const result = await service.submitBid('ship1', 'carrier1', {
proposedPrice: 100,
});
expect(result).toMatchObject({ id: 'bid1' });
expect(bidRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ expiresAt: expect.any(Date) }),
Expand All @@ -95,7 +104,9 @@ describe('BidsService', () => {
it('throws if carrier already has a pending bid', async () => {
shipmentRepo.findOne.mockResolvedValue(pendingShipment());
bidRepo.findOne.mockResolvedValue(makeBid());
await expect(service.submitBid('ship1', 'carrier1', { proposedPrice: 100 })).rejects.toThrow(BadRequestException);
await expect(
service.submitBid('ship1', 'carrier1', { proposedPrice: 100 }),
).rejects.toThrow(BadRequestException);
});
});

Expand Down Expand Up @@ -140,7 +151,9 @@ describe('BidsService', () => {
shipmentRepo.findOne.mockResolvedValue(pendingShipment());
const expired = makeBid({ expiresAt: new Date(Date.now() - 1000) });
bidRepo.findOne.mockResolvedValue(expired);
await expect(service.acceptBid('ship1', 'bid1', 'shipper1')).rejects.toThrow(BadRequestException);
await expect(
service.acceptBid('ship1', 'bid1', 'shipper1'),
).rejects.toThrow(BadRequestException);
});

it('throws ForbiddenException if not the shipper', async () => {
Expand All @@ -166,68 +179,110 @@ describe('BidsService', () => {
shipmentRepo.findOne.mockResolvedValue(pendingShipment());
const bid = makeBid();
bidRepo.findOne.mockResolvedValue(bid);
bidRepo.save.mockResolvedValue({ ...bid, status: BidStatus.COUNTER_OFFERED, counterPrice: 90 });
bidRepo.save.mockResolvedValue({
...bid,
status: BidStatus.COUNTER_OFFERED,
counterPrice: 90,
});

const result = await service.counterBid('ship1', 'bid1', 'shipper1', { counterPrice: 90 });
const result = await service.counterBid('ship1', 'bid1', 'shipper1', {
counterPrice: 90,
});
expect(result.status).toBe(BidStatus.COUNTER_OFFERED);
expect(eventEmitter.emit).toHaveBeenCalledWith('bid.countered', expect.anything());
expect(eventEmitter.emit).toHaveBeenCalledWith(
'bid.countered',
expect.anything(),
);
});

it('throws if bid is expired', async () => {
shipmentRepo.findOne.mockResolvedValue(pendingShipment());
bidRepo.findOne.mockResolvedValue(makeBid({ expiresAt: new Date(Date.now() - 1000) }));
await expect(service.counterBid('ship1', 'bid1', 'shipper1', { counterPrice: 90 })).rejects.toThrow(BadRequestException);
bidRepo.findOne.mockResolvedValue(
makeBid({ expiresAt: new Date(Date.now() - 1000) }),
);
await expect(
service.counterBid('ship1', 'bid1', 'shipper1', { counterPrice: 90 }),
).rejects.toThrow(BadRequestException);
});

it('throws ForbiddenException if not shipper', async () => {
shipmentRepo.findOne.mockResolvedValue(pendingShipment({ shipperId: 'other' }));
await expect(service.counterBid('ship1', 'bid1', 'shipper1', { counterPrice: 90 })).rejects.toThrow(ForbiddenException);
shipmentRepo.findOne.mockResolvedValue(
pendingShipment({ shipperId: 'other' }),
);
await expect(
service.counterBid('ship1', 'bid1', 'shipper1', { counterPrice: 90 }),
).rejects.toThrow(ForbiddenException);
});
});

describe('acceptCounter', () => {
it('accepts the counter, assigns carrier, emits shipment.accepted', async () => {
shipmentRepo.findOne.mockResolvedValue(pendingShipment());
const bid = makeBid({ status: BidStatus.COUNTER_OFFERED, counterPrice: 90 });
const bid = makeBid({
status: BidStatus.COUNTER_OFFERED,
counterPrice: 90,
});
bidRepo.findOne.mockResolvedValue(bid);
bidRepo.save.mockResolvedValue({ ...bid, status: BidStatus.COUNTER_ACCEPTED });
bidRepo.save.mockResolvedValue({
...bid,
status: BidStatus.COUNTER_ACCEPTED,
});
bidRepo.update.mockResolvedValue(undefined);
shipmentRepo.update.mockResolvedValue(undefined);

const result = await service.acceptCounter('ship1', 'bid1', 'carrier1');
expect(result.status).toBe(BidStatus.COUNTER_ACCEPTED);
expect(eventEmitter.emit).toHaveBeenCalledWith('shipment.accepted', expect.anything());
expect(eventEmitter.emit).toHaveBeenCalledWith(
'shipment.accepted',
expect.anything(),
);
});

it('throws ForbiddenException if not the bid owner', async () => {
shipmentRepo.findOne.mockResolvedValue(pendingShipment());
bidRepo.findOne.mockResolvedValue(makeBid({ status: BidStatus.COUNTER_OFFERED }));
await expect(service.acceptCounter('ship1', 'bid1', 'other-carrier')).rejects.toThrow(ForbiddenException);
bidRepo.findOne.mockResolvedValue(
makeBid({ status: BidStatus.COUNTER_OFFERED }),
);
await expect(
service.acceptCounter('ship1', 'bid1', 'other-carrier'),
).rejects.toThrow(ForbiddenException);
});

it('throws BadRequestException if not in COUNTER_OFFERED status', async () => {
shipmentRepo.findOne.mockResolvedValue(pendingShipment());
bidRepo.findOne.mockResolvedValue(makeBid({ status: BidStatus.PENDING }));
await expect(service.acceptCounter('ship1', 'bid1', 'carrier1')).rejects.toThrow(BadRequestException);
await expect(
service.acceptCounter('ship1', 'bid1', 'carrier1'),
).rejects.toThrow(BadRequestException);
});
});

describe('declineCounter', () => {
it('sets bid to REJECTED and emits event', async () => {
shipmentRepo.findOne.mockResolvedValue(pendingShipment());
const bid = makeBid({ status: BidStatus.COUNTER_OFFERED, counterPrice: 90 });
const bid = makeBid({
status: BidStatus.COUNTER_OFFERED,
counterPrice: 90,
});
bidRepo.findOne.mockResolvedValue(bid);
bidRepo.save.mockResolvedValue({ ...bid, status: BidStatus.REJECTED });

const result = await service.declineCounter('ship1', 'bid1', 'carrier1');
expect(result.status).toBe(BidStatus.REJECTED);
expect(eventEmitter.emit).toHaveBeenCalledWith('bid.counter_declined', expect.anything());
expect(eventEmitter.emit).toHaveBeenCalledWith(
'bid.counter_declined',
expect.anything(),
);
});

it('throws ForbiddenException if not the bid owner', async () => {
shipmentRepo.findOne.mockResolvedValue(pendingShipment());
bidRepo.findOne.mockResolvedValue(makeBid({ status: BidStatus.COUNTER_OFFERED }));
await expect(service.declineCounter('ship1', 'bid1', 'other')).rejects.toThrow(ForbiddenException);
bidRepo.findOne.mockResolvedValue(
makeBid({ status: BidStatus.COUNTER_OFFERED }),
);
await expect(
service.declineCounter('ship1', 'bid1', 'other'),
).rejects.toThrow(ForbiddenException);
});
});
});
Loading
Loading