Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[TM-1677] Implement user verification BE #56

Open
wants to merge 8 commits into
base: staging
Choose a base branch
from
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
7 changes: 5 additions & 2 deletions apps/user-service/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,20 @@ import { SentryGlobalFilter, SentryModule } from "@sentry/nestjs/setup";
import { APP_FILTER } from "@nestjs/core";
import { ResetPasswordController } from "./auth/reset-password.controller";
import { ResetPasswordService } from "./auth/reset-password.service";
import { VerificationUserController } from "./auth/verification-user.controller";
import { VerificationUserService } from "./auth/verification-user.service";

@Module({
imports: [SentryModule.forRoot(), DatabaseModule, CommonModule, HealthModule],
controllers: [LoginController, UsersController, ResetPasswordController],
controllers: [LoginController, UsersController, ResetPasswordController, VerificationUserController],
providers: [
{
provide: APP_FILTER,
useClass: SentryGlobalFilter
},
AuthService,
ResetPasswordService
ResetPasswordService,
VerificationUserService
]
})
export class AppModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsNotEmpty } from "class-validator";

export class VerificationUserRequest {
@IsNotEmpty()
@ApiProperty()
token: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { JsonApiDto } from "@terramatch-microservices/common/decorators";
import { JsonApiAttributes } from "@terramatch-microservices/common/dto/json-api-attributes";
import { ApiProperty } from "@nestjs/swagger";

@JsonApiDto({ type: "verifications", id: "uuid" })
export class VerificationUserResponseDto extends JsonApiAttributes<VerificationUserResponseDto> {
@ApiProperty()
verified: boolean;
}
53 changes: 53 additions & 0 deletions apps/user-service/src/auth/verification-user.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Test, TestingModule } from "@nestjs/testing";
import { createMock, DeepMocked } from "@golevelup/ts-jest";
import { NotFoundException } from "@nestjs/common";
import { faker } from "@faker-js/faker";
import { VerificationUserController } from "./verification-user.controller";
import { VerificationUserService } from "./verification-user.service";

describe("VerificationUserController", () => {
let controller: VerificationUserController;
let verificationUserService: DeepMocked<VerificationUserService>;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [VerificationUserController],
providers: [
{
provide: VerificationUserService,
useValue: (verificationUserService = createMock<VerificationUserService>())
}
]
}).compile();

controller = module.get<VerificationUserController>(VerificationUserController);
});

afterEach(() => {
jest.restoreAllMocks();
});

it("should successfully verify a user when token is valid", async () => {
const uuid = faker.string.uuid();
verificationUserService.verify.mockResolvedValue({ uuid, isVerified: true });

const result = await controller.verifyUser({ token: "my token" });
expect(result).toMatchObject({
data: { id: uuid, type: "verifications", attributes: { verified: true } }
});
});

it("should throw NotFoundException if verification is not found", async () => {
verificationUserService.verify.mockRejectedValue(new NotFoundException("Verification not found"));

await expect(controller.verifyUser({ token: "my token" })).rejects.toThrow(
new NotFoundException("Verification not found")
);
});

it("should throw NotFoundException if user is not found", async () => {
verificationUserService.verify.mockRejectedValue(new NotFoundException("User not found"));

await expect(controller.verifyUser({ token: "my token" })).rejects.toThrow(new NotFoundException("User not found"));
});
});
28 changes: 28 additions & 0 deletions apps/user-service/src/auth/verification-user.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Controller, Body, Post, HttpStatus, BadRequestException } from "@nestjs/common";
import { ApiOperation } from "@nestjs/swagger";
import { ExceptionResponse, JsonApiResponse } from "@terramatch-microservices/common/decorators";
import { buildJsonApi, JsonApiDocument } from "@terramatch-microservices/common/util";
import { VerificationUserService } from "./verification-user.service";
import { VerificationUserRequest } from "./dto/verification-user-request.dto";
import { VerificationUserResponseDto } from "./dto/verification-user-response.dto";
import { NoBearerAuth } from "@terramatch-microservices/common/guards";

@Controller("auth/v3/verifications")
export class VerificationUserController {
constructor(private readonly verificationUserService: VerificationUserService) {}

@Post()
@NoBearerAuth
@ApiOperation({
operationId: "verifyUser",
description: "Receive a token to verify a user and return the verification status"
})
@JsonApiResponse({ status: HttpStatus.CREATED, data: { type: VerificationUserResponseDto } })
@ExceptionResponse(BadRequestException, { description: "Invalid request" })
async verifyUser(@Body() { token }: VerificationUserRequest): Promise<JsonApiDocument> {
const { uuid, isVerified } = await this.verificationUserService.verify(token);
return buildJsonApi()
.addData(uuid, new VerificationUserResponseDto({ verified: isVerified }))
.document.serialize();
}
}
47 changes: 47 additions & 0 deletions apps/user-service/src/auth/verification-user.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Test, TestingModule } from "@nestjs/testing";
import { User, Verification } from "@terramatch-microservices/database/entities";
import { NotFoundException } from "@nestjs/common";
import { VerificationUserService } from "./verification-user.service";
import { UserFactory } from "@terramatch-microservices/database/factories";
import { VerificationFactory } from "@terramatch-microservices/database/factories/verification.factory";

describe("VerificationUserService", () => {
let service: VerificationUserService;

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

service = module.get<VerificationUserService>(VerificationUserService);
});

afterEach(() => {
jest.restoreAllMocks();
});

it("should throw when user is not found", async () => {
jest.spyOn(User, "findOne").mockImplementation(() => Promise.resolve(null));
await expect(service.verify("my token")).rejects.toThrow(NotFoundException);
});

it("should throw when verification is not found", async () => {
const user = await UserFactory.create();
jest.spyOn(User, "findOne").mockImplementation(() => Promise.resolve(user));
jest.spyOn(Verification, "findOne").mockImplementation(() => Promise.resolve(null));
await expect(service.verify("my token")).rejects.toThrow(NotFoundException);
});

it("should verify an user", async () => {
const user = await UserFactory.create();
const verification = await VerificationFactory.create({ userId: user.id });
verification.user = user;
jest.spyOn(Verification, "findOne").mockImplementation(() => Promise.resolve(verification));
const destroySpy = jest.spyOn(verification, "destroy").mockResolvedValue();

const result = await service.verify(verification.token);
expect(user.emailAddressVerifiedAt).toBeDefined();
expect(destroySpy).toHaveBeenCalled();
expect(result).toStrictEqual({ uuid: user.uuid, isVerified: true });
});
});
27 changes: 27 additions & 0 deletions apps/user-service/src/auth/verification-user.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Injectable, NotFoundException, LoggerService } from "@nestjs/common";
import { TMLogService } from "@terramatch-microservices/common/util/tm-log.service";
import { Verification } from "@terramatch-microservices/database/entities/verification.entity";

@Injectable()
export class VerificationUserService {
protected readonly logger: LoggerService = new TMLogService(VerificationUserService.name);

async verify(token: string) {
const verification = await Verification.findOne({
where: { token },
include: [{ association: "user", attributes: ["id", "uuid", "emailAddressVerifiedAt"] }]
});

if (verification?.user == null) throw new NotFoundException("Verification token invalid");
const user = verification.user;
try {
user.emailAddressVerifiedAt = new Date();
await user.save();
await verification.destroy();
return { uuid: user.uuid, isVerified: true };
} catch (error) {
this.logger.error(error);
return { uuid: user.uuid, isVerified: false };
}
}
}
1 change: 1 addition & 0 deletions libs/database/src/lib/entities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@ export * from "./site-report.entity";
export * from "./tree-species.entity";
export * from "./tree-species-research.entity";
export * from "./user.entity";
export * from "./verification.entity";
21 changes: 21 additions & 0 deletions libs/database/src/lib/entities/verification.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { AutoIncrement, BelongsTo, Column, ForeignKey, Model, PrimaryKey, Table } from "sequelize-typescript";
import { BIGINT, STRING } from "sequelize";
import { User } from "./user.entity";

@Table({ tableName: "verifications", underscored: true })
export class Verification extends Model<Verification> {
@PrimaryKey
@AutoIncrement
@Column(BIGINT.UNSIGNED)
override id: number;

@Column(STRING)
token: string | null;

@ForeignKey(() => User)
@Column(BIGINT.UNSIGNED)
userId: number;
Copy link
Collaborator

Choose a reason for hiding this comment

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

This userId should get a foreign key and this entity should get a relationship to user:

  @ForeignKey(() => User)
  @Column(BIGINT.UNSIGNED)
  userId: number;

  @BelongsTo(() => User)
  user: User | null;


@BelongsTo(() => User)
user: User | null;
}
8 changes: 8 additions & 0 deletions libs/database/src/lib/factories/verification.factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { FactoryGirl } from "factory-girl-ts";
import { faker } from "@faker-js/faker";
import { Verification } from "../entities";

export const VerificationFactory = FactoryGirl.define(Verification, async () => ({
token: faker.lorem.word(),
userId: faker.number
}));
Loading