Skip to content
Open
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
1 change: 1 addition & 0 deletions src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ export default () => ({
klaviyo: {
apiKey: process.env.KLAVIYO_MAILER_API_KEY,
baseUrl: process.env.KLAVIYO_URL,
sendListId: process.env.KLAVIYO_SEND_LIST_ID,
},
sentry: {
dsn: process.env.SENTRY_DSN,
Expand Down
104 changes: 104 additions & 0 deletions src/externals/newsletter/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { NewsletterService } from './index';
import { ConfigService } from '@nestjs/config';
import { HttpClient } from '../http/http.service';
import { createMock } from '@golevelup/ts-jest';

describe('NewsletterService', () => {
let service: NewsletterService;
let configService: ConfigService;
let httpClient: HttpClient;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [NewsletterService],
})
.useMocker(createMock)
.compile();

service = module.get<NewsletterService>(NewsletterService);
configService = module.get<ConfigService>(ConfigService);
httpClient = module.get<HttpClient>(HttpClient);

jest.spyOn(configService, 'get').mockImplementation((key: string) => {
if (key === 'newsletter.listId') return 'defaultListId';
if (key === 'newsletter.apiKey') return 'testApiKey';
if (key === 'klaviyo.baseUrl') return 'https://a.klaviyo.com/api/';
return null;
});
});

it('should be defined', () => {
expect(service).toBeDefined();
});

describe('subscribe', () => {
it('should subscribe user using default listId if not provided', async () => {
const email = 'test@example.com';
jest.spyOn(httpClient, 'post').mockResolvedValueOnce({
data: { data: { id: 'prof_123' } },
} as any);
jest.spyOn(httpClient, 'post').mockResolvedValueOnce({} as any);

await service.subscribe(email);

expect(httpClient.post).toHaveBeenNthCalledWith(
1,
'https://a.klaviyo.com/api/profiles/',
{
data: {
type: 'profile',
attributes: { email },
},
},
{
headers: {
Accept: 'application/json',
Authorization: 'Klaviyo-API-Key testApiKey',
'Content-Type': 'application/json',
revision: '2024-10-15',
},
},
);

expect(httpClient.post).toHaveBeenNthCalledWith(
2,
'https://a.klaviyo.com/api/lists/defaultListId/relationships/profiles/',
{ data: [{ type: 'profile', id: 'prof_123' }] },
{
headers: {
Accept: 'application/json',
Authorization: 'Klaviyo-API-Key testApiKey',
'Content-Type': 'application/json',
revision: '2024-10-15',
},
},
);
});

it('should subscribe user using provided listId', async () => {
const email = 'test@example.com';
const providedListId = 'providedListId';
jest.spyOn(httpClient, 'post').mockResolvedValueOnce({
data: { data: { id: 'prof_123' } },
} as any);
jest.spyOn(httpClient, 'post').mockResolvedValueOnce({} as any);

await service.subscribe(email, providedListId);

expect(httpClient.post).toHaveBeenNthCalledWith(
1,
'https://a.klaviyo.com/api/profiles/',
expect.any(Object),
expect.any(Object),
);

expect(httpClient.post).toHaveBeenNthCalledWith(
2,
'https://a.klaviyo.com/api/lists/providedListId/relationships/profiles/',
{ data: [{ type: 'profile', id: 'prof_123' }] },
expect.any(Object),
);
});
});
});
10 changes: 7 additions & 3 deletions src/externals/newsletter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,12 @@ export class NewsletterService {
private readonly httpClient: HttpClient,
) {}

async subscribe(email: UserAttributes['email']): Promise<void> {
const listId: string = this.configService.get('newsletter.listId');
async subscribe(
email: UserAttributes['email'],
listId?: string,
): Promise<void> {
const resolvedListId =
listId ?? this.configService.get('newsletter.listId');
const apiKey: string = this.configService.get('newsletter.apiKey');
const baseUrl: string = this.configService.get('klaviyo.baseUrl');

Expand All @@ -37,7 +41,7 @@ export class NewsletterService {
const profileId = profileResponse.data.data.id;

await this.httpClient.post(
`${baseUrl}lists/${listId}/relationships/profiles/`,
`${baseUrl}lists/${resolvedListId}/relationships/profiles/`,
{ data: [{ type: 'profile', id: profileId }] },
{
headers: {
Expand Down
10 changes: 9 additions & 1 deletion src/modules/send/send.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { forwardRef, Module } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
import { CryptoModule } from '../../externals/crypto/crypto.module';
import { NotificationModule } from '../../externals/notifications/notifications.module';
import { HttpClientModule } from '../../externals/http/http.module';
import { NewsletterService } from '../../externals/newsletter';
import { FileModule } from '../file/file.module';
import { FolderModule } from '../folder/folder.module';
import { FolderModel } from '../folder/folder.model';
Expand Down Expand Up @@ -31,8 +33,14 @@ import { CaptchaService } from '../../externals/captcha/captcha.service';
FolderModule,
NotificationModule,
CryptoModule,
HttpClientModule,
],
controllers: [SendController],
providers: [SequelizeSendRepository, SendUseCases, CaptchaService],
providers: [
SequelizeSendRepository,
SendUseCases,
CaptchaService,
NewsletterService,
],
})
export class SendModule {}
139 changes: 137 additions & 2 deletions src/modules/send/send.usecase.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { ForbiddenException } from '@nestjs/common';
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { getModelToken } from '@nestjs/sequelize';
import { Test, type TestingModule } from '@nestjs/testing';
import { Sequelize } from 'sequelize-typescript';
import { CryptoModule } from '../../externals/crypto/crypto.module';
import { NotificationService } from '../../externals/notifications/notification.service';
import { NewsletterService } from '../../externals/newsletter';
import { FolderModel } from '../folder/folder.model';
import { User } from '../user/user.domain';
import { UserModel } from '../user/user.repository';
Expand All @@ -19,7 +20,10 @@
import { createMock } from '@golevelup/ts-jest';

describe('Send Use Cases', () => {
let service: SendUseCases, notificationService, sendRepository;
let service: SendUseCases,
notificationService,
sendRepository,
newsletterService;
const userMock = User.build({
id: 2,
userId: 'userId',
Expand Down Expand Up @@ -88,6 +92,8 @@
service = module.get<SendUseCases>(SendUseCases);
notificationService = module.get<NotificationService>(NotificationService);
sendRepository = module.get<SendRepository>(SequelizeSendRepository);
newsletterService = module.get<NewsletterService>(NewsletterService);
jest.spyOn(newsletterService, 'subscribe').mockResolvedValue(undefined);
});

it('should be defined', () => {
Expand All @@ -96,6 +102,12 @@
describe('get By Id use case', () => {
const sendLinkMockId = '53cf59ce-599d-4bc3-8497-09b72301d2a4';

it('throw bad request when id is not valid uuid format', async () => {
await expect(service.getById('invalid-uuid')).rejects.toThrow(
BadRequestException,
);
});

it('throw not found when id invalid', async () => {
jest.spyOn(sendRepository, 'findById').mockResolvedValue(null);

Expand Down Expand Up @@ -192,6 +204,10 @@
receivers: ['receiver@gmail.com'],
items: [],
});
expect(newsletterService.subscribe).toHaveBeenCalledWith(
'sender@gmail.com',
undefined,
);
});

it('create send links with user', async () => {
Expand Down Expand Up @@ -222,6 +238,54 @@
receivers: ['receiver@gmail.com'],
items: [],
});
expect(newsletterService.subscribe).toHaveBeenCalledWith(
'sender@gmail.com',
undefined,
);
});

it('create send links with items', async () => {
jest.spyOn(notificationService, 'add').mockResolvedValue(true);
jest
.spyOn(sendRepository, 'createSendLinkWithItems')
.mockResolvedValue(undefined);
jest.spyOn(sendRepository, 'findById').mockResolvedValue(undefined);
jest.spyOn(sendRepository, 'countBySendersToday').mockResolvedValue(2);

const items = [
{
id: '965306cd-0a88-4447-aa7c-d6b354381ae2',
name: 'test.txt',
type: 'file',
networkId: 'network123',
encryptionKey: 'key',
size: 1024,
},
{
id: 'a0ece540-3945-42cf-96d5-a7751f98d5c4',
name: 'folder',
type: 'folder',
networkId: 'network124',
encryptionKey: 'key',
size: 0,
parent_folder: 'parent_id',
},
] as any;

const sendLink = await service.createSendLinks(
userMock,
items,
'code',
['receiver@gmail.com'],
'sender@gmail.com',
'title',
'subject',
'plainCode',
null,
);
expect(sendRepository.createSendLinkWithItems).toHaveBeenCalledTimes(1);
expect(notificationService.add).toHaveBeenCalledTimes(1);
expect(sendLink.items).toHaveLength(2);
});

it('should create a sendLink protected by password', async () => {
Expand All @@ -244,6 +308,50 @@
);

expect(sendLink.isProtected()).toBe(true);
expect(newsletterService.subscribe).toHaveBeenCalledWith(
'sender@gmail.com',
undefined,
);
});

it('create send links should handle newsletter subscription error', async () => {
jest.spyOn(notificationService, 'add').mockResolvedValue(true);
jest
.spyOn(sendRepository, 'createSendLinkWithItems')
.mockResolvedValue(undefined);
jest.spyOn(sendRepository, 'findById').mockResolvedValue(undefined);
jest.spyOn(sendRepository, 'countBySendersToday').mockResolvedValue(2);

const loggerSpy = jest
.spyOn((service as any).logger, 'error')
.mockImplementation(() => {});
jest
.spyOn(newsletterService, 'subscribe')
.mockRejectedValue(new Error('Klaviyo error'));

const sendLink = await service.createSendLinks(
userMock,
[],
'code',
['receiver@gmail.com'],
'sender@gmail.com',
'title',
'subject',
'plainCode',
null,
);
expect(sendRepository.createSendLinkWithItems).toHaveBeenCalledTimes(1);
expect(newsletterService.subscribe).toHaveBeenCalledWith(
'sender@gmail.com',
undefined,
);

// Wait for the fire-and-forget promise to catch and log
await new Promise(setImmediate);

expect(loggerSpy).toHaveBeenCalledWith(
'Failed to subscribe sender@gmail.com to Klaviyo list: Klaviyo error',
);
});

describe('Unlock Link', () => {
Expand Down Expand Up @@ -314,5 +422,32 @@
expect(err).toBeInstanceOf(ForbiddenException);
}
});

it('unlock protected send link with valid password succeeds', () => {
const cryptoService = (service as any).cryptoService;
jest
.spyOn(cryptoService, 'deterministicEncryption')
.mockReturnValue('hashed');

const protectedSendLink = SendLink.build({
id: '46716608-c5e4-5404-a2b9-2a38d737d87d',
views: 0,
user: userMock,
items: [],
createdAt: new Date(),
updatedAt: new Date(),
sender: 'sender@gmail.com',
receivers: ['receiver@gmail.com'],
code: 'code',
title: 'title',
subject: 'subject',
expirationAt: new Date(),
hashedPassword: 'hashed',

Check failure

Code scanning / SonarCloud

Credentials should not be hard-coded High test

Review this potentially hard-coded password. See more on SonarQube Cloud
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thats a false positive

});

expect(() =>
service.unlockLink(protectedSendLink, 'valid-password'),
).not.toThrow();
});
});
});
Loading
Loading