Skip to content
This repository was archived by the owner on Mar 20, 2023. It is now read-only.

Commit a52eb89

Browse files
KrystianKjjkMateusz Nowak
andauthored
feat(api): test strategy for guarded REST API endpoints (378) (#385)
* add api docs and test for approve email-confirmation Co-authored-by: Mateusz Nowak <mateusz.nowak@vonage.com>
1 parent dde8c45 commit a52eb89

File tree

8 files changed

+235
-48
lines changed

8 files changed

+235
-48
lines changed

.eslintrc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ module.exports = {
180180
'testHelpers.tsx',
181181
'*.test-module.ts',
182182
'test-utils.ts',
183+
'rest-api-test-utils.ts',
183184
'*Handlers.ts',
184185
'**/mocks/*',
185186
'jest-setup.ts',

packages/api/rest-api-docs.yaml

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,25 @@ paths:
8787
description: 'Email confirmation was successfully requested. Email was sent.'
8888
tags:
8989
- Email Confirmation
90+
/email-confirmation/approval:
91+
post:
92+
summary: Confirm email for given user.
93+
operationId: ConfirmUserRegistrationPost
94+
requestBody:
95+
required: true
96+
content:
97+
application/json:
98+
schema:
99+
$ref: '#/components/schemas/ApproveEmailConfirmationBody'
100+
responses:
101+
204:
102+
description: 'Mail for user successfully confirmed'
103+
400:
104+
$ref: '#/components/responses/BadRequest'
105+
401:
106+
$ref: '#/components/responses/Unauthorized'
107+
tags:
108+
- Email Confirmation
90109
/users:
91110
get:
92111
summary: All users registered in the platform
@@ -138,7 +157,7 @@ paths:
138157
get:
139158
summary: How many course activites was completed by user.
140159
operationId: CourseProgress_getCourseProgress
141-
parameters: [ ]
160+
parameters: []
142161
responses:
143162
200:
144163
description: ''
@@ -259,6 +278,15 @@ components:
259278
example: user-registration
260279
required:
261280
- confirmationFor
281+
ApproveEmailConfirmationBody:
282+
type: object
283+
properties:
284+
confirmationToken:
285+
description: Given confirmation token created meanwhile registration
286+
type: string
287+
example: someExample.Token
288+
required:
289+
- confirmationFor
262290
CourseProgressGetResponseBody:
263291
description: Current course progress of logged user
264292
type: object
@@ -398,7 +426,7 @@ components:
398426
message:
399427
type: string
400428
description: Error message
401-
example: "Learning materials url was already generated"
429+
example: 'Learning materials url was already generated'
402430
required:
403431
- message
404432
NestErrorResponseBody:
Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ApproveEmailConfirmation } from '@/module/commands/approve-email-confirmation';
22
import { emailConfirmationWasApprovedEvent } from '@/module/events/email-confirmation-was-approved.domain.event';
3+
import { DomainRuleViolationException } from '@/shared/errors/domain-rule-violation.exception';
34

45
import { EmailConfirmationDomainEvent } from './events';
56

@@ -8,13 +9,14 @@ export const approveEmailConfirmation =
89
(pastEvents: EmailConfirmationDomainEvent[]): EmailConfirmationDomainEvent[] => {
910
const lastPublishedEmailConfirmation = pastEvents[pastEvents.length - 1];
1011

11-
if (!lastPublishedEmailConfirmation) throw new Error("Couldn't find request which could be approved");
12+
if (!lastPublishedEmailConfirmation)
13+
throw new DomainRuleViolationException("Couldn't find request which could be approved");
1214

1315
if (lastPublishedEmailConfirmation.type === 'EmailConfirmationWasApproved')
14-
throw new Error('Email confirmation has been already approved');
16+
throw new DomainRuleViolationException('Email confirmation has been already approved');
1517

1618
if (lastPublishedEmailConfirmation.data.confirmationToken !== command.data.confirmationToken)
17-
throw new Error('An attempt was made on obsolete confirmation token');
19+
throw new DomainRuleViolationException('An attempt was made on obsolete confirmation token');
1820

1921
return [emailConfirmationWasApprovedEvent(command.data)];
2022
};
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { HttpStatus } from '@nestjs/common';
2+
import { AsyncReturnType } from 'type-fest';
3+
4+
import { APPROVE_ENDPOINT } from '@coderscamp/shared/models/email-confirmation/approve-email-confirmation';
5+
6+
import { DomainRuleViolationException } from '@/shared/errors/domain-rule-violation.exception';
7+
import { initTestModuleRestApi } from '@/shared/rest-api-test-utils';
8+
9+
import { initOpenApiExpect } from '../../../../../../jest-setup';
10+
import { EmailConfirmationRestController } from './email-confirmation.rest-controller';
11+
12+
initOpenApiExpect();
13+
14+
describe('email confirmation | REST API', () => {
15+
let restUnderTest: AsyncReturnType<typeof initTestModuleRestApi>;
16+
17+
beforeAll(async () => {
18+
restUnderTest = await initTestModuleRestApi(EmailConfirmationRestController);
19+
restUnderTest.commandBusExecute.mockClear();
20+
});
21+
22+
afterAll(async () => {
23+
await restUnderTest.close();
24+
});
25+
26+
describe(`POST /email-confirmation${APPROVE_ENDPOINT}`, () => {
27+
it('Failed, user is not authenticated', async () => {
28+
// Given
29+
restUnderTest.commandBusExecute.mockImplementation(() => Promise.resolve());
30+
31+
// When
32+
const response = await restUnderTest.http.post(`/api/email-confirmation${APPROVE_ENDPOINT}`).send({
33+
confirmationToken: 'exampleToken',
34+
});
35+
36+
// Then
37+
expect(response.status).toBe(HttpStatus.UNAUTHORIZED);
38+
expect(response.body.message).toEqual('Unauthorized');
39+
expect(response).toSatisfyApiSpec();
40+
});
41+
42+
it('Failed, trying to approve email confirmation without earlier request', async () => {
43+
// Given
44+
restUnderTest.commandBusExecute.mockRejectedValue(
45+
new DomainRuleViolationException("Couldn't find request which could be approved"),
46+
);
47+
48+
// When
49+
const response = await restUnderTest.asLoggedUser((http) =>
50+
http.post(`/api/email-confirmation${APPROVE_ENDPOINT}`).send({
51+
confirmationToken: 'exampleToken',
52+
}),
53+
);
54+
55+
// Then
56+
expect(response.status).toBe(HttpStatus.BAD_REQUEST);
57+
expect(response.body.message).toBe("Couldn't find request which could be approved");
58+
expect(response).toSatisfyApiSpec();
59+
});
60+
61+
it('Success, email confirmation has been approved', async () => {
62+
// Given
63+
restUnderTest.commandBusExecute.mockImplementation(() => Promise.resolve());
64+
65+
// When
66+
const response = await restUnderTest.asLoggedUser((http) =>
67+
http.post(`/api/email-confirmation${APPROVE_ENDPOINT}`).send({
68+
confirmationToken: 'exampleToken',
69+
}),
70+
);
71+
72+
// Then
73+
expect(response.status).toBe(HttpStatus.NO_CONTENT);
74+
expect(response).toSatisfyApiSpec();
75+
});
76+
});
77+
});

packages/api/src/module/write/user-registration/domain/CompleteUserRegistration.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { CompleteUserRegistration } from '@/commands/complete-user-registration';
22
import { userRegistrationWasCompletedEvent } from '@/events/user-registration-was-completed.domain-event';
3+
import { DomainRuleViolationException } from '@/shared/errors/domain-rule-violation.exception';
34
import { UserRegistrationDomainEvent } from '@/write/user-registration/domain/events';
45

56
export const completeUserRegistration =
@@ -8,10 +9,13 @@ export const completeUserRegistration =
89
const { data } = command;
910
const lastUserRegistrationEvent = pastEvents[pastEvents.length - 1];
1011

11-
if (!lastUserRegistrationEvent) throw new Error('Impossible to complete registration while it is not started');
12+
if (!lastUserRegistrationEvent)
13+
throw new DomainRuleViolationException('Impossible to complete registration while it is not started');
1214

1315
if (lastUserRegistrationEvent.type === 'UserRegistrationWasCompleted')
14-
throw new Error(`Registration for user ${lastUserRegistrationEvent.data.fullName} was already completed`);
16+
throw new DomainRuleViolationException(
17+
`Registration for user ${lastUserRegistrationEvent.data.fullName} was already completed`,
18+
);
1519

1620
const { fullName, emailAddress, hashedPassword } = lastUserRegistrationEvent.data;
1721
const userRegistrationWasCompleted = userRegistrationWasCompletedEvent({

packages/api/src/module/write/user-registration/presentation/rest/user-registration.rest-controller.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { AsyncReturnType } from 'type-fest';
44
import { registerError } from '@coderscamp/shared/models/auth/register';
55

66
import { DomainRuleViolationException } from '@/shared/errors/domain-rule-violation.exception';
7-
import { initTestModuleRestApi } from '@/shared/test-utils';
7+
import { initTestModuleRestApi } from '@/shared/rest-api-test-utils';
88
import { UserRegistrationRestController } from '@/write/user-registration/presentation/rest/user-registration.rest-controller';
99

1010
import { initOpenApiExpect } from '../../../../../../jest-setup';
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { INestApplication } from '@nestjs/common';
2+
import { Type } from '@nestjs/common/interfaces/type.interface';
3+
import { CommandBus } from '@nestjs/cqrs';
4+
import { Test, TestingModuleBuilder } from '@nestjs/testing';
5+
import supertest from 'supertest';
6+
import { v4 as uuid } from 'uuid';
7+
8+
import { AuthUser } from '@coderscamp/shared/models/auth';
9+
10+
import { AuthModule } from '@/crud/auth/auth.module';
11+
import { PrismaService } from '@/prisma/prisma.service';
12+
import { cleanupDatabase } from '@/shared/test-utils';
13+
import { ApplicationCommandFactory } from '@/write/shared/application/application-command.factory';
14+
import { UuidGenerator } from '@/write/shared/infrastructure/id-generator/uuid-generator';
15+
import { hashPassword } from '@/write/shared/infrastructure/password-encoder/crypto-password-encoder';
16+
import { SystemTimeProvider } from '@/write/shared/infrastructure/time-provider/system-time-provider';
17+
18+
import { setupMiddlewares } from '../app.middlewares';
19+
import { eventEmitterRootModule } from '../event-emitter.root-module';
20+
21+
const DEFAULT_TEST_PASSWORD = 'stronk';
22+
23+
export async function initTestModuleRestApi(
24+
controller: Type,
25+
config?: (module: TestingModuleBuilder) => TestingModuleBuilder,
26+
) {
27+
const commandBusExecute = jest.fn();
28+
const moduleBuilder = await Test.createTestingModule({
29+
providers: [
30+
{
31+
provide: CommandBus,
32+
useValue: { execute: commandBusExecute, register: jest.fn() },
33+
},
34+
{
35+
provide: ApplicationCommandFactory,
36+
useValue: new ApplicationCommandFactory(new UuidGenerator(), new SystemTimeProvider()),
37+
},
38+
],
39+
controllers: [controller],
40+
imports: [eventEmitterRootModule, AuthModule],
41+
});
42+
const moduleRef = await (config ? config(moduleBuilder) : moduleBuilder).compile();
43+
44+
const app: INestApplication = moduleRef.createNestApplication();
45+
46+
const prismaService = app.get<PrismaService>(PrismaService);
47+
48+
await cleanupDatabase(prismaService);
49+
50+
setupMiddlewares(app);
51+
52+
await app.init();
53+
54+
const http = supertest(app.getHttpServer());
55+
56+
const randomUser = () => {
57+
const id = uuid();
58+
59+
return {
60+
id,
61+
email: `${id}@email.com`,
62+
password: DEFAULT_TEST_PASSWORD,
63+
};
64+
};
65+
66+
const registerUser = async (userToCreate: Partial<AuthUser> = randomUser()) => {
67+
const id = userToCreate.id ?? uuid();
68+
const authUser = {
69+
id,
70+
email: `${id}@email.com`,
71+
password: DEFAULT_TEST_PASSWORD,
72+
...userToCreate,
73+
};
74+
const hashedPassword = await hashPassword(authUser.password);
75+
76+
return prismaService.authUser.create({
77+
data: {
78+
...authUser,
79+
password: hashedPassword,
80+
},
81+
});
82+
};
83+
84+
const loginUser = async (request: { email: string } = randomUser()) => {
85+
await registerUser({ email: request.email, password: DEFAULT_TEST_PASSWORD });
86+
87+
const response = await http.post('/api/auth/login').send(request);
88+
89+
if (response.status !== 204) {
90+
throw new Error('Example user login failed');
91+
}
92+
93+
return response.get('set-cookie');
94+
};
95+
96+
const logoutUser = async () => {
97+
const response = await http.post('/api/auth/logout').send();
98+
99+
if (response.status !== 201) {
100+
throw new Error('Logout user failed');
101+
}
102+
};
103+
104+
const asLoggedUser = async (request: (http: supertest.SuperTest<supertest.Test>) => supertest.Test) => {
105+
const cookie = await loginUser();
106+
107+
return request(http).set('Cookie', cookie);
108+
};
109+
110+
async function close() {
111+
await app.close();
112+
}
113+
114+
return { http, close, commandBusExecute, loginUser, logoutUser, asLoggedUser };
115+
}

packages/api/src/shared/test-utils.ts

Lines changed: 0 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
1-
import { INestApplication } from '@nestjs/common';
21
import { Abstract } from '@nestjs/common/interfaces';
32
import { ModuleMetadata } from '@nestjs/common/interfaces/modules/module-metadata.interface';
43
import { Type } from '@nestjs/common/interfaces/type.interface';
54
import { CommandBus, ICommand } from '@nestjs/cqrs';
65
import { Test, TestingModule, TestingModuleBuilder } from '@nestjs/testing';
76
import _ from 'lodash';
8-
import supertest from 'supertest';
97
import { v4 as uuid } from 'uuid';
108
import waitForExpect from 'wait-for-expect';
119

@@ -21,12 +19,9 @@ import { EventStreamName } from '@/write/shared/application/event-stream-name.va
2119
import { SubscriptionId } from '@/write/shared/application/events-subscription/events-subscription';
2220
import { ID_GENERATOR, IdGenerator } from '@/write/shared/application/id-generator';
2321
import { TIME_PROVIDER } from '@/write/shared/application/time-provider.port';
24-
import { UuidGenerator } from '@/write/shared/infrastructure/id-generator/uuid-generator';
2522
import { FixedTimeProvider } from '@/write/shared/infrastructure/time-provider/fixed-time-provider';
26-
import { SystemTimeProvider } from '@/write/shared/infrastructure/time-provider/system-time-provider';
2723
import { SharedModule } from '@/write/shared/shared.module';
2824

29-
import { setupMiddlewares } from '../app.middlewares';
3025
import { AppModule } from '../app.module';
3126
import { eventEmitterRootModule } from '../event-emitter.root-module';
3227

@@ -373,38 +368,3 @@ export const commandBusNoFailWithoutHandler: Partial<CommandBus> = {
373368
register: jest.fn(),
374369
execute: jest.fn(),
375370
};
376-
377-
export async function initTestModuleRestApi(
378-
controller: Type,
379-
config?: (module: TestingModuleBuilder) => TestingModuleBuilder,
380-
) {
381-
const commandBusExecute = jest.fn();
382-
const moduleBuilder = await Test.createTestingModule({
383-
providers: [
384-
{
385-
provide: CommandBus,
386-
useValue: { execute: commandBusExecute, register: jest.fn() },
387-
},
388-
{
389-
provide: ApplicationCommandFactory,
390-
useValue: new ApplicationCommandFactory(new UuidGenerator(), new SystemTimeProvider()),
391-
},
392-
],
393-
controllers: [controller],
394-
});
395-
const moduleRef = await (config ? config(moduleBuilder) : moduleBuilder).compile();
396-
397-
const app: INestApplication = moduleRef.createNestApplication();
398-
399-
setupMiddlewares(app);
400-
401-
await app.init();
402-
403-
const http = supertest(app.getHttpServer());
404-
405-
async function close() {
406-
await app.close();
407-
}
408-
409-
return { http, close, commandBusExecute };
410-
}

0 commit comments

Comments
 (0)