From 52f3a9f56cd8bbfbbc06bbcd7a3b6bd941597a03 Mon Sep 17 00:00:00 2001 From: Eric Cabrel TIOGO Date: Sun, 2 Jun 2024 13:17:46 +0200 Subject: [PATCH] test: finish integration tests for authentication fix: tests in the ci --- .github/workflows/build.yml | 2 +- apps/backend/.env.example | 1 + apps/backend/.env.test | 1 + apps/backend/README.md | 3 +- apps/backend/env.d.ts | 1 + apps/backend/package.json | 3 +- apps/backend/src/configs/environment.ts | 1 + .../auth/graphql/auth.integration.spec.ts | 285 +++++++++++++++++- .../features/auth/graphql/auth.resolvers.ts | 31 +- .../src/features/auth/graphql/schema.graphql | 6 + .../src/features/auth/rest/auth.controller.ts | 4 +- apps/backend/src/features/schema.graphql | 1 + apps/backend/src/types/graphql.schema.ts | 9 +- apps/backend/src/utils/tests/helpers.ts | 23 +- apps/web/.eslintrc.js | 94 ++++++ apps/web/.eslintrc.json | 94 ------ apps/web/src/hooks/authentication/use-auth.ts | 9 +- .../domain/src/services/users/user.service.ts | 10 + .../__tests__/common/environment.test.ts | 24 -- packages/utils/index.ts | 15 +- packages/utils/package.json | 5 +- packages/utils/src/common/constants.ts | 2 - packages/utils/src/common/environment.ts | 11 - packages/utils/src/common/jwt.test.ts | 20 ++ packages/utils/src/common/jwt.ts | 17 ++ packages/utils/src/common/uuid.test.ts | 20 ++ packages/utils/src/date/date.test.ts | 36 +++ packages/utils/src/date/date.ts | 4 +- .../src/{errors/types.ts => error/error.ts} | 17 +- .../utils/src/{errors => error}/messages.ts | 2 + packages/utils/src/errors/app-error.ts | 14 - .../utils/src/{types => snippet}/snippet.ts | 0 packages/utils/tsconfig.prod.json | 2 +- yarn.lock | 108 +++++++ 34 files changed, 683 insertions(+), 192 deletions(-) create mode 100644 apps/web/.eslintrc.js delete mode 100644 apps/web/.eslintrc.json delete mode 100644 packages/utils/__tests__/common/environment.test.ts delete mode 100644 packages/utils/src/common/constants.ts delete mode 100644 packages/utils/src/common/environment.ts create mode 100644 packages/utils/src/common/jwt.test.ts create mode 100644 packages/utils/src/common/jwt.ts create mode 100644 packages/utils/src/common/uuid.test.ts create mode 100644 packages/utils/src/date/date.test.ts rename packages/utils/src/{errors/types.ts => error/error.ts} (57%) rename packages/utils/src/{errors => error}/messages.ts (92%) delete mode 100644 packages/utils/src/errors/app-error.ts rename packages/utils/src/{types => snippet}/snippet.ts (100%) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index becd13b2..64207018 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -45,7 +45,7 @@ jobs: - name: Run tests run: | yarn prisma migrate dev --schema=packages/domain/prisma/schema.prisma - yarn test --runInBand --coverage + yarn test -- --runInBand --coverage preview-frontend: runs-on: ubuntu-latest diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 815a57bf..26e4eac1 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -17,3 +17,4 @@ SESSION_LIFETIME=90# 90 days SENTRY_DSN= SENTRY_ENABLED=false SNIPPET_RENDERER_API_URL=http://localhost:3000/dev +JWT_SECRET=jwtSecret diff --git a/apps/backend/.env.test b/apps/backend/.env.test index 4c91fe26..6b1f1aad 100644 --- a/apps/backend/.env.test +++ b/apps/backend/.env.test @@ -17,3 +17,4 @@ SESSION_LIFETIME=90 SENTRY_DSN=sentry-dsn SENTRY_ENABLED=false SNIPPET_RENDERER_API_URL=http://localhost:3000/dev +JWT_SECRET=jwtSecret diff --git a/apps/backend/README.md b/apps/backend/README.md index 10a04a1a..b7713c6c 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -23,7 +23,7 @@ These packages are located in the folder `packages`, so you might need to change Here are the packages used in this project: * [@snipcode/domain](../../packages/domain) -* [@snipcode/logger](../../packages/logger-old) +* [@snipcode/embed](../../packages/embed) * [@snipcode/utils](../../packages/utils) ## Set up the project @@ -67,6 +67,7 @@ nano .env.local | SENTRY_DSN | Sentry DSN | | SENTRY_ENABLED | Enable/Disable Sentry | | SNIPPET_RENDERER_API_URL | Base URL of the API (the current one) for generating the html content from a snippet | +| JWT_SECRET | The secret code for decoding JWT token generated in the application | Start the application ```bash diff --git a/apps/backend/env.d.ts b/apps/backend/env.d.ts index a68d9690..49fdd07a 100644 --- a/apps/backend/env.d.ts +++ b/apps/backend/env.d.ts @@ -9,6 +9,7 @@ export type EnvironmentVariables = { GITHUB_CLIENT_SECRET: string; HOST: string; INTROSPECTION_ENABLED: string; + JWT_SECRET: string; NODE_ENV: string; PORT: string; REQUEST_TIMEOUT: string; diff --git a/apps/backend/package.json b/apps/backend/package.json index ef2417c9..8d9b0d47 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -13,7 +13,8 @@ "gql:gen:types": "ts-node scripts/generate-graphql-types.ts", "lint": "eslint \"{src,scripts}/**/*.ts\" --fix", "prod": "node dist/main", - "test": "yarn workspace @snipcode/domain db:test && dotenv -e .env.test -- jest --watchAll --runInBand", + "test": "yarn workspace @snipcode/domain db:test && dotenv -e .env.test -- jest --runInBand", + "test:watch": "yarn workspace @snipcode/domain db:test && dotenv -e .env.test -- jest --watchAll --runInBand", "test:coverage": "yarn test --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:it": "yarn test integration.spec.ts" diff --git a/apps/backend/src/configs/environment.ts b/apps/backend/src/configs/environment.ts index 42039b33..60250a34 100644 --- a/apps/backend/src/configs/environment.ts +++ b/apps/backend/src/configs/environment.ts @@ -11,6 +11,7 @@ const EnvironmentVariablesSchema = z.object({ GITHUB_CLIENT_SECRET: z.string(), HOST: z.string(), INTROSPECTION_ENABLED: z.boolean({ coerce: true }), + JWT_SECRET: z.string(), NODE_ENV: z.union([z.literal('development'), z.literal('production'), z.literal('test')]), PORT: z.number({ coerce: true }).min(7000).max(8000), SENTRY_DSN: z.string(), diff --git a/apps/backend/src/features/auth/graphql/auth.integration.spec.ts b/apps/backend/src/features/auth/graphql/auth.integration.spec.ts index 8d7398ee..e2bff88c 100644 --- a/apps/backend/src/features/auth/graphql/auth.integration.spec.ts +++ b/apps/backend/src/features/auth/graphql/auth.integration.spec.ts @@ -1,5 +1,5 @@ -import { PrismaService, RoleService, UserService } from '@snipcode/domain'; -import { isValidUUIDV4 } from '@snipcode/utils'; +import { PrismaService, RoleService, SessionService, UserService } from '@snipcode/domain'; +import { generateJwtToken, isValidUUIDV4 } from '@snipcode/utils'; import request from 'supertest'; import { TestHelper } from '../../../utils/tests/helpers'; @@ -12,14 +12,16 @@ describe('Test Authentication', () => { let testHelper: TestHelper; let prismaService: PrismaService; let roleService: RoleService; + let sessionService: SessionService; let userService: UserService; beforeAll(async () => { server = await startTestServer(); prismaService = server.app.get(PrismaService); - userService = server.app.get(UserService); roleService = server.app.get(RoleService); + userService = server.app.get(UserService); + sessionService = server.app.get(SessionService); testHelper = new TestHelper(prismaService, roleService, userService); }); @@ -38,6 +40,7 @@ describe('Test Authentication', () => { signupUser(input: $input) { __typename message + userId } } `; @@ -57,6 +60,7 @@ describe('Test Authentication', () => { expect(response.body.data.signupUser).toMatchObject({ __typename: 'SignupUserResult', message: 'Account created successfully!', + userId: expect.any(String), }); }); @@ -84,8 +88,10 @@ describe('Test Authentication', () => { .send({ query, variables }) .expect(200); - expect(response.body.errors[0].extensions.code).toEqual('EMAIL_ALREADY_TAKEN'); - expect(response.body.errors[0].message).toEqual('The email address is already taken'); + const [error] = response.body.errors; + + expect(error.extensions.code).toEqual('EMAIL_ALREADY_TAKEN'); + expect(error.message).toEqual('The email address is already taken'); }); test('Returns an error when authenticating with bad credentials', async () => { @@ -106,8 +112,10 @@ describe('Test Authentication', () => { .send({ query, variables }) .expect(200); - expect(response.body.errors[0].extensions.code).toEqual('LOGIN_FAILED'); - expect(response.body.errors[0].message).toEqual('Invalid email address or password.'); + const [error] = response.body.errors; + + expect(error.extensions.code).toEqual('LOGIN_FAILED'); + expect(error.message).toEqual('Invalid email address or password.'); }); test('Returns a token when authenticating with correct credentials', async () => { @@ -136,8 +144,14 @@ describe('Test Authentication', () => { .send({ query, variables }) .expect(200); - expect(response.body.data.loginUser.token).toBeDefined(); - expect(isValidUUIDV4(response.body.data.loginUser.token)).toBe(true); + const { loginUser } = response.body.data; + + expect(loginUser.token).toBeDefined(); + expect(isValidUUIDV4(loginUser.token)).toBe(true); + + const session = await sessionService.findByToken(loginUser.token); + + expect(session).toBeDefined(); }); test('Returns an error message when trying to authenticate with a disabled account', async () => { @@ -166,7 +180,256 @@ describe('Test Authentication', () => { .send({ query, variables }) .expect(200); - expect(response.body.errors[0].extensions.code).toEqual('ACCOUNT_DISABLED'); - expect(response.body.errors[0].message).toEqual('Your account is disabled!'); + const [error] = response.body.errors; + + expect(error.extensions.code).toEqual('ACCOUNT_DISABLED'); + expect(error.message).toEqual('Your account is disabled!'); + }); + + test('Returns when retrieving the authenticated user without an authentication token', async () => { + const authenticatedUserQuery = ` + query AuthenticatedUser { + authenticatedUser { + id + } + } + `; + + const response = await request(server.app.getHttpServer()) + .post(graphqlEndpoint) + .send({ query: authenticatedUserQuery }) + .expect(200); + + const [error] = response.body.errors; + + expect(error.extensions.code).toEqual('UNAUTHENTICATED'); + expect(error.message).toEqual('You must be authenticated to access to this resource.'); + }); + + test('Retrieve the authenticated user', async () => { + const signUpQuery = ` + mutation SignupUser($input: SignupUserInput!) { + signupUser(input: $input) { + __typename + message + userId + } + } + `; + + const signUpVariables = { + input: { + email: 'jon.doe@snipcode.dev', + name: 'John Doe', + password: 'password', + }, + }; + + const signUpResponse = await request(server.app.getHttpServer()) + .post(graphqlEndpoint) + .send({ query: signUpQuery, variables: signUpVariables }) + .expect(200); + + const confirmationToken = generateJwtToken({ + expiresIn: '1h', + payload: { userId: signUpResponse.body.data.signupUser.userId }, + secret: process.env.JWT_SECRET, + }); + + const confirmUserQuery = ` + mutation ConfirmUser($token: String!) { + confirmUser(token: $token) { + message + } + } + `; + + const confirmUserVariables = { + token: confirmationToken, + }; + + await request(server.app.getHttpServer()) + .post(graphqlEndpoint) + .send({ query: confirmUserQuery, variables: confirmUserVariables }) + .expect(200); + + const loginQuery = ` + mutation LoginUser($email: String!, $password: String!) { + loginUser(email: $email, password: $password) { + token + } + } + `; + + const loginVariables = { + email: signUpVariables.input.email, + password: signUpVariables.input.password, + }; + + const loginResponse = await request(server.app.getHttpServer()) + .post(graphqlEndpoint) + .send({ query: loginQuery, variables: loginVariables }) + .expect(200); + + const authToken = loginResponse.body.data.loginUser.token; + + const authenticatedUserQuery = ` + query AuthenticatedUser { + authenticatedUser { + id + name + email + isEnabled + timezone + username + pictureUrl + role { + name + } + rootFolder { + id + name + } + createdAt + oauthProvider + } + } + `; + + const response = await request(server.app.getHttpServer()) + .post(graphqlEndpoint) + .set('Authorization', authToken) + .send({ query: authenticatedUserQuery }) + .expect(200); + + const { authenticatedUser } = response.body.data; + + expect(authenticatedUser).toMatchObject({ + createdAt: expect.any(Number), + email: loginVariables.email, + id: signUpResponse.body.data.signupUser.userId, + isEnabled: true, + name: signUpVariables.input.name, + oauthProvider: 'email', + pictureUrl: null, + role: { + name: 'user', + }, + rootFolder: { + id: expect.any(String), + name: `__${authenticatedUser.id}__`, + }, + timezone: null, + username: expect.any(String), + }); + }); + + test('Log out the authenticated user', async () => { + const signUpQuery = ` + mutation SignupUser($input: SignupUserInput!) { + signupUser(input: $input) { + __typename + message + userId + } + } + `; + + const signUpVariables = { + input: { + email: 'jane.doe@snipcode.dev', + name: 'Jane Doe', + password: 'password', + }, + }; + + const signUpResponse = await request(server.app.getHttpServer()) + .post(graphqlEndpoint) + .send({ query: signUpQuery, variables: signUpVariables }) + .expect(200); + + const confirmationToken = generateJwtToken({ + expiresIn: '1h', + payload: { userId: signUpResponse.body.data.signupUser.userId }, + secret: process.env.JWT_SECRET, + }); + + const confirmUserQuery = ` + mutation ConfirmUser($token: String!) { + confirmUser(token: $token) { + message + } + } + `; + + const confirmUserVariables = { + token: confirmationToken, + }; + + await request(server.app.getHttpServer()) + .post(graphqlEndpoint) + .send({ query: confirmUserQuery, variables: confirmUserVariables }) + .expect(200); + + const loginQuery = ` + mutation LoginUser($email: String!, $password: String!) { + loginUser(email: $email, password: $password) { + token + } + } + `; + + const loginVariables = { + email: signUpVariables.input.email, + password: signUpVariables.input.password, + }; + + const loginResponse = await request(server.app.getHttpServer()) + .post(graphqlEndpoint) + .send({ query: loginQuery, variables: loginVariables }) + .expect(200); + + const authToken = loginResponse.body.data.loginUser.token; + + const authenticatedUserQuery = ` + query AuthenticatedUser { + authenticatedUser { + id + } + } + `; + + const response = await request(server.app.getHttpServer()) + .post(graphqlEndpoint) + .set('Authorization', authToken) + .send({ query: authenticatedUserQuery }) + .expect(200); + + const { authenticatedUser } = response.body.data; + + expect(authenticatedUser.id).toEqual(signUpResponse.body.data.signupUser.userId); + + const logoutQuery = ` + mutation LogoutUser { + logoutUser + } + `; + + await request(server.app.getHttpServer()) + .post(graphqlEndpoint) + .set('Authorization', authToken) + .send({ query: logoutQuery }) + .expect(200); + + const afterLogoutResponse = await request(server.app.getHttpServer()) + .post(graphqlEndpoint) + .set('Authorization', authToken) + .send({ query: authenticatedUserQuery }) + .expect(200); + + const [error] = afterLogoutResponse.body.errors; + + expect(error.extensions.code).toEqual('UNAUTHENTICATED'); + expect(error.message).toEqual('You must be authenticated to access to this resource.'); }); }); diff --git a/apps/backend/src/features/auth/graphql/auth.resolvers.ts b/apps/backend/src/features/auth/graphql/auth.resolvers.ts index 9ca584af..b66ceda4 100644 --- a/apps/backend/src/features/auth/graphql/auth.resolvers.ts +++ b/apps/backend/src/features/auth/graphql/auth.resolvers.ts @@ -13,12 +13,12 @@ import { User, UserService, } from '@snipcode/domain'; -import { addDayToDate } from '@snipcode/utils'; +import { AppError, addDayToDate, errors, verifyJwtToken } from '@snipcode/utils'; import { AuthGuard, UserId } from '../../../configs/auth.guard'; import { EnvironmentVariables } from '../../../configs/environment'; import { GraphQLContext } from '../../../types/common'; -import { LoginResult, SignupUserInput, SignupUserResult } from '../../../types/graphql.schema'; +import { ConfirmUserResult, LoginResult, SignupUserInput, SignupUserResult } from '../../../types/graphql.schema'; import { AUTH_USER_NOT_FOUND, AUTH_USER_NOT_FOUND_CODE } from '../../../utils/constants'; import { GraphQLAppError } from '../../../utils/errors'; @@ -57,7 +57,7 @@ export class AuthResolvers { const sessionLifetime = this.configService.get('SESSION_LIFETIME'); const sessionInput = new CreateSessionInput({ - expireDate: addDayToDate(sessionLifetime), + expireDate: addDayToDate(new Date(), sessionLifetime), userId: user.id, }); @@ -86,7 +86,7 @@ export class AuthResolvers { const role = await this.roleService.findByName('user'); - const createUserDto = new CreateUserInput({ + const createUserInput = new CreateUserInput({ email, name, oauthProvider: 'email', @@ -97,7 +97,7 @@ export class AuthResolvers { username: null, }); - const user = await this.userService.create(createUserDto); + const user = await this.userService.create(createUserInput); const createUserRootFolderDto = new CreateUserRootFolderInput(user.id); @@ -105,7 +105,26 @@ export class AuthResolvers { // TODO published user created event - return { message: 'Account created successfully!' }; + return { message: 'Account created successfully!', userId: user.id }; + } + + @Mutation('confirmUser') + async confirmUser(@Args('token') token: string): Promise { + const decodedToken = verifyJwtToken<{ userId: string }>({ secret: this.configService.get('JWT_SECRET'), token }); + + if (!decodedToken) { + throw new AppError(errors.INVALID_CONFIRMATION_TOKEN, 'INVALID_CONFIRMATION_TOKEN'); + } + + const user = await this.userService.findById(decodedToken.userId); + + if (!user) { + throw new AppError(errors.USER_NOT_FOUND_FROM_TOKEN, 'USER_NOT_FOUND'); + } + + await this.userService.activate(user.id); + + return { message: 'Email confirmed' }; } @ResolveField() diff --git a/apps/backend/src/features/auth/graphql/schema.graphql b/apps/backend/src/features/auth/graphql/schema.graphql index 4ea54baf..d849716d 100644 --- a/apps/backend/src/features/auth/graphql/schema.graphql +++ b/apps/backend/src/features/auth/graphql/schema.graphql @@ -10,6 +10,11 @@ input SignupUserInput { type SignupUserResult { message: String! + userId: String! +} + +type ConfirmUserResult { + message: String! } type Query { @@ -20,4 +25,5 @@ type Mutation { loginUser(email: String!, password: String!): LoginResult! logoutUser: Boolean! signupUser(input: SignupUserInput!): SignupUserResult! + confirmUser(token: String!): ConfirmUserResult! } diff --git a/apps/backend/src/features/auth/rest/auth.controller.ts b/apps/backend/src/features/auth/rest/auth.controller.ts index 54afe9ac..7a17e46c 100644 --- a/apps/backend/src/features/auth/rest/auth.controller.ts +++ b/apps/backend/src/features/auth/rest/auth.controller.ts @@ -44,7 +44,7 @@ export class AuthController { if (userExist) { const sessionInput = new CreateSessionInput({ - expireDate: addDayToDate(sessionLifetime), + expireDate: addDayToDate(new Date(), sessionLifetime), userId: userExist.id, }); const session = await this.sessionService.create(sessionInput); @@ -76,7 +76,7 @@ export class AuthController { await this.folderService.createUserRootFolder(createUserRootFolderInput); const sessionInput = new CreateSessionInput({ - expireDate: addDayToDate(sessionLifetime), + expireDate: addDayToDate(new Date(), sessionLifetime), userId: createdUser.id, }); diff --git a/apps/backend/src/features/schema.graphql b/apps/backend/src/features/schema.graphql index a8a196da..06fcbe51 100644 --- a/apps/backend/src/features/schema.graphql +++ b/apps/backend/src/features/schema.graphql @@ -6,6 +6,7 @@ enum RoleName { } enum OauthProvider { + email github stackoverflow twitter diff --git a/apps/backend/src/types/graphql.schema.ts b/apps/backend/src/types/graphql.schema.ts index 6a8637aa..3e8ae16a 100644 --- a/apps/backend/src/types/graphql.schema.ts +++ b/apps/backend/src/types/graphql.schema.ts @@ -7,7 +7,7 @@ /* tslint:disable */ /* eslint-disable */ export type RoleName = 'user' | 'admin'; -export type OauthProvider = 'github' | 'stackoverflow' | 'twitter'; +export type OauthProvider = 'email' | 'github' | 'stackoverflow' | 'twitter'; export type SnippetVisibility = 'public' | 'private'; export type SnippetSortMethod = 'recently_created' | 'recently_updated'; @@ -64,6 +64,12 @@ export interface LoginResult { export interface SignupUserResult { __typename?: 'SignupUserResult'; message: string; + userId: string; +} + +export interface ConfirmUserResult { + __typename?: 'ConfirmUserResult'; + message: string; } export interface IQuery { @@ -84,6 +90,7 @@ export interface IMutation { loginUser(email: string, password: string): LoginResult | Promise; logoutUser(): boolean | Promise; signupUser(input: SignupUserInput): SignupUserResult | Promise; + confirmUser(token: string): ConfirmUserResult | Promise; createFolder(input: CreateFolderInput): Folder | Promise; deleteFolders(folderIds: string[]): boolean | Promise; updateFolder(id: string, input: UpdateFolderInput): Folder | Promise; diff --git a/apps/backend/src/utils/tests/helpers.ts b/apps/backend/src/utils/tests/helpers.ts index 55297ff6..902c8a88 100644 --- a/apps/backend/src/utils/tests/helpers.ts +++ b/apps/backend/src/utils/tests/helpers.ts @@ -29,16 +29,6 @@ export class TestHelper { private readonly userService: UserService, ) {} - async findTestRole(name: RoleName): Promise { - const role = await this.roleService.findByName(name); - - if (!role) { - throw new Error(`Role with the name "${name}" not found!`); - } - - return role; - } - static createTestUserInput(override: Partial): CreateUserInput { const input = new CreateUserInput({ email: randEmail(), @@ -57,6 +47,16 @@ export class TestHelper { return input; } + async findTestRole(name: RoleName): Promise { + const role = await this.roleService.findByName(name); + + if (!role) { + throw new Error(`Role with the name "${name}" not found!`); + } + + return role; + } + async createTestUser(input: Partial): Promise { const role = await this.findTestRole(input.role ?? 'user'); @@ -66,6 +66,9 @@ export class TestHelper { } async cleanDatabase(): Promise { + await this.prismaService.snippet.deleteMany(); + await this.prismaService.folder.deleteMany(); + await this.prismaService.session.deleteMany(); await this.prismaService.user.deleteMany(); } } diff --git a/apps/web/.eslintrc.js b/apps/web/.eslintrc.js new file mode 100644 index 00000000..19518ab3 --- /dev/null +++ b/apps/web/.eslintrc.js @@ -0,0 +1,94 @@ +module.exports = { + plugins: [], + root: true, + extends: ['next', 'next/core-web-vitals', '../../.eslintrc.json'], + settings: { + next: { + rootDir: '.', + }, + }, + ignorePatterns: [ + 'jest.config.ts', + '__mocks__', + 'next.config.js', + 'tailwind.config.js', + 'postcss.config.js', + 'next-sitemap.js', + 'sentry.client.config.js', + 'sentry.server.config.js', + '.eslintrc.js', + ], + parserOptions: { + ecmaVersion: 2023, + sourceType: 'module', + project: 'tsconfig.json', + tsconfigRootDir: __dirname, + }, + rules: { + camelcase: 'off', + 'import/prefer-default-export': 'off', + 'react/jsx-filename-extension': 'off', + 'react/jsx-props-no-spreading': 'off', + 'react/no-unused-prop-types': 'off', + 'react/require-default-props': 'off', + 'import/extensions': 'off', + quotes: 'off', + 'jsx-a11y/anchor-is-valid': [ + 'error', + { + components: ['Link'], + specialLink: ['hrefLeft', 'hrefRight'], + aspects: ['invalidHref', 'preferButton'], + }, + ], + '@next/next/no-html-link-for-pages': ['error', '.'], + }, + overrides: [ + { + files: '**/*.+(ts|tsx)', + parser: '@typescript-eslint/parser', + plugins: ['testing-library', 'jest-dom'], + extends: ['prettier', 'plugin:jest-dom/recommended'], + rules: { + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + 'no-use-before-define': [0], + '@typescript-eslint/no-use-before-define': [1], + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-var-requires': 'off', + '@typescript-eslint/ban-ts-comment': 'off', + '@typescript-eslint/quotes': [ + 2, + 'single', + { + avoidEscape: true, + }, + ], + 'max-len': [ + 'warn', + { + code: 120, + ignorePattern: '/className=".+"/gm', + ignoreTemplateLiterals: true, + ignoreStrings: true, + ignoreComments: true, + }, + ], + 'sort-keys': 'error', + 'react/destructuring-assignment': 'warn', + 'react/display-name': 'warn', + 'react/prop-types': 'warn', + 'react/sort-prop-types': 'warn', + 'react/no-unescaped-entities': 'off', + 'react/jsx-sort-props': [ + 2, + { + callbacksLast: true, + ignoreCase: false, + shorthandLast: true, + }, + ], + }, + }, + ], +}; diff --git a/apps/web/.eslintrc.json b/apps/web/.eslintrc.json deleted file mode 100644 index 3703e973..00000000 --- a/apps/web/.eslintrc.json +++ /dev/null @@ -1,94 +0,0 @@ -{ - "plugins": [], - "root": true, - "extends": [ - "next", - "next/core-web-vitals", - "../../.eslintrc.json" - ], - "settings": { - "next": { - "rootDir": "." - } - }, - "parserOptions": { - "ecmaVersion": 2023, - "sourceType": "module", - "project": "tsconfig.json" - }, - "rules": { - "camelcase": "off", - "import/prefer-default-export": "off", - "react/jsx-filename-extension": "off", - "react/jsx-props-no-spreading": "off", - "react/no-unused-prop-types": "off", - "react/require-default-props": "off", - "import/extensions": "off", - "quotes": "off", - "jsx-a11y/anchor-is-valid": [ - "error", - { - "components": ["Link"], - "specialLink": ["hrefLeft", "hrefRight"], - "aspects": ["invalidHref", "preferButton"] - } - ], - "@next/next/no-html-link-for-pages": ["error", "."] - }, - "overrides": [ - { - "files": "**/*.+(ts|tsx)", - "parser": "@typescript-eslint/parser", - "plugins": ["testing-library", "jest-dom"], - "extends": ["prettier", "plugin:jest-dom/recommended"], - "rules": { - "@typescript-eslint/explicit-function-return-type": "off", - "@typescript-eslint/explicit-module-boundary-types": "off", - "no-use-before-define": [0], - "@typescript-eslint/no-use-before-define": [1], - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-var-requires": "off", - "@typescript-eslint/ban-ts-comment": "off", - "@typescript-eslint/quotes": [ - 2, - "single", - { - "avoidEscape": true - } - ], - "max-len": ["warn", - { - "code": 120, - "ignorePattern": "/className=\".+\"/gm", - "ignoreTemplateLiterals": true, - "ignoreStrings": true, - "ignoreComments": true - }], - "sort-keys": "error", - "react/destructuring-assignment": "warn", - "react/display-name": "warn", - "react/prop-types": "warn", - "react/sort-prop-types": "warn", - "react/no-unescaped-entities": "off", - "react/jsx-sort-props": [ - 2, - { - "callbacksLast": true, - "ignoreCase": false, - "shorthandLast": true - } - ] - } - } - ], - "ignorePatterns": [ - "jest.config.ts", - "__mocks__", - "next.config.js", - "tailwind.config.js", - "postcss.config.js", - "next-sitemap.js", - "sentry.client.config.js", - "sentry.server.config.js" - ] -} diff --git a/apps/web/src/hooks/authentication/use-auth.ts b/apps/web/src/hooks/authentication/use-auth.ts index 4ef9dbdd..4a1431e8 100644 --- a/apps/web/src/hooks/authentication/use-auth.ts +++ b/apps/web/src/hooks/authentication/use-auth.ts @@ -14,7 +14,14 @@ const useAuth = () => { const { data, isLoading } = useAuthenticatedUser(); const saveToken = (token: string) => { - setCookie(COOKIE_NAME, token, { expires: addDayToDate(90), path: '/', sameSite: 'none', secure: true }); + const currentDate = new Date(); + + setCookie(COOKIE_NAME, token, { + expires: addDayToDate(currentDate, 90), + path: '/', + sameSite: 'none', + secure: true, + }); }; const deleteToken = async () => { diff --git a/packages/domain/src/services/users/user.service.ts b/packages/domain/src/services/users/user.service.ts index f6515b9b..52976b04 100644 --- a/packages/domain/src/services/users/user.service.ts +++ b/packages/domain/src/services/users/user.service.ts @@ -139,6 +139,16 @@ export class UserService { return user; } + async activate(userId: string): Promise { + return this.prisma.user.update({ + data: { + isEnabled: true, + updatedAt: new Date(), + }, + where: { id: userId }, + }); + } + private async generateUsername(email: string, username: string | null): Promise { if (!username) { return generateFromEmail(email, 3); diff --git a/packages/utils/__tests__/common/environment.test.ts b/packages/utils/__tests__/common/environment.test.ts deleted file mode 100644 index e7f68b5b..00000000 --- a/packages/utils/__tests__/common/environment.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { UNDEFINED_VARIABLE_MESSAGE } from '../../src/common/constants'; -import { getEnv } from '../../src/common/environment'; - -describe('Tests environment utils', () => { - test('node env is defined', () => { - // GIVEN - const key = 'NODE_ENV'; - - // WHEN - const result = getEnv(key); - - // THEN - expect(result).toBeTruthy(); - }); - - test('environment variable not defined', () => { - // GIVEN - const key = 'UNDEFINED_VAR'; - - // WHEN - // THEN - expect(() => getEnv(key)).toThrow(UNDEFINED_VARIABLE_MESSAGE(key)); - }); -}); diff --git a/packages/utils/index.ts b/packages/utils/index.ts index 910b27a8..6f2812eb 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -1,10 +1,9 @@ -export { AppError, isAppError } from './src/errors/app-error'; +export { AppError, isAppError } from './src/error/error'; +export type { AppErrorCode } from './src/error/error'; +export * as errors from './src/error/messages'; -export * as constants from './src/common/constants'; -export * as errors from './src/errors/messages'; -export * from './src/common/environment'; -export * from './src/common/uuid'; -export * from './src/date/date'; +export { generateJwtToken, verifyJwtToken } from './src/common/jwt'; +export { isValidUUIDV4, generateRandomId } from './src/common/uuid'; +export { addDayToDate } from './src/date/date'; -export type { Language } from './src/types/snippet'; -export type { AppErrorCode } from './src/errors/types'; +export type { Language } from './src/snippet/snippet'; diff --git a/packages/utils/package.json b/packages/utils/package.json index 527eb4ea..d2d7f0bd 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -10,14 +10,17 @@ "build": "tsc --project tsconfig.prod.json", "clean": "rm -rf .turbo dist", "lint": "eslint src index.ts", - "test": "jest" + "test": "jest", + "test:watch": "jest --watch" }, "dependencies": { "dayjs": "1.11.10", + "jsonwebtoken": "9.0.2", "lodash": "4.17.21", "uuid": "9.0.1" }, "devDependencies": { + "@types/jsonwebtoken": "9.0.6", "@types/uuid": "9.0.8" } } diff --git a/packages/utils/src/common/constants.ts b/packages/utils/src/common/constants.ts deleted file mode 100644 index 285b18f6..00000000 --- a/packages/utils/src/common/constants.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const UNDEFINED_VARIABLE_MESSAGE = (key: string) => - `The environment variable "${key}" is required but not provided!`; diff --git a/packages/utils/src/common/environment.ts b/packages/utils/src/common/environment.ts deleted file mode 100644 index 1f64dcfd..00000000 --- a/packages/utils/src/common/environment.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { UNDEFINED_VARIABLE_MESSAGE } from './constants'; - -export const getEnv = (key: string): string => { - const value = process.env[key]; - - if (!value) { - throw new Error(UNDEFINED_VARIABLE_MESSAGE(key)); - } - - return value; -}; diff --git a/packages/utils/src/common/jwt.test.ts b/packages/utils/src/common/jwt.test.ts new file mode 100644 index 00000000..9df5e234 --- /dev/null +++ b/packages/utils/src/common/jwt.test.ts @@ -0,0 +1,20 @@ +import { generateJwtToken, verifyJwtToken } from './jwt'; + +describe('JWT utility functions', () => { + const payload = { name: 'John Doe' }; + const secret = 'your-256-bit-secret'; + const expiresIn = '1h'; + + it('should generate a JWT token', () => { + const token = generateJwtToken({ expiresIn, payload, secret }); + + expect(typeof token).toBe('string'); + }); + + it('should verify a JWT token', () => { + const token = generateJwtToken({ expiresIn, payload, secret }); + const decodedPayload = verifyJwtToken({ secret, token }); + + expect(decodedPayload).toEqual(expect.objectContaining({ name: 'John Doe' })); + }); +}); diff --git a/packages/utils/src/common/jwt.ts b/packages/utils/src/common/jwt.ts new file mode 100644 index 00000000..aed66988 --- /dev/null +++ b/packages/utils/src/common/jwt.ts @@ -0,0 +1,17 @@ +import jwt, { JwtPayload } from 'jsonwebtoken'; + +type GenerateJwtInput = { expiresIn: string; payload: Payload; secret: string }; + +type VerifyJwtInput = { secret: string; token: string }; + +export const generateJwtToken = ({ + expiresIn, + payload, + secret, +}: GenerateJwtInput): string => { + return jwt.sign(payload, secret, { expiresIn }); +}; + +export const verifyJwtToken = ({ secret, token }: VerifyJwtInput): T => { + return jwt.verify(token, secret) as T; +}; diff --git a/packages/utils/src/common/uuid.test.ts b/packages/utils/src/common/uuid.test.ts new file mode 100644 index 00000000..beb2b555 --- /dev/null +++ b/packages/utils/src/common/uuid.test.ts @@ -0,0 +1,20 @@ +import { v4 as uuidv4 } from 'uuid'; + +import { generateRandomId, isValidUUIDV4 } from './uuid'; + +describe('UUID utility functions', () => { + it('should generate a valid UUID', () => { + const id = generateRandomId(); + + expect(typeof id).toBe('string'); + expect(isValidUUIDV4(id)).toBe(true); + }); + + it('should validate a UUID', () => { + const validUUID = uuidv4(); + const invalidUUID = 'invalid-uuid'; + + expect(isValidUUIDV4(validUUID)).toBe(true); + expect(isValidUUIDV4(invalidUUID)).toBe(false); + }); +}); diff --git a/packages/utils/src/date/date.test.ts b/packages/utils/src/date/date.test.ts new file mode 100644 index 00000000..fb87998f --- /dev/null +++ b/packages/utils/src/date/date.test.ts @@ -0,0 +1,36 @@ +import { addDayToDate } from './date'; + +describe('TesT addDayToDate', () => { + it('should add the correct number of days to the current date', () => { + const date = new Date(2024, 5, 1, 10, 45, 30); + const numberOfDaysToAdd = 5; + + const result = addDayToDate(date, numberOfDaysToAdd); + + const expectedDate = new Date(2024, 5, 6, 10, 45, 30); + + expect(result).toEqual(expectedDate); + }); + + it('should return the current date when no days are added', () => { + const date = new Date(2024, 5, 1, 10, 45, 30); + const numberOfDaysToAdd = 0; + + const result = addDayToDate(date, numberOfDaysToAdd); + + const expectedDate = new Date(2024, 5, 1, 10, 45, 30); + + expect(result).toEqual(expectedDate); + }); + + it('should handle negative numbers correctly', () => { + const date = new Date(2024, 5, 2, 10, 45, 30); + const numberOfDaysToAdd = -3; + + const result = addDayToDate(date, numberOfDaysToAdd); + + const expectedDate = new Date(2024, 4, 30, 10, 45, 30); + + expect(result).toEqual(expectedDate); + }); +}); diff --git a/packages/utils/src/date/date.ts b/packages/utils/src/date/date.ts index 8a11d65f..27ba5d25 100644 --- a/packages/utils/src/date/date.ts +++ b/packages/utils/src/date/date.ts @@ -1,5 +1,5 @@ import dayjs from 'dayjs'; -export const addDayToDate = (numberOfDay: number): Date => { - return dayjs().add(numberOfDay, 'days').toDate(); +export const addDayToDate = (date: Date, numberOfDay: number): Date => { + return dayjs(date).add(numberOfDay, 'days').toDate(); }; diff --git a/packages/utils/src/errors/types.ts b/packages/utils/src/error/error.ts similarity index 57% rename from packages/utils/src/errors/types.ts rename to packages/utils/src/error/error.ts index b65f8052..14679aa8 100644 --- a/packages/utils/src/errors/types.ts +++ b/packages/utils/src/error/error.ts @@ -16,4 +16,19 @@ export type AppErrorCode = | 'SNIPPET_NOT_FOUND' | 'CANT_EDIT_SNIPPET' | 'CANT_EDIT_FOLDER' - | 'CANT_RENAME_ROOT_FOLDER'; + | 'CANT_RENAME_ROOT_FOLDER' + | 'INVALID_CONFIRMATION_TOKEN' + | 'USER_NOT_FOUND'; + +export class AppError extends Error { + constructor( + public message: string, + public code: AppErrorCode = 'INTERNAL_ERROR', + ) { + super(); + } +} + +export const isAppError = (error: unknown): error is AppError => { + return error instanceof AppError; +}; diff --git a/packages/utils/src/errors/messages.ts b/packages/utils/src/error/messages.ts similarity index 92% rename from packages/utils/src/errors/messages.ts rename to packages/utils/src/error/messages.ts index 950a6051..39f41a26 100644 --- a/packages/utils/src/errors/messages.ts +++ b/packages/utils/src/error/messages.ts @@ -20,3 +20,5 @@ export const CANT_EDIT_SNIPPET = (userId: string, snippetId: string) => export const CANT_EDIT_FOLDER = (userId: string, folderId: string) => `The user with id ${userId} cannot edit the folder ${folderId}`; export const CANT_RENAME_ROOT_FOLDER = 'The root folder cannot be renamed.'; +export const INVALID_CONFIRMATION_TOKEN = 'Invalid confirmation token'; +export const USER_NOT_FOUND_FROM_TOKEN = 'No user associated with this token'; diff --git a/packages/utils/src/errors/app-error.ts b/packages/utils/src/errors/app-error.ts deleted file mode 100644 index 2d1aa2e8..00000000 --- a/packages/utils/src/errors/app-error.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { AppErrorCode } from './types'; - -export class AppError extends Error { - constructor( - public message: string, - public code: AppErrorCode = 'INTERNAL_ERROR', - ) { - super(); - } -} - -export const isAppError = (error: unknown): error is AppError => { - return error instanceof AppError; -}; diff --git a/packages/utils/src/types/snippet.ts b/packages/utils/src/snippet/snippet.ts similarity index 100% rename from packages/utils/src/types/snippet.ts rename to packages/utils/src/snippet/snippet.ts diff --git a/packages/utils/tsconfig.prod.json b/packages/utils/tsconfig.prod.json index e8c5afe2..aa170495 100644 --- a/packages/utils/tsconfig.prod.json +++ b/packages/utils/tsconfig.prod.json @@ -4,6 +4,6 @@ "sourceMap": false }, "exclude": [ - "__tests__" + "**/*.test.ts", ] } diff --git a/yarn.lock b/yarn.lock index f3e7386d..25c909cf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4551,8 +4551,10 @@ __metadata: version: 0.0.0-use.local resolution: "@snipcode/utils@workspace:packages/utils" dependencies: + "@types/jsonwebtoken": "npm:9.0.6" "@types/uuid": "npm:9.0.8" dayjs: "npm:1.11.10" + jsonwebtoken: "npm:9.0.2" lodash: "npm:4.17.21" uuid: "npm:9.0.1" languageName: unknown @@ -5067,6 +5069,15 @@ __metadata: languageName: node linkType: hard +"@types/jsonwebtoken@npm:9.0.6": + version: 9.0.6 + resolution: "@types/jsonwebtoken@npm:9.0.6" + dependencies: + "@types/node": "npm:*" + checksum: 10/1f2145222f62da1b3dbfc586160c4f9685782a671f4a4f4a72151c773945fe25807fd88ed1c270536b76f49053ed932c5dbf714ea0ed77f785665abb75beef05 + languageName: node + linkType: hard + "@types/keygrip@npm:*": version: 1.0.6 resolution: "@types/keygrip@npm:1.0.6" @@ -6866,6 +6877,13 @@ __metadata: languageName: node linkType: hard +"buffer-equal-constant-time@npm:1.0.1": + version: 1.0.1 + resolution: "buffer-equal-constant-time@npm:1.0.1" + checksum: 10/80bb945f5d782a56f374b292770901065bad21420e34936ecbe949e57724b4a13874f735850dd1cc61f078773c4fb5493a41391e7bda40d1fa388d6bd80daaab + languageName: node + linkType: hard + "buffer-from@npm:^1.0.0": version: 1.1.2 resolution: "buffer-from@npm:1.1.2" @@ -8237,6 +8255,15 @@ __metadata: languageName: node linkType: hard +"ecdsa-sig-formatter@npm:1.0.11": + version: 1.0.11 + resolution: "ecdsa-sig-formatter@npm:1.0.11" + dependencies: + safe-buffer: "npm:^5.0.1" + checksum: 10/878e1aab8a42773320bc04c6de420bee21aebd71810e40b1799880a8a1c4594bcd6adc3d4213a0fb8147d4c3f529d8f9a618d7f59ad5a9a41b142058aceda23f + languageName: node + linkType: hard + "ee-first@npm:1.1.1": version: 1.1.1 resolution: "ee-first@npm:1.1.1" @@ -11742,6 +11769,24 @@ __metadata: languageName: node linkType: hard +"jsonwebtoken@npm:9.0.2": + version: 9.0.2 + resolution: "jsonwebtoken@npm:9.0.2" + dependencies: + jws: "npm:^3.2.2" + lodash.includes: "npm:^4.3.0" + lodash.isboolean: "npm:^3.0.3" + lodash.isinteger: "npm:^4.0.4" + lodash.isnumber: "npm:^3.0.3" + lodash.isplainobject: "npm:^4.0.6" + lodash.isstring: "npm:^4.0.1" + lodash.once: "npm:^4.0.0" + ms: "npm:^2.1.1" + semver: "npm:^7.5.4" + checksum: 10/6e9b6d879cec2b27f2f3a88a0c0973edc7ba956a5d9356b2626c4fddfda969e34a3832deaf79c3e1c6c9a525bc2c4f2c2447fa477f8ac660f0017c31a59ae96b + languageName: node + linkType: hard + "jsx-ast-utils@npm:^2.4.1 || ^3.0.0, jsx-ast-utils@npm:^3.3.5": version: 3.3.5 resolution: "jsx-ast-utils@npm:3.3.5" @@ -11754,6 +11799,27 @@ __metadata: languageName: node linkType: hard +"jwa@npm:^1.4.1": + version: 1.4.1 + resolution: "jwa@npm:1.4.1" + dependencies: + buffer-equal-constant-time: "npm:1.0.1" + ecdsa-sig-formatter: "npm:1.0.11" + safe-buffer: "npm:^5.0.1" + checksum: 10/0bc002b71dd70480fedc7d442a4d2b9185a9947352a027dcb4935864ad2323c57b5d391adf968a3622b61e940cef4f3484d5813b95864539272d41cac145d6f3 + languageName: node + linkType: hard + +"jws@npm:^3.2.2": + version: 3.2.2 + resolution: "jws@npm:3.2.2" + dependencies: + jwa: "npm:^1.4.1" + safe-buffer: "npm:^5.0.1" + checksum: 10/70b016974af8a76d25030c80a0097b24ed5b17a9cf10f43b163c11cb4eb248d5d04a3fe48c0d724d2884c32879d878ccad7be0663720f46b464f662f7ed778fe + languageName: node + linkType: hard + "keyv@npm:^4.5.3": version: 4.5.4 resolution: "keyv@npm:4.5.4" @@ -11928,6 +11994,20 @@ __metadata: languageName: node linkType: hard +"lodash.includes@npm:^4.3.0": + version: 4.3.0 + resolution: "lodash.includes@npm:4.3.0" + checksum: 10/45e0a7c7838c931732cbfede6327da321b2b10482d5063ed21c020fa72b09ca3a4aa3bda4073906ab3f436cf36eb85a52ea3f08b7bab1e0baca8235b0e08fe51 + languageName: node + linkType: hard + +"lodash.isboolean@npm:^3.0.3": + version: 3.0.3 + resolution: "lodash.isboolean@npm:3.0.3" + checksum: 10/b70068b4a8b8837912b54052557b21fc4774174e3512ed3c5b94621e5aff5eb6c68089d0a386b7e801d679cd105d2e35417978a5e99071750aa2ed90bffd0250 + languageName: node + linkType: hard + "lodash.isfunction@npm:^3.0.9": version: 3.0.9 resolution: "lodash.isfunction@npm:3.0.9" @@ -11935,6 +12015,20 @@ __metadata: languageName: node linkType: hard +"lodash.isinteger@npm:^4.0.4": + version: 4.0.4 + resolution: "lodash.isinteger@npm:4.0.4" + checksum: 10/c971f5a2d67384f429892715550c67bac9f285604a0dd79275fd19fef7717aec7f2a6a33d60769686e436ceb9771fd95fe7fcb68ad030fc907d568d5a3b65f70 + languageName: node + linkType: hard + +"lodash.isnumber@npm:^3.0.3": + version: 3.0.3 + resolution: "lodash.isnumber@npm:3.0.3" + checksum: 10/913784275b565346255e6ae6a6e30b760a0da70abc29f3e1f409081585875105138cda4a429ff02577e1bc0a7ae2a90e0a3079a37f3a04c3d6c5aaa532f4cab2 + languageName: node + linkType: hard + "lodash.isplainobject@npm:^4.0.6": version: 4.0.6 resolution: "lodash.isplainobject@npm:4.0.6" @@ -11942,6 +12036,13 @@ __metadata: languageName: node linkType: hard +"lodash.isstring@npm:^4.0.1": + version: 4.0.1 + resolution: "lodash.isstring@npm:4.0.1" + checksum: 10/eaac87ae9636848af08021083d796e2eea3d02e80082ab8a9955309569cb3a463ce97fd281d7dc119e402b2e7d8c54a23914b15d2fc7fff56461511dc8937ba0 + languageName: node + linkType: hard + "lodash.kebabcase@npm:^4.1.1": version: 4.1.1 resolution: "lodash.kebabcase@npm:4.1.1" @@ -11977,6 +12078,13 @@ __metadata: languageName: node linkType: hard +"lodash.once@npm:^4.0.0": + version: 4.1.1 + resolution: "lodash.once@npm:4.1.1" + checksum: 10/202f2c8c3d45e401b148a96de228e50ea6951ee5a9315ca5e15733d5a07a6b1a02d9da1e7fdf6950679e17e8ca8f7190ec33cae47beb249b0c50019d753f38f3 + languageName: node + linkType: hard + "lodash.snakecase@npm:^4.1.1": version: 4.1.1 resolution: "lodash.snakecase@npm:4.1.1"