diff --git a/.gitignore b/.gitignore index 43ad4b2f..b324834c 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,8 @@ lerna-debug.log* .data /files .env -/ormconfig.json \ No newline at end of file +/ormconfig.json + +# Other +local_dev/ +temp/ diff --git a/package-lock.json b/package-lock.json index 1efe0632..082e3853 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "api-cct", - "version": "0.0.4", + "version": "0.0.8", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "api-cct", - "version": "0.0.4", + "version": "0.0.8", "license": "UNLICENSED", "dependencies": { "@aws-sdk/client-s3": "3.350.0", @@ -32,6 +32,7 @@ "class-validator": "0.14.0", "date-fns": "^2.30.0", "fb": "2.0.0", + "gerador-validador-cpf": "^5.0.2", "google-auth-library": "8.8.0", "handlebars": "4.7.7", "multer": "1.4.4", @@ -65,6 +66,7 @@ "@types/node": "18.16.16", "@types/passport-anonymous": "1.0.3", "@types/passport-jwt": "3.0.8", + "@types/selenium-webdriver": "^4.1.21", "@types/supertest": "2.0.12", "@types/twitter": "1.7.1", "@typescript-eslint/eslint-plugin": "5.59.9", @@ -4181,6 +4183,15 @@ "form-data": "^2.5.0" } }, + "node_modules/@types/selenium-webdriver": { + "version": "4.1.21", + "resolved": "https://registry.npmjs.org/@types/selenium-webdriver/-/selenium-webdriver-4.1.21.tgz", + "integrity": "sha512-QGURnImvxYlIQz5DVhvHdqpYNLBjhJ2Vm+cnQI2G9QZzkWlZm0LkLcvDcHp+qE6N2KBz4CeuvXgPO7W3XQ0Tyw==", + "dev": true, + "dependencies": { + "@types/ws": "*" + } + }, "node_modules/@types/semver": { "version": "7.5.0", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.0.tgz", @@ -4242,6 +4253,15 @@ "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.10.tgz", "integrity": "sha512-t1yxFAR2n0+VO6hd/FJ9F2uezAZVWHLmpmlJzm1eX03+H7+HsuTAp7L8QJs+2pQCfWkP1+EXsGK9Z9v7o/qPVQ==" }, + "node_modules/@types/ws": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", + "integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.12", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.12.tgz", @@ -8115,6 +8135,11 @@ "node": ">=6.9.0" } }, + "node_modules/gerador-validador-cpf": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/gerador-validador-cpf/-/gerador-validador-cpf-5.0.2.tgz", + "integrity": "sha512-7nqJilkfIv3HIbB50uP32SOxe/A3TyvVS3AXxwU6cqHq7jMTnkp0WGPaGytY3Yc36RjzysVQ6xhlwcCt70CnOw==" + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -19074,6 +19099,15 @@ "form-data": "^2.5.0" } }, + "@types/selenium-webdriver": { + "version": "4.1.21", + "resolved": "https://registry.npmjs.org/@types/selenium-webdriver/-/selenium-webdriver-4.1.21.tgz", + "integrity": "sha512-QGURnImvxYlIQz5DVhvHdqpYNLBjhJ2Vm+cnQI2G9QZzkWlZm0LkLcvDcHp+qE6N2KBz4CeuvXgPO7W3XQ0Tyw==", + "dev": true, + "requires": { + "@types/ws": "*" + } + }, "@types/semver": { "version": "7.5.0", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.0.tgz", @@ -19135,6 +19169,15 @@ "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.10.tgz", "integrity": "sha512-t1yxFAR2n0+VO6hd/FJ9F2uezAZVWHLmpmlJzm1eX03+H7+HsuTAp7L8QJs+2pQCfWkP1+EXsGK9Z9v7o/qPVQ==" }, + "@types/ws": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", + "integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/yargs": { "version": "17.0.12", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.12.tgz", @@ -22129,6 +22172,11 @@ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "dev": true }, + "gerador-validador-cpf": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/gerador-validador-cpf/-/gerador-validador-cpf-5.0.2.tgz", + "integrity": "sha512-7nqJilkfIv3HIbB50uP32SOxe/A3TyvVS3AXxwU6cqHq7jMTnkp0WGPaGytY3Yc36RjzysVQ6xhlwcCt70CnOw==" + }, "get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", diff --git a/package.json b/package.json index 9643c1de..7d4348dd 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "class-validator": "0.14.0", "date-fns": "^2.30.0", "fb": "2.0.0", + "gerador-validador-cpf": "^5.0.2", "google-auth-library": "8.8.0", "handlebars": "4.7.7", "multer": "1.4.4", @@ -86,6 +87,7 @@ "@types/node": "18.16.16", "@types/passport-anonymous": "1.0.3", "@types/passport-jwt": "3.0.8", + "@types/selenium-webdriver": "^4.1.21", "@types/supertest": "2.0.12", "@types/twitter": "1.7.1", "@typescript-eslint/eslint-plugin": "5.59.9", diff --git a/src/auth-licensee/auth-licensee.service.spec.ts b/src/auth-licensee/auth-licensee.service.spec.ts index 527f9657..4445ccac 100644 --- a/src/auth-licensee/auth-licensee.service.spec.ts +++ b/src/auth-licensee/auth-licensee.service.spec.ts @@ -21,7 +21,7 @@ import { BaseValidator } from 'src/utils/validators/base-validator'; /** * All tests below were based on the requirements on GitHub. - * @see {@link https://github.com/RJ-SMTR/api-cct/issues/94#issuecomment-1815016208 Requirements - GitHub} + * @see {@link https://github.com/RJ-SMTR/api-cct/issues/94#issuecomment-1815016208 Requirements #94 - GitHub} */ describe('AuthLicenseeService', () => { let authLicenseeService: AuthLicenseeService; diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts index 30b3fffa..ece61daa 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/auth/auth.service.spec.ts @@ -19,7 +19,7 @@ process.env.TZ = 'UTC'; /** * All tests below were based on the requirements on GitHub. - * @see {@link https://github.com/RJ-SMTR/api-cct/issues/94#issuecomment-1815016208 Requirements - GitHub} + * @see {@link https://github.com/RJ-SMTR/api-cct/issues/94#issuecomment-1815016208 Requirements #94 - GitHub} */ describe('AuthService', () => { let authService: AuthService; diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 216ef5f6..0a6c3a3d 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -381,16 +381,7 @@ export class AuthService { return returnMessage; } - let hash = crypto - .createHash('sha256') - .update(randomStringGenerator()) - .digest('hex'); - while (await this.mailHistoryService.findOne({ hash })) { - hash = crypto - .createHash('sha256') - .update(randomStringGenerator()) - .digest('hex'); - } + const hash = await this.forgotService.generateHash(); await this.forgotService.create({ hash, @@ -447,7 +438,8 @@ export class AuthService { { error: HttpErrorMessages.UNAUTHORIZED, details: { - hash: `notFound`, + error: 'hash not found', + hash, }, }, HttpStatus.UNAUTHORIZED, diff --git a/src/cron-jobs/cron-jobs.service.spec.ts b/src/cron-jobs/cron-jobs.service.spec.ts index 76047185..72778f88 100644 --- a/src/cron-jobs/cron-jobs.service.spec.ts +++ b/src/cron-jobs/cron-jobs.service.spec.ts @@ -21,7 +21,7 @@ import { DeepPartial } from 'typeorm'; /** * All tests below were based on the requirements on GitHub. - * @see {@link https://github.com/RJ-SMTR/api-cct/issues/94#issuecomment-1815016208 Requirements - GitHub} + * @see {@link https://github.com/RJ-SMTR/api-cct/issues/94#issuecomment-1815016208 Requirements #94 - GitHub} */ describe('CronJobsService', () => { let cronJobsService: CronJobsService; diff --git a/src/database/seeds/user/user-seed-data.service.ts b/src/database/seeds/user/user-seed-data.service.ts index 01cf29a1..b6421d09 100644 --- a/src/database/seeds/user/user-seed-data.service.ts +++ b/src/database/seeds/user/user-seed-data.service.ts @@ -137,6 +137,14 @@ export class UserSeedDataService { role: { id: RoleEnum.admin } as Role, status: { id: StatusEnum.active } as Status, }, + { + fullName: 'Administrador Teste', + email: 'admin.test@example.com', + password: 'secret', + permitCode: '', + role: { id: RoleEnum.admin } as Role, + status: { id: StatusEnum.active } as Status, + }, ] : []), ]; diff --git a/src/forgot/forgot.service.ts b/src/forgot/forgot.service.ts index 8379de49..60dcc606 100644 --- a/src/forgot/forgot.service.ts +++ b/src/forgot/forgot.service.ts @@ -1,3 +1,5 @@ +import * as crypto from 'crypto'; +import { randomStringGenerator } from '@nestjs/common/utils/random-string-generator.util'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { FindOptions } from 'src/utils/types/find-options.type'; @@ -31,4 +33,18 @@ export class ForgotService { async softDelete(id: number): Promise { await this.forgotRepository.softDelete(id); } + + async generateHash(): Promise { + let hash = crypto + .createHash('sha256') + .update(randomStringGenerator()) + .digest('hex'); + while (await this.findOne({ where: { hash } })) { + hash = crypto + .createHash('sha256') + .update(randomStringGenerator()) + .digest('hex'); + } + return hash; + } } diff --git a/src/mail-history/mail-history.service.spec.ts b/src/mail-history/mail-history.service.spec.ts index 38243245..9df3e289 100644 --- a/src/mail-history/mail-history.service.spec.ts +++ b/src/mail-history/mail-history.service.spec.ts @@ -53,7 +53,7 @@ describe('InviteService', () => { }); /** - * @see {@link https://github.com/RJ-SMTR/api-cct/issues/94#issuecomment-1815016208 Requirements - GitHub} + * @see {@link https://github.com/RJ-SMTR/api-cct/issues/94#issuecomment-1815016208 Requirements #94 - GitHub} */ describe('getUpdatedMailCounts', () => { it('should return quota as max value after midnight', async () => { diff --git a/src/mail/mail.service.ts b/src/mail/mail.service.ts index 98972f1f..c6b96911 100644 --- a/src/mail/mail.service.ts +++ b/src/mail/mail.service.ts @@ -127,7 +127,7 @@ export class MailService { async sendForgotPassword( mailData: MailData<{ hash: string }>, ): Promise { - const resetPasswordTitle = 'Refedinir senha'; + const mailTitle = 'Redefinir senha'; try { const frontendDomain = this.configService.get('app.frontendDomain', { @@ -135,17 +135,17 @@ export class MailService { }); const response = await this.safeSendMail({ to: mailData.to, - subject: resetPasswordTitle, + subject: mailTitle, text: `${this.configService.get('app.frontendDomain', { infer: true, - })}reset-password/${mailData.data.hash} ${resetPasswordTitle}`, + })}reset-password/${mailData.data.hash} ${mailTitle}`, template: 'reset-password', context: { - title: resetPasswordTitle, + title: mailTitle, url: `${this.configService.get('app.frontendDomain', { infer: true, })}reset-password/${mailData.data.hash}`, - actionTitle: resetPasswordTitle, + actionTitle: mailTitle, logoSrc: `${frontendDomain}/assets/icons/logoPrefeitura.png`, logoAlt: 'Prefeitura do Rio', bodyText: 'Redefina sua senha clicando no botão abaixo!', @@ -168,7 +168,7 @@ export class MailService { statusCount: IMailHistoryStatusCount; }>, ): Promise { - const resetPasswordTitle = 'Relatório diário'; + const mailTitle = 'Relatório diário'; const from = this.configService.get('mail.senderNotification', { infer: true, }); @@ -192,16 +192,11 @@ export class MailService { const response = await this.safeSendMail({ from, to: mailData.to, - subject: resetPasswordTitle, - text: `${this.configService.get('app.frontendDomain', { - infer: true, - })}reset-password/${'mailData.data.hash'} ${resetPasswordTitle}`, + subject: mailTitle, + text: mailTitle, template: 'report', context: { - title: resetPasswordTitle, - url: `${this.configService.get('app.frontendDomain', { - infer: true, - })}reset-password/${'mailData.data.hash'}`, + title: mailTitle, headerTitle: appName, mailQueued: mailData.data.statusCount.queued, mailSent: mailData.data.statusCount.sent, diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 3ef666cb..cca3d145 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -79,6 +79,7 @@ export class UsersService { paginationOptions: IPaginationOptions, fields?: IFindUserPaginated, ): Promise { + console.log('findManyWithPagination'); const isSgtuBlocked = fields?.isSgtuBlocked || fields?._anyField?.value; let inviteStatus: any = null; @@ -91,6 +92,15 @@ export class UsersService { ), }; } + + const andWhere = { + ...(fields?.role + ? { + role: { id: fields.role.id }, + } + : {}), + } as FindOptionsWhere; + const where = [ ...(fields?.name || fields?._anyField?.value ? [ @@ -138,20 +148,13 @@ export class UsersService { }, ] : []), - ...(fields?.role - ? [ - { - role: { id: fields.role.id }, - }, - ] - : []), ] as FindOptionsWhere[]; - let users = await this.usersRepository.find({ - ...(fields ? { where: where } : {}), - skip: (paginationOptions.page - 1) * paginationOptions.limit, - take: paginationOptions.limit, - }); + let users = await this.usersRepository + .createQueryBuilder() + .where(where) + .andWhere(andWhere) + .getMany(); let invites: NullableType = null; if (inviteStatus) { diff --git a/test/admin/auth.e2e-spec.ts b/test/admin/auth.e2e-spec.ts index 8474530c..5790bac9 100644 --- a/test/admin/auth.e2e-spec.ts +++ b/test/admin/auth.e2e-spec.ts @@ -1,24 +1,83 @@ +import { HttpStatus } from '@nestjs/common'; +import { differenceInSeconds } from 'date-fns'; import * as request from 'supertest'; -import { ADMIN_EMAIL, ADMIN_PASSWORD, APP_URL } from '../utils/constants'; +import { + ADMIN_2_EMAIL, + ADMIN_EMAIL, + ADMIN_PASSWORD, + APP_URL, + MAILDEV_URL, +} from '../utils/constants'; -describe('Auth admin (e2e)', () => { - const app = APP_URL; +describe('Admin auth (e2e)', () => { + describe('Setup tests', () => { + it('Should have UTC and local timezones', () => { + new Date().getTimezoneOffset(); + expect(process.env.TZ).toEqual('UTC'); + expect(global.__localTzOffset).toBeDefined(); + }); - it('Login: /api/v1/auth/admin/email/login (POST)', () => { - return request(app) - .post('/api/v1/auth/admin/email/login') - .send({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }) - .expect(200) - .expect(({ body }) => { - expect(body.token).toBeDefined(); - expect(body.user.email).toBeDefined(); - }); + it('Should have mailDev server', async () => { + await request(MAILDEV_URL).get('').expect(HttpStatus.OK); + }); }); - it('Login via user endpoint: /api/v1/auth/email/login (POST)', () => { - return request(app) - .post('/api/v1/auth/email/login') - .send({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }) - .expect(422); + /** + * @see {@link https://github.com/RJ-SMTR/api-cct/issues/94#issuecomment-1815016208 Requirements #94 - GitHub} + */ + describe('Phase 1: Admin basics and user management', () => { + test('Login admin: POST /api/v1/auth/admin/email/login', () => { + return request(APP_URL) + .post('/api/v1/auth/admin/email/login') + .send({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }) + .expect(HttpStatus.OK) + .expect(({ body }) => { + expect(body.token).toBeDefined(); + expect(body.user.email).toBeDefined(); + }); + }); + + test('Reset admin password', async () => { + await request(APP_URL) + .post('/api/v1/auth/forgot/password') + .send({ + email: ADMIN_2_EMAIL, + }) + .expect(HttpStatus.ACCEPTED); + const forgotLocalDate = new Date(); + forgotLocalDate.setMinutes( + forgotLocalDate.getMinutes() + global.__localTzOffset, + ); + + const hash = await request(MAILDEV_URL) + .get('/email') + .then(({ body }) => + (body as any[]) + .filter( + (letter: any) => + letter.to[0].address.toLowerCase() === + ADMIN_2_EMAIL.toLowerCase() && + /.*reset\-password\/(\w+).*/g.test(letter.text) && + differenceInSeconds(forgotLocalDate, new Date(letter.date)) <= + 10, + ) + .pop() + ?.text.replace(/.*reset\-password\/(\w+).*/g, '$1'), + ); + + const newPassword = Math.random().toString(36).slice(-8); + await request(APP_URL) + .post('/api/v1/auth/reset/password') + .send({ + hash, + password: newPassword, + }) + .expect(HttpStatus.NO_CONTENT); + + await request(APP_URL) + .post('/api/v1/auth/admin/email/login') + .send({ email: ADMIN_2_EMAIL, password: newPassword }) + .expect(HttpStatus.OK); + }, 60000); }); }); diff --git a/test/admin/users.e2e-spec.ts b/test/admin/users.e2e-spec.ts index 64765618..e0363d28 100644 --- a/test/admin/users.e2e-spec.ts +++ b/test/admin/users.e2e-spec.ts @@ -1,17 +1,21 @@ -import { APP_URL, ADMIN_EMAIL, ADMIN_PASSWORD } from '../utils/constants'; +import { HttpStatus } from '@nestjs/common'; +import * as fs from 'fs'; +import * as path from 'path'; import * as request from 'supertest'; -import { RoleEnum } from '../../src/roles/roles.enum'; -import { StatusEnum } from '../../src/statuses/statuses.enum'; +import * as XLSX from 'xlsx'; +// import * as path from 'path'; +import { generate } from 'gerador-validador-cpf'; +import { + ADMIN_EMAIL, + ADMIN_PASSWORD, + APP_URL, + MAILDEV_URL, +} from '../utils/constants'; -describe('Users admin (e2e)', () => { +describe('Admin managing users (e2e)', () => { const app = APP_URL; - let newUserFirst; - const newUserEmailFirst = `user-first.${Date.now()}@example.com`; - const newUserPasswordFirst = `secret`; - const newUserChangedPasswordFirst = `new-secret`; - const newUserByAdminEmailFirst = `user-created-by-admin.${Date.now()}@example.com`; - const newUserByAdminPasswordFirst = `secret`; - let apiToken; + const tempFolder = path.join(__dirname, 'temp'); + let apiToken: any = {}; beforeAll(async () => { await request(app) @@ -21,101 +25,143 @@ describe('Users admin (e2e)', () => { apiToken = body.token; }); - await request(app) - .post('/api/v1/auth/email/register') - .send({ - email: newUserEmailFirst, - password: newUserPasswordFirst, - firstName: `First${Date.now()}`, - lastName: 'E2E', - }); - - await request(app) - .post('/api/v1/auth/email/login') - .send({ email: newUserEmailFirst, password: newUserPasswordFirst }) - .then(({ body }) => { - newUserFirst = body.user; - }); + if (!fs.existsSync(tempFolder)) { + fs.mkdirSync(tempFolder); + } }); - it('Change password for new user: /api/v1/users/:id (PATCH)', () => { - return request(app) - .patch(`/api/v1/users/${newUserFirst.id}`) - .auth(apiToken, { - type: 'bearer', - }) - .send({ password: newUserChangedPasswordFirst }) - .expect(200); - }); + describe('Setup tests', () => { + it('Should have UTC and local timezones', () => { + new Date().getTimezoneOffset(); + expect(process.env.TZ).toEqual('UTC'); + expect(global.__localTzOffset).toBeDefined(); + }); - it('Login via registered user: /api/v1/auth/email/login (POST)', () => { - return request(app) - .post('/api/v1/auth/email/login') - .send({ email: newUserEmailFirst, password: newUserChangedPasswordFirst }) - .expect(200) - .expect(({ body }) => { - expect(body.token).toBeDefined(); - }); + it('Should have mailDev server', async () => { + await request(MAILDEV_URL).get('').expect(HttpStatus.OK); + }); }); - it('Fail create new user by admin: /api/v1/users (POST)', () => { - return request(app) - .post(`/api/v1/users`) - .auth(apiToken, { - type: 'bearer', - }) - .send({ email: 'fail-data' }) - .expect(422); - }); + /** + * @see {@link https://github.com/RJ-SMTR/api-cct/issues/94#issuecomment-1815016208 Phase 1, requirements #94 - GitHub} + */ + describe('Upload users', () => { + let users: any[]; - it('Success create new user by admin: /api/v1/users (POST)', () => { - return request(app) - .post(`/api/v1/users`) - .auth(apiToken, { - type: 'bearer', - }) - .send({ - email: newUserByAdminEmailFirst, - password: newUserByAdminPasswordFirst, - firstName: `UserByAdmin${Date.now()}`, - lastName: 'E2E', - role: { - id: RoleEnum.user, - }, - status: { - id: StatusEnum.active, + beforeAll(() => { + const randomCode = Math.random().toString(36).slice(-8); + users = [ + { + codigo_permissionario: `permitCode_${randomCode}`, + nome: `name_${randomCode}`, + email: `user.${randomCode}@test.com`, + telefone: `219${Math.random().toString().slice(2, 10)}`, + cpf: generate(), }, - }) - .expect(201); - }); + ]; + }); - it('Login via created by admin user: /api/v1/auth/email/login (GET)', () => { - return request(app) - .post('/api/v1/auth/email/login') - .send({ - email: newUserByAdminEmailFirst, - password: newUserByAdminPasswordFirst, - }) - .expect(200) - .expect(({ body }) => { - expect(body.token).toBeDefined(); - }); - }); + it('Should upload users and get inviteStatus = QUEUED', async () => { + // Arrange + const excelFilePath = path.join(tempFolder, 'newUsers.xlsx'); + const workbook = XLSX.utils.book_new(); + const worksheet = XLSX.utils.json_to_sheet(users); + XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1'); + XLSX.writeFile(workbook, excelFilePath); - it('Get list of users by admin: /api/v1/users (GET)', () => { - return request(app) - .get(`/api/v1/users`) - .auth(apiToken, { - type: 'bearer', - }) - .expect(200) - .send() - .expect(({ body }) => { - expect(body.data[0].provider).toBeDefined(); - expect(body.data[0].email).toBeDefined(); - expect(body.data[0].hash).not.toBeDefined(); - expect(body.data[0].password).not.toBeDefined(); - expect(body.data[0].previousPassword).not.toBeDefined(); - }); + // Assert + await request(app) + .post('/api/v1/users/upload') + .auth(apiToken, { + type: 'bearer', + }) + .attach('file', excelFilePath) + .expect(HttpStatus.CREATED) + .expect(({ body }) => { + expect(body.uploadedUsers).toEqual(1); + }); + + await request(app) + .get('/api/v1/users/') + .auth(apiToken, { + type: 'bearer', + }) + .query({ permitCode: users[0].codigo_permissionario }) + .then(({ body }) => { + expect(body.data.length).toBe(1); + expect(body.data[0]?.aux_inviteStatus?.name).toEqual('queued'); + }); + }); }); }); + +// it('Login via registered user: /api/v1/auth/email/login (POST)', () => { +// return request(app) +// .post('/api/v1/auth/email/login') +// .send({ email: userEmail, password: userChangedPassword }) +// .expect(200) +// .expect(({ body }) => { +// expect(body.token).toBeDefined(); +// }); +// }); + +// it('Fail create new user by admin: /api/v1/users (POST)', () => { +// return request(app) +// .post(`/api/v1/users`) +// .auth(apiToken, { +// type: 'bearer', +// }) +// .send({ email: 'fail-data' }) +// .expect(422); +// }); + +// it('Success create new user by admin: /api/v1/users (POST)', () => { +// return request(app) +// .post(`/api/v1/users`) +// .auth(apiToken, { +// type: 'bearer', +// }) +// .send({ +// email: newUserEmail, +// password: userPassword, +// firstName: `UserByAdmin${Date.now()}`, +// lastName: 'E2E', +// role: { +// id: RoleEnum.user, +// }, +// status: { +// id: StatusEnum.active, +// }, +// }) +// .expect(201); +// }); + +// it('Login via created by admin user: /api/v1/auth/email/login (GET)', () => { +// return request(app) +// .post('/api/v1/auth/email/login') +// .send({ +// email: newUserEmail, +// password: userPassword, +// }) +// .expect(200) +// .expect(({ body }) => { +// expect(body.token).toBeDefined(); +// }); +// }); + +// it('Get list of users by admin: /api/v1/users (GET)', () => { +// return request(app) +// .get(`/api/v1/users`) +// .auth(apiToken, { +// type: 'bearer', +// }) +// .expect(200) +// .send() +// .expect(({ body }) => { +// expect(body.data[0].provider).toBeDefined(); +// expect(body.data[0].email).toBeDefined(); +// expect(body.data[0].hash).not.toBeDefined(); +// expect(body.data[0].password).not.toBeDefined(); +// expect(body.data[0].previousPassword).not.toBeDefined(); +// }); +// }); diff --git a/test/global-setup.ts b/test/global-setup.ts index 924680da..3136c6c7 100644 --- a/test/global-setup.ts +++ b/test/global-setup.ts @@ -1,3 +1,11 @@ +import { differenceInMinutes } from 'date-fns'; + module.exports = () => { + global.__localTz = Intl.DateTimeFormat().resolvedOptions().timeZone; + const localDateStr = new Date().toString(); process.env.TZ = 'UTC'; + global.__localTzOffset = differenceInMinutes( + new Date(localDateStr.split(' GMT')[0]).getTime(), + new Date().getTime(), + ); }; diff --git a/test/jest-e2e.json b/test/jest-e2e.json index 7c6bbdff..4a19aea5 100644 --- a/test/jest-e2e.json +++ b/test/jest-e2e.json @@ -1,5 +1,9 @@ { - "moduleFileExtensions": ["js", "json", "ts"], + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], "rootDir": ".", "testEnvironment": "node", "testRegex": ".e2e-spec.ts$", @@ -10,4 +14,4 @@ "^src/(.*)$": "/../src/$1" }, "globalSetup": "./global-setup.ts" -} +} \ No newline at end of file diff --git a/test/user/auth.e2e-spec.ts b/test/user/auth.e2e-spec.ts index 0315117e..a64effaf 100644 --- a/test/user/auth.e2e-spec.ts +++ b/test/user/auth.e2e-spec.ts @@ -1,222 +1,80 @@ +import { HttpStatus } from '@nestjs/common'; +import { differenceInSeconds } from 'date-fns'; import * as request from 'supertest'; import { APP_URL, - TESTER_EMAIL, - TESTER_PASSWORD, - MAIL_HOST, - MAIL_PORT, + LICENSEE_2_EMAIL, + LICENSEE_2_PERMIT_CODE, + LICENSEE_PASSWORD, + LICENSEE_PERMIT_CODE, + MAILDEV_URL, } from '../utils/constants'; -describe('Auth user (e2e)', () => { +describe('User auth (e2e)', () => { const app = APP_URL; - const mail = `http://${MAIL_HOST}:${MAIL_PORT}`; - const newUserFirstName = `Tester${Date.now()}`; - const newUserLastName = `E2E`; - const newUserEmail = `User.${Date.now()}@example.com`; - const newUserPassword = `secret`; - it('Login: /api/v1/auth/email/login (POST)', () => { - return request(app) - .post('/api/v1/auth/email/login') - .send({ email: TESTER_EMAIL, password: TESTER_PASSWORD }) - .expect(200) - .expect(({ body }) => { - expect(body.token).toBeDefined(); - expect(body.user.email).toBeDefined(); - expect(body.user.hash).not.toBeDefined(); - expect(body.user.password).not.toBeDefined(); - expect(body.user.previousPassword).not.toBeDefined(); - }); - }); - - it('Login via admin endpoint: /api/v1/auth/admin/email/login (POST)', () => { - return request(app) - .post('/api/v1/auth/admin/email/login') - .send({ email: TESTER_EMAIL, password: TESTER_PASSWORD }) - .expect(422); - }); - - it('Login via admin endpoint with extra spaced: /api/v1/auth/admin/email/login (POST)', () => { - return request(app) - .post('/api/v1/auth/admin/email/login') - .send({ email: TESTER_EMAIL + ' ', password: TESTER_PASSWORD }) - .expect(422); - }); - - it('Do not allow register user with exists email: /api/v1/auth/email/register (POST)', () => { - return request(app) - .post('/api/v1/auth/email/register') - .send({ - email: TESTER_EMAIL, - password: TESTER_PASSWORD, - firstName: 'Tester', - lastName: 'E2E', - }) - .expect(422) - .expect(({ body }) => { - expect(body.errors.email).toBeDefined(); - }); - }); - - it('Register new user: /api/v1/auth/email/register (POST)', async () => { - return request(app) - .post('/api/v1/auth/email/register') - .send({ - email: newUserEmail, - password: newUserPassword, - firstName: newUserFirstName, - lastName: newUserLastName, - }) - .expect(204); - }); + describe('Setup tests', () => { + it('Should have UTC and local timezones', () => { + new Date().getTimezoneOffset(); + expect(process.env.TZ).toEqual('UTC'); + expect(global.__localTzOffset).toBeDefined(); + }); - it('Login unconfirmed user: /api/v1/auth/email/login (POST)', () => { - return request(app) - .post('/api/v1/auth/email/login') - .send({ email: newUserEmail, password: newUserPassword }) - .expect(200) - .expect(({ body }) => { - expect(body.token).toBeDefined(); - }); + it('Should have mailDev server', async () => { + await request(MAILDEV_URL).get('').expect(HttpStatus.OK); + }); }); - it('Confirm email: /api/v1/auth/email/confirm (POST)', async () => { - const hash = await request(mail) - .get('/email') - .then(({ body }) => - body - .find( - (letter) => - letter.to[0].address.toLowerCase() === - newUserEmail.toLowerCase() && - /.*confirm\-email\/(\w+).*/g.test(letter.text), - ) - ?.text.replace(/.*confirm\-email\/(\w+).*/g, '$1'), - ); - - return request(app) - .post('/api/v1/auth/email/confirm') - .send({ - hash, - }) - .expect(204); - }); + /** + * @see {@link https://github.com/RJ-SMTR/api-cct/issues/94#issuecomment-1815016208 Requirements #94 - GitHub} + */ + describe('Phase 1: User basics', () => { + test('Login user: POST /api/v1/auth/licensee/login', () => { + return request(app) + .post('/api/v1/auth/licensee/login') + .send({ permitCode: LICENSEE_PERMIT_CODE, password: LICENSEE_PASSWORD }) + .expect(HttpStatus.OK); + }); - it('Can not confirm email with same link twice: /api/v1/auth/email/confirm (POST)', async () => { - const hash = await request(mail) - .get('/email') - .then(({ body }) => - body - .find( - (letter) => - letter.to[0].address.toLowerCase() === - newUserEmail.toLowerCase() && - /.*confirm\-email\/(\w+).*/g.test(letter.text), - ) - ?.text.replace(/.*confirm\-email\/(\w+).*/g, '$1'), + test('Reset user password', async () => { + await request(APP_URL) + .post('/api/v1/auth/forgot/password') + .send({ email: LICENSEE_2_EMAIL }) + .expect(HttpStatus.ACCEPTED); + const forgotLocalDate = new Date(); + forgotLocalDate.setMinutes( + forgotLocalDate.getMinutes() + global.__localTzOffset, ); - return request(app) - .post('/api/v1/auth/email/confirm') - .send({ - hash, - }) - .expect(404); - }); - - it('Login confirmed user: /api/v1/auth/email/login (POST)', () => { - return request(app) - .post('/api/v1/auth/email/login') - .send({ email: newUserEmail, password: newUserPassword }) - .expect(200) - .expect(({ body }) => { - expect(body.token).toBeDefined(); - expect(body.user.email).toBeDefined(); - }); - }); - - it('Confirmed user retrieve profile: /api/v1/auth/me (GET)', async () => { - const newUserApiToken = await request(app) - .post('/api/v1/auth/email/login') - .send({ email: newUserEmail, password: newUserPassword }) - .then(({ body }) => body.token); - - await request(app) - .get('/api/v1/auth/me') - .auth(newUserApiToken, { - type: 'bearer', - }) - .send() - .expect(({ body }) => { - expect(body.provider).toBeDefined(); - expect(body.email).toBeDefined(); - expect(body.hash).not.toBeDefined(); - expect(body.password).not.toBeDefined(); - expect(body.previousPassword).not.toBeDefined(); - }); - }); - - it('New user update profile: /api/v1/auth/me (PATCH)', async () => { - const newUserNewName = Date.now(); - const newUserNewPassword = 'new-secret'; - const newUserApiToken = await request(app) - .post('/api/v1/auth/email/login') - .send({ email: newUserEmail, password: newUserPassword }) - .then(({ body }) => body.token); - - await request(app) - .patch('/api/v1/auth/me') - .auth(newUserApiToken, { - type: 'bearer', - }) - .send({ - firstName: newUserNewName, - password: newUserNewPassword, - }) - .expect(422); - - await request(app) - .patch('/api/v1/auth/me') - .auth(newUserApiToken, { - type: 'bearer', - }) - .send({ - firstName: newUserNewName, - password: newUserNewPassword, - oldPassword: newUserPassword, - }) - .expect(200); - - await request(app) - .post('/api/v1/auth/email/login') - .send({ email: newUserEmail, password: newUserNewPassword }) - .expect(200) - .expect(({ body }) => { - expect(body.token).toBeDefined(); - }); - - await request(app) - .patch('/api/v1/auth/me') - .auth(newUserApiToken, { - type: 'bearer', - }) - .send({ password: newUserPassword, oldPassword: newUserNewPassword }) - .expect(200); - }); - - it('New user delete profile: /api/v1/auth/me (DELETE)', async () => { - const newUserApiToken = await request(app) - .post('/api/v1/auth/email/login') - .send({ email: newUserEmail, password: newUserPassword }) - .then(({ body }) => body.token); - - await request(app).delete('/api/v1/auth/me').auth(newUserApiToken, { - type: 'bearer', - }); - - return request(app) - .post('/api/v1/auth/email/login') - .send({ email: newUserEmail, password: newUserPassword }) - .expect(422); + const hash = await request(MAILDEV_URL) + .get('/email') + .then(({ body }) => + (body as any[]) + .filter( + (letter: any) => + letter.to[0].address.toLowerCase() === + LICENSEE_2_EMAIL.toLowerCase() && + /.*reset\-password\/(\w+).*/g.test(letter.text) && + differenceInSeconds(forgotLocalDate, new Date(letter.date)) <= + 10, + ) + .pop() + ?.text.replace(/.*reset\-password\/(\w+).*/g, '$1'), + ); + + const newPassword = Math.random().toString(36).slice(-8); + await request(APP_URL) + .post('/api/v1/auth/reset/password') + .send({ + hash, + password: newPassword, + }) + .expect(HttpStatus.NO_CONTENT); + + await request(APP_URL) + .post('/api/v1/auth/licensee/login') + .send({ permitCode: LICENSEE_2_PERMIT_CODE, password: newPassword }) + .expect(HttpStatus.OK); + }, 60000); }); }); diff --git a/test/utils/constants.ts b/test/utils/constants.ts index 93eecf33..cd791cbf 100644 --- a/test/utils/constants.ts +++ b/test/utils/constants.ts @@ -2,10 +2,20 @@ import { config } from 'dotenv'; config(); export const APP_URL = `http://localhost:${process.env.APP_PORT}`; +export const MAILDEV_URL = `http://${process.env.MAIL_HOST}:${process.env.MAIL_CLIENT_PORT}`; export const TESTER_EMAIL = 'john.doe@example.com'; export const TESTER_PASSWORD = 'secret'; export const ADMIN_EMAIL = process.env.TEST_ADMIN_EMAIL || 'admin@example.com'; export const ADMIN_PASSWORD = process.env.TEST_ADMIN_PASSWORD || 'secret'; +export const ADMIN_2_EMAIL = + process.env.TEST_ADMIN_2_EMAIL || 'admin.test@example.com'; +export const ADMIN_2_PASSWORD = process.env.TEST_ADMIN_2_PASSWORD || 'secret'; +export const LICENSEE_EMAIL = + process.env.TEST_LICENSEE_EMAIL || 'henrique@example.com'; +export const LICENSEE_2_EMAIL = + process.env.TEST_LICENSEE_2_EMAIL || 'marcia@example.com'; +export const LICENSEE_2_PERMIT_CODE = + process.env.TEST_LICENSEE_2_PERMIT_CODE || '319274392832023'; export const LICENSEE_PERMIT_CODE = process.env.TEST_LICENSEE_PERMIT_CODE || '213890329890312'; export const LICENSEE_PASSWORD = process.env.TEST_LICENSEE_PASSWORD || 'secret'; diff --git a/test/utils/selenium-helper.ts b/test/utils/selenium-helper.ts new file mode 100644 index 00000000..52c3903f --- /dev/null +++ b/test/utils/selenium-helper.ts @@ -0,0 +1,198 @@ +import { Builder, By, WebDriver, WebElement } from 'selenium-webdriver'; + +export interface Email { + title: string; + recipient: string; + isSelected: boolean; + isRead: boolean; + receivedDate: string; + element: WebElement; +} + +export class MaildevScraper { + private driver: WebDriver; + private maildevUrl: string; + private isDefaultContent: boolean; + + constructor(args?: { maildevUrl?: string; minimizeWindow?: boolean }) { + const minimizeWindow = + args?.minimizeWindow === undefined ? true : args?.minimizeWindow; + this.driver = new Builder().forBrowser('chrome').build(); + if (args?.maildevUrl) { + this.maildevUrl = args.maildevUrl; + } + this.isDefaultContent = true; + (async () => { + await this.adjustWindow(minimizeWindow); + })().catch((error: Error) => { + throw error; + }); + } + + async adjustWindow(minimize?: boolean): Promise { + const width = 1200; + const height = 800; + try { + const screenResolution: any = await this.driver.executeScript( + 'return { width: window.screen.width, height: window.screen.height };', + ); + + const centerX = Math.max(0, (screenResolution.width - width) / 2); + const centerY = Math.max(0, (screenResolution.height - height) / 2); + await this.driver + .manage() + .window() + .setRect({ width, height, x: centerX, y: centerY }); + + // Minimize the window + if (minimize) { + await this.driver.manage().window().minimize(); + } + } catch (error) { + console.error('Error while resizing and minimizing window:', error); + } + } + + async handleIfDefaultContent() { + if (!this.isDefaultContent) { + await this.driver.switchTo().defaultContent(); + } + this.isDefaultContent = true; + } + + async openMailDev() { + await this.driver.get(this.maildevUrl); + } + + async getSidebarEmails(limit = 10): Promise { + const emailElements: WebElement[] = await this.driver.findElements( + By.css('.email-item'), + ); + const emails: Email[] = []; + + for (let i = 0; i < limit; i++) { + const element = emailElements[i]; + const classes = (await element.getAttribute('class')).split(' '); + const isSelected = classes.includes('current'); + const isRead = classes.includes('read'); + const title = ( + await element.findElement(By.css('.title')).getText() + ).split(' To:')[0]; + const recipient = ( + await element.findElement(By.css('.title-subline')).getText() + ) + .split('To:')[1] + .trim(); + const receivedDate = await element + .findElement(By.css('.subline')) + .getText(); + + emails.push({ + title, + recipient, + receivedDate, + isSelected, + isRead, + element, + }); + } + + return emails; + } + + async sleep(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + } + + async waitDriver() { + await this.driver.wait(function () { + return this.driver + .executeScript('return document.readyState') + .then(function (readyState) { + return readyState === 'complete'; + }); + }); + } + + async findElementsInMailContent(args: { + tagName?: string; + innerText?: string; + classes?: string[]; + id?: string; + }): Promise { + const iframeSelector = By.css('.email-content-view-html > iframe'); + const bodySelector = By.css('body'); + + const iframeElement = await this.driver.findElement(iframeSelector); + + await this.driver.switchTo().frame(iframeElement); + this.isDefaultContent = false; + + const bodyElement = await this.driver.findElement(bodySelector); + + let xpath = './/*'; // Start with any element + + if (args?.tagName) { + xpath += `[local-name()="${args.tagName}"]`; + } + + if (args?.innerText) { + xpath += `[contains(text(), "${args.innerText}")]`; + } + + if (args?.classes && args.classes.length > 0) { + const classCondition = args.classes + .map((className) => `contains(@class, "${className}")`) + .join(' and '); + xpath += `[${classCondition}]`; + } + + if (args?.id) { + xpath += `[@id="${args.id}"]`; + } + + const elements = await bodyElement.findElements(By.xpath(xpath)); + + return elements; + } + + async clickEmail(email: Email) { + if (!this.isDefaultContent) { + await this.handleIfDefaultContent(); + } + await email.element.click(); + } + + async deleteAllEmails() { + await this.handleIfDefaultContent(); + const deleteAllEmails = await this.driver.findElement( + By.xpath(`//a[@title="Delete all emails"]`), + ); + await deleteAllEmails.click(); + await deleteAllEmails.click(); + } + + async close() { + await this.driver.quit(); + } +} + +// let frontendLink: string = ''; +// await maildev.sleep(1000); +// const mails = (await maildev.getSidebarEmails()) +// .filter(i => ['Redefinir senha', 'Refedinir senha'].includes(i.title) +// && i.recipient === ADMIN_2_EMAIL); +// expect(mails.length).toBeGreaterThan(0); +// const mail = mails[0]; +// expect(differenceInSeconds(forgotLocalDate, new Date(mail.receivedDate))).toBeLessThanOrEqual(10); +// if (!mail.isSelected) { +// await maildev.clickEmail(mails[0]) +// await maildev.sleep(1000); +// } +// const elements = await maildev.findElementsInMailContent({ tagName: 'a', innerText: 'Redefinir senha.' }); +// expect(elements.length).toBeGreaterThan(0); +// const resetPassButton = elements[0]; +// frontendLink = await resetPassButton.getAttribute('href'); +// let hash = (frontendLink.split('/').pop() as String); diff --git a/yarn.lock b/yarn.lock index 1ca28f0a..ec702a0c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2373,6 +2373,13 @@ "@types/tough-cookie" "*" "form-data" "^2.5.0" +"@types/selenium-webdriver@^4.1.21": + "integrity" "sha512-QGURnImvxYlIQz5DVhvHdqpYNLBjhJ2Vm+cnQI2G9QZzkWlZm0LkLcvDcHp+qE6N2KBz4CeuvXgPO7W3XQ0Tyw==" + "resolved" "https://registry.npmjs.org/@types/selenium-webdriver/-/selenium-webdriver-4.1.21.tgz" + "version" "4.1.21" + dependencies: + "@types/ws" "*" + "@types/semver@^7.3.12": "integrity" "sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==" "resolved" "https://registry.npmjs.org/@types/semver/-/semver-7.5.0.tgz" @@ -2424,6 +2431,13 @@ "resolved" "https://registry.npmjs.org/@types/validator/-/validator-13.7.10.tgz" "version" "13.7.10" +"@types/ws@*": + "integrity" "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==" + "resolved" "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz" + "version" "8.5.10" + dependencies: + "@types/node" "*" + "@types/yargs-parser@*": "integrity" "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==" "resolved" "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.0.tgz" @@ -4990,6 +5004,11 @@ "resolved" "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz" "version" "1.0.0-beta.2" +"gerador-validador-cpf@^5.0.2": + "integrity" "sha512-7nqJilkfIv3HIbB50uP32SOxe/A3TyvVS3AXxwU6cqHq7jMTnkp0WGPaGytY3Yc36RjzysVQ6xhlwcCt70CnOw==" + "resolved" "https://registry.npmjs.org/gerador-validador-cpf/-/gerador-validador-cpf-5.0.2.tgz" + "version" "5.0.2" + "get-caller-file@^2.0.5": "integrity" "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" "resolved" "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz"