diff --git a/.github/workflows/ci-server.yaml b/.github/workflows/ci-server.yaml index 82c24cac9a8e6..04a9426ef7b12 100644 --- a/.github/workflows/ci-server.yaml +++ b/.github/workflows/ci-server.yaml @@ -5,7 +5,26 @@ on: - main pull_request_target: jobs: + postgres-job: + runs-on: ubuntu-latest + container: node:10.18-jessie + steps: + - run: echo "Postgres job finished" + services: + postgres: + image: postgres + env: + POSTGRES_HOST: postgres + POSTGRES_PASSWORD: postgrespassword + POSTGRES_DB: test + POSTGRES_PORT: 5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 server-test: + needs: postgres-job runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 @@ -27,3 +46,6 @@ jobs: - name: Server / Run jest tests run: | cd server && yarn test + - name: Server / Run e2e tests + run: | + cd server && yarn test:e2e diff --git a/front/src/generated/graphql.tsx b/front/src/generated/graphql.tsx index 673c5eed38579..bfa5f35a0e665 100644 --- a/front/src/generated/graphql.tsx +++ b/front/src/generated/graphql.tsx @@ -1727,7 +1727,7 @@ export type QueryFindManyWorkspaceMemberArgs = { export type QueryFindUniqueCompanyArgs = { - id: Scalars['String']; + where: CompanyWhereUniqueInput; }; @@ -2299,7 +2299,7 @@ export type GetCompaniesQueryVariables = Exact<{ export type GetCompaniesQuery = { __typename?: 'Query', companies: Array<{ __typename?: 'Company', id: string, domainName: string, name: string, createdAt: string, address: string, linkedinUrl?: string | null, employees?: number | null, _commentThreadCount: number, accountOwner?: { __typename?: 'User', id: string, email: string, displayName: string, firstName?: string | null, lastName?: string | null, avatarUrl?: string | null } | null }> }; export type GetCompanyQueryVariables = Exact<{ - id: Scalars['String']; + where: CompanyWhereUniqueInput; }>; @@ -3390,8 +3390,8 @@ export type GetCompaniesQueryHookResult = ReturnType; export type GetCompaniesQueryResult = Apollo.QueryResult; export const GetCompanyDocument = gql` - query GetCompany($id: String!) { - findUniqueCompany(id: $id) { + query GetCompany($where: CompanyWhereUniqueInput!) { + findUniqueCompany(where: $where) { id domainName name @@ -3422,7 +3422,7 @@ export const GetCompanyDocument = gql` * @example * const { data, loading, error } = useGetCompanyQuery({ * variables: { - * id: // value for 'id' + * where: // value for 'where' * }, * }); */ diff --git a/front/src/modules/companies/queries/show.ts b/front/src/modules/companies/queries/show.ts index 4babcbe55fe23..4344b5b2a2d39 100644 --- a/front/src/modules/companies/queries/show.ts +++ b/front/src/modules/companies/queries/show.ts @@ -3,8 +3,8 @@ import { gql } from '@apollo/client'; import { useGetCompanyQuery } from '~/generated/graphql'; export const GET_COMPANY = gql` - query GetCompany($id: String!) { - findUniqueCompany(id: $id) { + query GetCompany($where: CompanyWhereUniqueInput!) { + findUniqueCompany(where: $where) { id domainName name @@ -24,5 +24,5 @@ export const GET_COMPANY = gql` `; export function useCompanyQuery(id: string) { - return useGetCompanyQuery({ variables: { id } }); + return useGetCompanyQuery({ variables: { where: { id } } }); } diff --git a/infra/dev/postgres/init.sql b/infra/dev/postgres/init.sql index c777e4b91c4b7..ec152e4f7e3a6 100644 --- a/infra/dev/postgres/init.sql +++ b/infra/dev/postgres/init.sql @@ -1 +1,5 @@ +-- Create the default database for development CREATE DATABASE "default"; + +-- Create the tests database for e2e testing +CREATE DATABASE "tests"; diff --git a/server/.env.test b/server/.env.test new file mode 100644 index 0000000000000..e77a739cf6a3e --- /dev/null +++ b/server/.env.test @@ -0,0 +1,12 @@ +DEBUG_MODE=true +AUTH_GOOGLE_ENABLED=false +ACCESS_TOKEN_SECRET=secret_jwt +ACCESS_TOKEN_EXPIRES_IN=1d +REFRESH_TOKEN_SECRET=secret_refresh_token +REFRESH_TOKEN_EXPIRES_IN=30d +LOGIN_TOKEN_SECRET=secret_login_token +LOGIN_TOKEN_EXPIRES_IN=15m +FRONT_AUTH_CALLBACK_URL=http://localhost:3001/auth/callback +PG_DATABASE_URL=postgres://postgres:postgrespassword@localhost:5432/tests?connection_limit=1 +STORAGE_TYPE=local +STORAGE_LOCAL_PATH=.local-storage diff --git a/server/jest.config.ts b/server/jest.config.ts index fd6760a66f355..1ae2d964e87d3 100644 --- a/server/jest.config.ts +++ b/server/jest.config.ts @@ -2,7 +2,9 @@ module.exports = { clearMocks: true, preset: 'ts-jest', testEnvironment: 'node', - setupFilesAfterEnv: ['/src/database/client-mock/jest-prisma-singleton.ts'], + setupFilesAfterEnv: [ + '/src/database/client-mock/jest-prisma-singleton.ts', + ], moduleFileExtensions: ['js', 'json', 'ts'], moduleNameMapper: { diff --git a/server/package.json b/server/package.json index 998aa985bc66e..f78d290b0ff06 100644 --- a/server/package.json +++ b/server/package.json @@ -18,7 +18,7 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json", + "test:e2e": "./scripts/run-integration.sh", "prisma:generate-client": "npx prisma generate --generator client && yarn prisma:generate-gql-select", "prisma:generate-gql-select": "node scripts/generate-model-select-map.js", "prisma:generate-nest-graphql": "npx prisma generate --generator nestgraphql", @@ -57,7 +57,7 @@ "class-validator": "^0.14.0", "date-fns": "^2.30.0", "file-type": "13.0.0", - "graphql": "^16.6.0", + "graphql": "^16.7.1", "graphql-type-json": "^0.3.2", "graphql-upload": "^13.0.0", "jest-mock-extended": "^3.0.4", diff --git a/server/scripts/run-integration.sh b/server/scripts/run-integration.sh new file mode 100755 index 0000000000000..cfdeaa792b3da --- /dev/null +++ b/server/scripts/run-integration.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# src/run-integration.sh + +DIR="$(cd "$(dirname "$0")" && pwd)" +source $DIR/setenv.sh + +npx ts-node ./test/utils/check-db.ts +EXIT_CODE=$? + +if [ $EXIT_CODE -ne 0 ]; then + echo '🟡 - Database is not initialized. Running migrations...' + npx prisma migrate reset --force && yarn prisma:generate +else + echo "🟢 - Database is already initialized." +fi + +yarn jest --config ./test/jest-e2e.json diff --git a/server/scripts/setenv.sh b/server/scripts/setenv.sh new file mode 100755 index 0000000000000..29d74b2f4fd1c --- /dev/null +++ b/server/scripts/setenv.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# scripts/setenv.sh + +# Get script's directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Construct the absolute path of .env file in the project root directory +ENV_PATH="${SCRIPT_DIR}/../.env.test" + +# Check if the file exists +if [ -f "${ENV_PATH}" ]; then + echo "🔵 - Loading environment variables from "${ENV_PATH}"..." + # Export env vars + export $(grep -v '^#' ${ENV_PATH} | xargs) +else + echo "Error: ${ENV_PATH} does not exist." + exit 1 +fi diff --git a/server/src/ability/ability.module.ts b/server/src/ability/ability.module.ts index c772a55545bb2..9e1412432519c 100644 --- a/server/src/ability/ability.module.ts +++ b/server/src/ability/ability.module.ts @@ -26,7 +26,7 @@ import { } from './handlers/workspace-member.ability-handler'; import { ManageCompanyAbilityHandler, - ReadCompanyAbilityHandler, + ReadOneCompanyAbilityHandler, CreateCompanyAbilityHandler, UpdateCompanyAbilityHandler, DeleteCompanyAbilityHandler, @@ -128,7 +128,7 @@ import { DeleteWorkspaceMemberAbilityHandler, // Company ManageCompanyAbilityHandler, - ReadCompanyAbilityHandler, + ReadOneCompanyAbilityHandler, CreateCompanyAbilityHandler, UpdateCompanyAbilityHandler, DeleteCompanyAbilityHandler, @@ -215,7 +215,7 @@ import { DeleteWorkspaceMemberAbilityHandler, // Company ManageCompanyAbilityHandler, - ReadCompanyAbilityHandler, + ReadOneCompanyAbilityHandler, CreateCompanyAbilityHandler, UpdateCompanyAbilityHandler, DeleteCompanyAbilityHandler, diff --git a/server/src/ability/ability.util.ts b/server/src/ability/ability.util.ts index 8b61c92d82991..94bb193b7e2de 100644 --- a/server/src/ability/ability.util.ts +++ b/server/src/ability/ability.util.ts @@ -205,3 +205,42 @@ export async function relationAbilityChecker( return true; } + +const isWhereInput = (input: any): boolean => { + return Object.values(input).some((value) => typeof value === 'object'); +}; + +type ExcludeUnique = T extends infer U + ? 'AND' extends keyof U + ? U + : never + : never; + +/** + * Convert a where unique input to a where input prisma + * @param args Can be a where unique input or a where input + * @returns whare input + */ +export const convertToWhereInput = ( + where: T | undefined, +): ExcludeUnique | undefined => { + const input = where as any; + + if (!input) { + return input; + } + + // If it's already a WhereInput, return it directly + if (isWhereInput(input)) { + return input; + } + + // If not convert it to a WhereInput + const whereInput = {}; + + for (const key in input) { + whereInput[key] = { equals: input[key] }; + } + + return whereInput as ExcludeUnique; +}; diff --git a/server/src/ability/handlers/company.ability-handler.ts b/server/src/ability/handlers/company.ability-handler.ts index 0be59ca2a5bbf..4727b4333b14c 100644 --- a/server/src/ability/handlers/company.ability-handler.ts +++ b/server/src/ability/handlers/company.ability-handler.ts @@ -13,11 +13,15 @@ import { PrismaService } from 'src/database/prisma.service'; import { AbilityAction } from 'src/ability/ability.action'; import { AppAbility } from 'src/ability/ability.factory'; import { CompanyWhereInput } from 'src/core/@generated/company/company-where.input'; -import { relationAbilityChecker } from 'src/ability/ability.util'; +import { CompanyWhereUniqueInput } from 'src/core/@generated/company/company-where-unique.input'; +import { + convertToWhereInput, + relationAbilityChecker, +} from 'src/ability/ability.util'; import { assert } from 'src/utils/assert'; class CompanyArgs { - where?: CompanyWhereInput; + where?: CompanyWhereUniqueInput | CompanyWhereInput; [key: string]: any; } @@ -29,9 +33,18 @@ export class ManageCompanyAbilityHandler implements IAbilityHandler { } @Injectable() -export class ReadCompanyAbilityHandler implements IAbilityHandler { - handle(ability: AppAbility) { - return ability.can(AbilityAction.Read, 'Company'); +export class ReadOneCompanyAbilityHandler implements IAbilityHandler { + constructor(private readonly prismaService: PrismaService) {} + + async handle(ability: AppAbility, context: ExecutionContext) { + const gqlContext = GqlExecutionContext.create(context); + const args = gqlContext.getArgs(); + const company = await this.prismaService.client.company.findFirst({ + where: args.where, + }); + assert(company, '', NotFoundException); + + return ability.can(AbilityAction.Read, subject('Company', company)); } } @@ -65,10 +78,11 @@ export class UpdateCompanyAbilityHandler implements IAbilityHandler { async handle(ability: AppAbility, context: ExecutionContext) { const gqlContext = GqlExecutionContext.create(context); const args = gqlContext.getArgs(); - const company = await this.prismaService.client.company.findFirst({ - where: args.where, + const where = convertToWhereInput(args.where); + const companies = await this.prismaService.client.company.findMany({ + where, }); - assert(company, '', NotFoundException); + assert(companies.length, '', NotFoundException); const allowed = await relationAbilityChecker( 'Company', @@ -81,7 +95,18 @@ export class UpdateCompanyAbilityHandler implements IAbilityHandler { return false; } - return ability.can(AbilityAction.Update, subject('Company', company)); + for (const company of companies) { + const allowed = ability.can( + AbilityAction.Delete, + subject('Company', company), + ); + + if (!allowed) { + return false; + } + } + + return true; } } @@ -92,11 +117,23 @@ export class DeleteCompanyAbilityHandler implements IAbilityHandler { async handle(ability: AppAbility, context: ExecutionContext) { const gqlContext = GqlExecutionContext.create(context); const args = gqlContext.getArgs(); - const company = await this.prismaService.client.company.findFirst({ - where: args.where, + const where = convertToWhereInput(args.where); + const companies = await this.prismaService.client.company.findMany({ + where, }); - assert(company, '', NotFoundException); + assert(companies.length, '', NotFoundException); + + for (const company of companies) { + const allowed = ability.can( + AbilityAction.Delete, + subject('Company', company), + ); + + if (!allowed) { + return false; + } + } - return ability.can(AbilityAction.Delete, subject('Company', company)); + return true; } } diff --git a/server/src/core/company/company.resolver.ts b/server/src/core/company/company.resolver.ts index f3c4cc1b02df9..c7f9084f7ebd9 100644 --- a/server/src/core/company/company.resolver.ts +++ b/server/src/core/company/company.resolver.ts @@ -21,11 +21,12 @@ import { CheckAbilities } from 'src/decorators/check-abilities.decorator'; import { CreateCompanyAbilityHandler, DeleteCompanyAbilityHandler, - ReadCompanyAbilityHandler, + ReadOneCompanyAbilityHandler, UpdateCompanyAbilityHandler, } from 'src/ability/handlers/company.ability-handler'; import { UserAbility } from 'src/decorators/user-ability.decorator'; import { AppAbility } from 'src/ability/ability.factory'; +import { FindUniqueCompanyArgs } from 'src/core/@generated/company/find-unique-company.args'; import { CompanyService } from './company.service'; @@ -36,7 +37,6 @@ export class CompanyResolver { @Query(() => [Company]) @UseGuards(AbilityGuard) - @CheckAbilities(ReadCompanyAbilityHandler) async findManyCompany( @Args() args: FindManyCompanyArgs, @UserAbility() ability: AppAbility, @@ -60,19 +60,18 @@ export class CompanyResolver { @Query(() => Company) @UseGuards(AbilityGuard) - @CheckAbilities(ReadCompanyAbilityHandler) + @CheckAbilities(ReadOneCompanyAbilityHandler) async findUniqueCompany( - @Args('id') id: string, - @UserAbility() ability: AppAbility, + @Args() args: FindUniqueCompanyArgs, @PrismaSelector({ modelName: 'Company' }) prismaSelect: PrismaSelect<'Company'>, ): Promise> { - return this.companyService.findUniqueOrThrow({ - where: { - id: id, - }, + const company = this.companyService.findUniqueOrThrow({ + where: args.where, select: prismaSelect.value, }); + + return company; } @Mutation(() => Company, { diff --git a/server/src/core/view/resolvers/view-field.resolver.spec.ts b/server/src/core/view/resolvers/view-field.resolver.spec.ts index 2084cedb46041..870fbe26d7f79 100644 --- a/server/src/core/view/resolvers/view-field.resolver.spec.ts +++ b/server/src/core/view/resolvers/view-field.resolver.spec.ts @@ -1,9 +1,9 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ViewFieldService } from 'src/core/view/services/view-field.service'; +import { AbilityFactory } from 'src/ability/ability.factory'; import { ViewFieldResolver } from './view-field.resolver'; -import { AbilityFactory } from 'src/ability/ability.factory'; describe('ViewFieldResolver', () => { let resolver: ViewFieldResolver; diff --git a/server/src/database/prisma.service.ts b/server/src/database/prisma.service.ts index d21592b1455c3..15b806b332fff 100644 --- a/server/src/database/prisma.service.ts +++ b/server/src/database/prisma.service.ts @@ -2,6 +2,7 @@ import { INestApplication, Injectable, Logger, + OnModuleDestroy, OnModuleInit, } from '@nestjs/common'; @@ -20,7 +21,7 @@ const createPrismaClient = (options: Prisma.PrismaClientOptions) => { type ExtendedPrismaClient = ReturnType; @Injectable() -export class PrismaService implements OnModuleInit { +export class PrismaService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(PrismaService.name); private prismaClient!: ExtendedPrismaClient; @@ -61,6 +62,10 @@ export class PrismaService implements OnModuleInit { await this.prismaClient.$connect(); } + async onModuleDestroy(): Promise { + await this.prismaClient.$disconnect(); + } + async enableShutdownHooks(app: INestApplication) { this.prismaClient.$on('beforeExit', async () => { await app.close(); diff --git a/server/test/app.e2e-spec.ts b/server/test/app.e2e-spec.ts index 50cda62332e94..a8a18c8bcbf1a 100644 --- a/server/test/app.e2e-spec.ts +++ b/server/test/app.e2e-spec.ts @@ -1,24 +1,31 @@ -import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; -import * as request from 'supertest'; -import { AppModule } from './../src/app.module'; + +import request from 'supertest'; + +import { createApp } from './utils/create-app'; describe('AppController (e2e)', () => { let app: INestApplication; beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); + [app] = await createApp(); + }); - app = moduleFixture.createNestApplication(); - await app.init(); + afterEach(async () => { + await app.close(); }); - it('/ (GET)', () => { + it('/healthz (GET)', () => { return request(app.getHttpServer()) - .get('/') + .get('/healthz') .expect(200) - .expect('Hello World!'); + .expect((response) => { + expect(response.body).toEqual({ + status: 'ok', + info: { database: { status: 'up' } }, + error: {}, + details: { database: { status: 'up' } }, + }); + }); }); }); diff --git a/server/test/company.e2e-spec.ts b/server/test/company.e2e-spec.ts new file mode 100644 index 0000000000000..988cca0b8e0af --- /dev/null +++ b/server/test/company.e2e-spec.ts @@ -0,0 +1,298 @@ +import { INestApplication } from '@nestjs/common'; + +import request from 'supertest'; + +import { JwtAuthGuard } from 'src/guards/jwt.auth.guard'; + +import { createApp } from './utils/create-app'; + +describe('CompanyResolver (e2e)', () => { + let app: INestApplication; + let companyId: string | undefined; + + const authGuardMock = { canActivate: (): any => true }; + + beforeEach(async () => { + [app] = await createApp({ + moduleBuilderHook: (moduleBuilder) => + moduleBuilder.overrideGuard(JwtAuthGuard).useValue(authGuardMock), + }); + }); + + afterEach(async () => { + await app.close(); + }); + + it('should create a company', () => { + const queryData = { + query: ` + mutation CreateOneCompany($data: CompanyCreateInput!) { + createOneCompany(data: $data) { + id + name + domainName + address + } + } + `, + variables: { + data: { + name: 'New Company', + domainName: 'new-company.com', + address: 'New Address', + }, + }, + }; + + return request(app.getHttpServer()) + .post('/graphql') + .send(queryData) + .expect(200) + .expect((res) => { + const data = res.body.data.createOneCompany; + + companyId = data.id; + + expect(data).toBeDefined(); + expect(data).toHaveProperty('id'); + expect(data).toHaveProperty('name', 'New Company'); + expect(data).toHaveProperty('domainName', 'new-company.com'); + expect(data).toHaveProperty('address', 'New Address'); + }); + }); + + it('should find many companies', () => { + const queryData = { + query: ` + query FindManyCompany { + findManyCompany { + id + name + domainName + address + } + } + `, + }; + + return request(app.getHttpServer()) + .post('/graphql') + .send(queryData) + .expect(200) + .expect((res) => { + const data = res.body.data.findManyCompany; + expect(data).toBeDefined(); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBeGreaterThan(0); + + const company = data.find((c) => c.id === companyId); + expect(company).toBeDefined(); + expect(company).toHaveProperty('id'); + expect(company).toHaveProperty('name', 'New Company'); + expect(company).toHaveProperty('domainName', 'new-company.com'); + expect(company).toHaveProperty('address', 'New Address'); + + // Check if we have access to ressources outside of our workspace + const instagramCompany = data.find((c) => c.name === 'Instagram'); + expect(instagramCompany).toBeUndefined(); + }); + }); + + it('should find unique company', () => { + const queryData = { + query: ` + query FindUniqueCompany($where: CompanyWhereUniqueInput!) { + findUniqueCompany(where: $where) { + id + name + domainName + address + } + } + `, + variables: { + where: { + id: companyId, + }, + }, + }; + + return request(app.getHttpServer()) + .post('/graphql') + .send(queryData) + .expect(200) + .expect((res) => { + const data = res.body.data.findUniqueCompany; + + expect(data).toBeDefined(); + expect(data).toHaveProperty('id'); + expect(data).toHaveProperty('name', 'New Company'); + expect(data).toHaveProperty('domainName', 'new-company.com'); + expect(data).toHaveProperty('address', 'New Address'); + }); + }); + + it('should not find unique company (forbidden because outside workspace)', () => { + const queryData = { + query: ` + query FindUniqueCompany($where: CompanyWhereUniqueInput!) { + findUniqueCompany(where: $where) { + id + name + domainName + address + } + } + `, + variables: { + where: { + id: 'twenty-dev-a674fa6c-1455-4c57-afaf-dd5dc086361e', + }, + }, + }; + + return request(app.getHttpServer()) + .post('/graphql') + .send(queryData) + .expect(200) + .expect((res) => { + const errors = res.body.errors; + const error = errors?.[0]; + + expect(error).toBeDefined(); + expect(error.extensions.code).toBe('FORBIDDEN'); + expect(error.extensions.originalError.statusCode).toBe(403); + }); + }); + + it('should update a company', () => { + const queryData = { + query: ` + mutation UpdateOneCompany($where: CompanyWhereUniqueInput!, $data: CompanyUpdateInput!) { + updateOneCompany(data: $data, where: $where) { + id + name + domainName + address + } + } + `, + variables: { + where: { + id: companyId, + }, + data: { + name: 'Updated Company', + domainName: 'updated-company.com', + address: 'Updated Address', + }, + }, + }; + + return request(app.getHttpServer()) + .post('/graphql') + .send(queryData) + .expect(200) + .expect((res) => { + const data = res.body.data.updateOneCompany; + + expect(data).toBeDefined(); + expect(data).toHaveProperty('id'); + expect(data).toHaveProperty('name', 'Updated Company'); + expect(data).toHaveProperty('domainName', 'updated-company.com'); + expect(data).toHaveProperty('address', 'Updated Address'); + }); + }); + + it('should not update a company (forbidden because outside workspace)', () => { + const queryData = { + query: ` + mutation UpdateOneCompany($where: CompanyWhereUniqueInput!, $data: CompanyUpdateInput!) { + updateOneCompany(data: $data, where: $where) { + id + name + domainName + address + } + } + `, + variables: { + where: { + id: 'twenty-dev-a674fa6c-1455-4c57-afaf-dd5dc086361e', + }, + data: { + name: 'Updated Instagram', + }, + }, + }; + + return request(app.getHttpServer()) + .post('/graphql') + .send(queryData) + .expect(200) + .expect((res) => { + const errors = res.body.errors; + const error = errors?.[0]; + + expect(error).toBeDefined(); + expect(error.extensions.code).toBe('FORBIDDEN'); + expect(error.extensions.originalError.statusCode).toBe(403); + }); + }); + + it('should delete a company', () => { + const queryData = { + query: ` + mutation DeleteManyCompany($ids: [String!]) { + deleteManyCompany(where: {id: {in: $ids}}) { + count + } + } + `, + variables: { + ids: [companyId], + }, + }; + + return request(app.getHttpServer()) + .post('/graphql') + .send(queryData) + .expect(200) + .expect((res) => { + const data = res.body.data.deleteManyCompany; + + companyId = undefined; + + expect(data).toBeDefined(); + expect(data).toHaveProperty('count', 1); + }); + }); + + it('should not delete a company (forbidden because outside workspace)', () => { + const queryData = { + query: ` + mutation DeleteManyCompany($ids: [String!]) { + deleteManyCompany(where: {id: {in: $ids}}) { + count + } + } + `, + variables: { + ids: ['twenty-dev-a674fa6c-1455-4c57-afaf-dd5dc086361e'], + }, + }; + + return request(app.getHttpServer()) + .post('/graphql') + .send(queryData) + .expect(200) + .expect((res) => { + const errors = res.body.errors; + const error = errors?.[0]; + + expect(error).toBeDefined(); + expect(error.extensions.code).toBe('FORBIDDEN'); + expect(error.extensions.originalError.statusCode).toBe(403); + }); + }); +}); diff --git a/server/test/jest-e2e.json b/server/test/jest-e2e.json index e9d912f3e3cef..0456e6d75219e 100644 --- a/server/test/jest-e2e.json +++ b/server/test/jest-e2e.json @@ -3,6 +3,11 @@ "rootDir": ".", "testEnvironment": "node", "testRegex": ".e2e-spec.ts$", + "setupFilesAfterEnv": ["/utils/setup-tests.ts"], + "moduleNameMapper": { + "^src/(.*)": "/../src/$1", + "^test/(.*)": "/$1" + }, "transform": { "^.+\\.(t|j)s$": "ts-jest" } diff --git a/server/test/mock-data/user.json b/server/test/mock-data/user.json new file mode 100644 index 0000000000000..82807a8c9e9f0 --- /dev/null +++ b/server/test/mock-data/user.json @@ -0,0 +1,9 @@ +{ + "id": "twenty-ge256b39-3ec3-4fe3-8997-b76aa0bfc102", + "firstName": "Tim", + "lastName": "Apple", + "email": "tim@apple.dev", + "locale": "en", + "passwordHash": "$2b$10$66d.6DuQExxnrfI9rMqOg.U1XIYpagr6Lv05uoWLYbYmtK0HDIvS6", + "avatarUrl": null +} diff --git a/server/test/mock-data/workspace.json b/server/test/mock-data/workspace.json new file mode 100644 index 0000000000000..0d3e7e3758bea --- /dev/null +++ b/server/test/mock-data/workspace.json @@ -0,0 +1,7 @@ +{ + "id": "twenty-7ed9d212-1c25-4d02-bf25-6aeccf7ea419", + "displayName": "Apple", + "domainName": "apple.dev", + "inviteHash": "apple.dev-invite-hash", + "logo": "data:image/png;base64,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" +} diff --git a/server/test/utils/check-db.ts b/server/test/utils/check-db.ts new file mode 100644 index 0000000000000..d73833cfedc50 --- /dev/null +++ b/server/test/utils/check-db.ts @@ -0,0 +1,41 @@ +// check-db.ts + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +const schemaDatabaseExists = async (databaseName: string) => { + try { + const result = await prisma.$queryRawUnsafe<[any]>( + `SELECT 1 FROM pg_database WHERE datname = '${databaseName}';`, + ); + + return result.length > 0; + } catch { + return false; + } +}; + +async function main() { + const databaseName = 'tests'; + // Check if schema exists + const databaseExistsResult = await schemaDatabaseExists(databaseName); + + if (!databaseExistsResult) { + throw new Error(`Schema ${databaseName} does not exist`); + } + + // Check if database is initialized + await prisma.$queryRaw`SELECT 1 FROM pg_tables WHERE tablename='_prisma_migrations';`; +} + +main() + .then(() => { + process.exit(0); + }) + .catch(() => { + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/server/test/utils/create-app.ts b/server/test/utils/create-app.ts new file mode 100644 index 0000000000000..fc64d61a8fe08 --- /dev/null +++ b/server/test/utils/create-app.ts @@ -0,0 +1,56 @@ +import { NestExpressApplication } from '@nestjs/platform-express'; +import { Test, TestingModule, TestingModuleBuilder } from '@nestjs/testing'; + +import mockUser from 'test/mock-data/user.json'; +import mockWorkspace from 'test/mock-data/workspace.json'; +import { RequestHandler } from 'express'; + +import { AppModule } from 'src/app.module'; + +interface TestingModuleCreatePreHook { + (moduleBuilder: TestingModuleBuilder): TestingModuleBuilder; +} + +/** + * Hook for adding items to nest application + */ +export type TestingAppCreatePreHook = ( + app: NestExpressApplication, +) => Promise; + +/** + * Sets basic e2e testing module of app + */ +export async function createApp( + config: { + moduleBuilderHook?: TestingModuleCreatePreHook; + appInitHook?: TestingAppCreatePreHook; + } = {}, +): Promise<[NestExpressApplication, TestingModule]> { + let moduleBuilder: TestingModuleBuilder = Test.createTestingModule({ + imports: [AppModule], + }); + + if (!!config.moduleBuilderHook) { + moduleBuilder = config.moduleBuilderHook(moduleBuilder); + } + + const moduleFixture: TestingModule = await moduleBuilder.compile(); + const app = moduleFixture.createNestApplication(); + + if (config.appInitHook) { + await config.appInitHook(app); + } + + const mockAuthHandler: RequestHandler = (req, _res, next) => { + req.user = { + user: mockUser, + workspace: mockWorkspace, + }; + next(); + }; + + app.use(mockAuthHandler); + + return [await app.init(), moduleFixture]; +} diff --git a/server/test/utils/reset-db.ts b/server/test/utils/reset-db.ts new file mode 100644 index 0000000000000..2a7863eb39bd8 --- /dev/null +++ b/server/test/utils/reset-db.ts @@ -0,0 +1,18 @@ +import { PrismaClient, Prisma } from '@prisma/client'; + +import { camelCase } from 'src/utils/camel-case'; + +const prisma = new PrismaClient(); + +export default async () => { + const models = Prisma.dmmf.datamodel.models; + const modelNames = models.map((model) => model.name); + const entities = modelNames.map((modelName) => camelCase(modelName)); + + await prisma.$transaction( + entities.map((entity) => { + console.log('entity: ', entity); + return prisma[entity].deleteMany(); + }), + ); +}; diff --git a/server/test/utils/setup-tests.ts b/server/test/utils/setup-tests.ts new file mode 100644 index 0000000000000..bfd4116a6f1db --- /dev/null +++ b/server/test/utils/setup-tests.ts @@ -0,0 +1,5 @@ +import resetDb from './reset-db'; + +global.beforeEach(() => { + // resetDb(); +}); diff --git a/server/yarn.lock b/server/yarn.lock index e483ddf10d761..82136fca7e4aa 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -1711,9 +1711,9 @@ integrity sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA== "@mapbox/node-pre-gyp@^1.0.10": - version "1.0.10" - resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.10.tgz#8e6735ccebbb1581e5a7e652244cadc8a844d03c" - integrity sha512-4ySo4CjzStuprMwk35H5pPbkymjv1SF3jGLj6rAHp/xT/RF7TL7bd9CTm1xDY49K2qF7jmR/g7k+SkLETP6opA== + version "1.0.11" + resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz#417db42b7f5323d79e93b34a6d7a2a12c0df43fa" + integrity sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ== dependencies: detect-libc "^2.0.0" https-proxy-agent "^5.0.0" @@ -5532,16 +5532,11 @@ graphql-ws@5.13.1: resolved "https://registry.npmjs.org/graphql-ws/-/graphql-ws-5.13.1.tgz" integrity sha512-eiX7ES/ZQr0q7hSM5UBOEIFfaAUmAY9/CSDyAnsETuybByU7l/v46drRg9DQoTvVABEHp3QnrvwgTRMhqy7zxQ== -"graphql@0.13.1 - 16": +"graphql@0.13.1 - 16", graphql@^16.7.1: version "16.7.1" resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.7.1.tgz#11475b74a7bff2aefd4691df52a0eca0abd9b642" integrity sha512-DRYR9tf+UGU0KOsMcKAlXeFfX89UiiIZ0dRU3mR0yJfu6OjZqUcp68NnFLnqQU5RexygFoDy1EW+ccOYcPfmHg== -graphql@^16.6.0: - version "16.6.0" - resolved "https://registry.npmjs.org/graphql/-/graphql-16.6.0.tgz" - integrity sha512-KPIBPDlW7NxrbT/eh4qPXz5FiFdL5UbaA0XUNz2Rp3Z3hqBSkbj0GVjwFDztsWVauZUWsbKHgMg++sk8UX0bkw== - has-bigints@^1.0.1, has-bigints@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa"