diff --git a/.github/workflows/pr-api-e2e.yml b/.github/workflows/pr-api-e2e.yml new file mode 100644 index 0000000000..910cb37e11 --- /dev/null +++ b/.github/workflows/pr-api-e2e.yml @@ -0,0 +1,64 @@ +name: API E2E Testing + +on: + push: + branches: + - master + pull_request: + +jobs: + # Label of the container job + container-job: + # Containers must run in Linux based operating systems + runs-on: ubuntu-latest + # Docker Hub image that `container-job` executes in + # container: node:20 + + # Service containers to run with `container-job` + services: + # Label used to access the service container + postgres: + image: postgres:13 + env: + POSTGRES_USER: prisma + POSTGRES_PASSWORD: prisma + POSTGRES_DB: tests + ports: + - 5434:5432 + # Set health checks to wait until postgres has started + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + # Downloads a copy of the code in your repository before running CI tests + - name: Check out repository code + uses: actions/checkout@v3 + - name: Use Node.js ${{ env.NODE_VERSION }} on ${{ matrix.os }} + uses: actions/setup-node@v1 + with: + node-version: ${{ env.NODE_VERSION }} + - name: restore lerna + uses: actions/cache@v2 + with: + path: | + node_modules + */*/node_modules + key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} + - run: yarn --frozen-lockfile --network-timeout 1000000 + - name: Build apps (with retries) + uses: nick-invision/retry@v2 + with: + timeout_minutes: 15 + max_attempts: 3 + command: yarn build:ci + # - name: Update env file + # run: | + # echo DATABASE_URL="postgresql://prisma:prisma@postgres:5434/tests?schema=public" >> packages/altair-api/.env.e2e + # cat packages/altair-api/.env.e2e + - name: Migration + run: yarn --cwd packages/altair-api migrate:e2e + - name: Run E2E + run: yarn --cwd packages/altair-api test:e2e diff --git a/.github/workflows/pr-e2e.yml b/.github/workflows/pr-e2e.yml index c1ae0c9b8b..e6e4bec359 100644 --- a/.github/workflows/pr-e2e.yml +++ b/.github/workflows/pr-e2e.yml @@ -17,28 +17,28 @@ jobs: os: [macos-latest, ubuntu-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v2 - - name: Use Node.js ${{ env.NODE_VERSION }} on ${{ matrix.os }} - uses: actions/setup-node@v1 - with: - node-version: ${{ env.NODE_VERSION }} - - name: restore lerna - uses: actions/cache@v2 - with: - path: | - node_modules - */*/node_modules - key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} - - run: yarn --frozen-lockfile --network-timeout 1000000 - - uses: browser-actions/setup-chrome@latest - - name: Build apps (with retries) - uses: nick-invision/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 3 - command: yarn build:ci - - run: npx playwright install - - name: Run headless e2e test - uses: GabrielBB/xvfb-action@v1 - with: - run: yarn playwright test --retries 2 + - uses: actions/checkout@v2 + - name: Use Node.js ${{ env.NODE_VERSION }} on ${{ matrix.os }} + uses: actions/setup-node@v1 + with: + node-version: ${{ env.NODE_VERSION }} + - name: restore lerna + uses: actions/cache@v2 + with: + path: | + node_modules + */*/node_modules + key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} + - run: yarn --frozen-lockfile --network-timeout 1000000 + - uses: browser-actions/setup-chrome@latest + - name: Build apps (with retries) + uses: nick-invision/retry@v2 + with: + timeout_minutes: 15 + max_attempts: 3 + command: yarn build:ci + - run: npx playwright install + - name: Run headless e2e test + uses: GabrielBB/xvfb-action@v1 + with: + run: yarn playwright test --retries 2 diff --git a/package.json b/package.json index 9ec8770ab5..606150f971 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "chalk": "^4.1.0", "compare-versions": "^4.1.3", "cwex": "^1.0.4", + "dotenv-cli": "^7.2.1", "eslint": "8.18.0", "eslint-config-altair": "^5.0.22", "eslint-config-prettier": "8.5.0", diff --git a/packages/altair-api/.env.e2e b/packages/altair-api/.env.e2e new file mode 100644 index 0000000000..78ac920fa1 --- /dev/null +++ b/packages/altair-api/.env.e2e @@ -0,0 +1,14 @@ +JWT_ACCESS_SECRET=super-secret-access-secret +EVENTS_JWT_ACCESS_SECRET=events-jwt-access-secret +JWT_REFRESH_SECRET=jwt-refresh-secret +GOOGLE_OAUTH_CLIENT_ID=test.apps.googleusercontent.com +GOOGLE_OAUTH_CLIENT_SECRET=GS-53CR37 +POSTGRES_DB=tests +POSTGRES_USER=prisma +POSTGRES_PASSWORD=prisma +DATABASE_URL=postgresql://prisma:prisma@localhost:5434/tests?schema=public +NEW_RELIC_APP_NAME=altairgraphql.test +NEW_RELIC_LICENSE_KEY=test-key +NEW_RELIC_APPLICATION_LOGGING_FORWARDING_ENABLED=true +STRIPE_SECRET_KEY=sk_test_xxx +STRIPE_WEBHOOK_SECRET= diff --git a/packages/altair-api/bin/e2e.sh b/packages/altair-api/bin/e2e.sh new file mode 100755 index 0000000000..a89c154e33 --- /dev/null +++ b/packages/altair-api/bin/e2e.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +# set -euo pipefail + +SCRIPT_DIR=$(dirname "$0") + +# start e2e test database +docker compose -f "$SCRIPT_DIR/../docker-compose.e2e.yml" up -d --wait + +# migrate e2e test database +yarn migrate:e2e + +# run e2e test +yarn test:e2e --watch + +# shutdown e2e test database +docker compose -f "$SCRIPT_DIR/../docker-compose.e2e.yml" down -v diff --git a/packages/altair-api/docker-compose.e2e.yml b/packages/altair-api/docker-compose.e2e.yml new file mode 100644 index 0000000000..9b430f5dca --- /dev/null +++ b/packages/altair-api/docker-compose.e2e.yml @@ -0,0 +1,25 @@ +# Set the version of docker compose to use +version: '3.9' + +# The containers that compose the project +services: + db: + image: postgres:13 + restart: always + container_name: integration-tests-prisma + ports: + - '5434:5432' + volumes: + - postgres-e2e:/var/lib/postgresql/data-e2e + environment: + POSTGRES_USER: prisma + POSTGRES_PASSWORD: prisma + POSTGRES_DB: tests + networks: + - test-network + +networks: + test-network: + +volumes: + postgres-e2e: \ No newline at end of file diff --git a/packages/altair-api/package.json b/packages/altair-api/package.json index dc87f9ea14..f5d41da310 100644 --- a/packages/altair-api/package.json +++ b/packages/altair-api/package.json @@ -53,6 +53,7 @@ "eslint-config-prettier": "^8.3.0", "eslint-plugin-prettier": "^4.0.0", "jest": "29.4.1", + "passport-custom": "^1.1.1", "pino-pretty": "^9.2.0", "prettier": "^2.3.2", "prettier-config-altair": "^5.0.25", @@ -79,10 +80,11 @@ "start:debug": "nest start --debug --watch", "start:dev": "nest start --watch", "start:prod": "node dist/main", + "migrate:e2e": "dotenv -e .env.e2e -- prisma migrate deploy --schema ../altair-db/prisma/schema.prisma", "test": "jest", "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": "dotenv -e .env.e2e -- jest --config ./test/jest-e2e.config.js --detectOpenHandles --forceExit", "test:watch": "jest --watch" } } diff --git a/packages/altair-api/src/app-bootstrap.ts b/packages/altair-api/src/app-bootstrap.ts new file mode 100644 index 0000000000..4972ada87c --- /dev/null +++ b/packages/altair-api/src/app-bootstrap.ts @@ -0,0 +1,58 @@ +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { HttpAdapterHost } from '@nestjs/core'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { Logger, LoggerErrorInterceptor } from 'nestjs-pino'; +import { PrismaClientExceptionFilter, PrismaService } from 'nestjs-prisma'; +import { CorsConfig, SwaggerConfig } from './common/config'; +import { NewrelicInterceptor } from './newrelic/newrelic.interceptor'; + +export const bootstrapApp = async (app: INestApplication) => { + // Logger + if (process.env.NODE_ENV === 'production') { + // Use pino logger in production + app.useLogger(app.get(Logger)); + } + + // Validation + app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); + + // Interceptors + if (process.env.NODE_ENV === 'production') { + app.useGlobalInterceptors(new NewrelicInterceptor(app.get(Logger))); + } + if (process.env.NODE_ENV === 'production') { + app.useGlobalInterceptors(new LoggerErrorInterceptor()); + } + + // enable shutdown hook + const prismaService: PrismaService = app.get(PrismaService); + await prismaService.enableShutdownHooks(app); + + // Prisma Client Exception Filter for unhandled exceptions + const { httpAdapter } = app.get(HttpAdapterHost); + app.useGlobalFilters(new PrismaClientExceptionFilter(httpAdapter)); + + const configService = app.get(ConfigService); + const corsConfig = configService.get('cors'); + const swaggerConfig = configService.get('swagger'); + + // Swagger Api + if (swaggerConfig.enabled) { + const options = new DocumentBuilder() + .setTitle(swaggerConfig.title || 'Altair') + .setDescription(swaggerConfig.description || 'The Altair API description') + .setVersion(swaggerConfig.version || '1.0') + .build(); + const document = SwaggerModule.createDocument(app, options); + + SwaggerModule.setup(swaggerConfig.path || 'swagger', app, document); + } + + // Cors + if (corsConfig.enabled) { + app.enableCors(); + } + + return app; +}; diff --git a/packages/altair-api/src/app.controller.ts b/packages/altair-api/src/app.controller.ts index 8a1f4fcaac..3bd35e8f7f 100644 --- a/packages/altair-api/src/app.controller.ts +++ b/packages/altair-api/src/app.controller.ts @@ -1,4 +1,12 @@ -import { Controller, Get, Req, Res, Sse, UseGuards } from '@nestjs/common'; +import { + Controller, + Get, + OnModuleDestroy, + Req, + Res, + Sse, + UseGuards, +} from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { Request, Response } from 'express'; import { PrismaService } from 'nestjs-prisma'; @@ -24,11 +32,11 @@ export class AppController { @Sse('events') handleUserEvents(@Req() req: Request): Observable { const subject$ = new Subject(); - const userId = req.user.id; + const userId = req?.user?.id; // TODO: Create events // TODO: Emit events from prisma middleware - this.eventService.on([EVENTS.COLLECTION_UPDATE], async ({ id }) => { + const collectionUpdateListener = async ({ id }: any) => { const validUserCollection = await this.prisma.queryCollection.findFirst({ select: { id: true, @@ -60,8 +68,10 @@ export class AppController { if (validUserCollection) { subject$.next({ uid: userId, collectionId: id }); } - }); - this.eventService.on([EVENTS.QUERY_UPDATE], async ({ id }) => { + }; + this.eventService.on([EVENTS.COLLECTION_UPDATE], collectionUpdateListener); + + const queryUpdateListener = async ({ id }: any) => { // check query workspace owner const validQueryItem = await this.prisma.queryItem.findFirst({ where: { @@ -93,9 +103,10 @@ export class AppController { }, }); if (validQueryItem) { - subject$.next({ uid: req.user.id, queryId: id }); + subject$.next({ uid: req?.user?.id, queryId: id }); } - }); + }; + this.eventService.on([EVENTS.QUERY_UPDATE], queryUpdateListener); return subject$.pipe(map((data) => ({ data }))); } diff --git a/packages/altair-api/src/app.module.ts b/packages/altair-api/src/app.module.ts index 3efeee0495..776d3b8ab8 100644 --- a/packages/altair-api/src/app.module.ts +++ b/packages/altair-api/src/app.module.ts @@ -16,12 +16,14 @@ import { StripeModule } from './stripe/stripe.module'; import { StripeWebhookController } from './stripe-webhook/stripe-webhook.controller'; import { WorkspacesModule } from './workspaces/workspaces.module'; -// eslint-disable-next-line @typescript-eslint/no-var-requires -const newrelicPino = require('@newrelic/pino-enricher'); +if (process.env.NEW_RELIC_APP_NAME && process.env.NODE_ENV === 'production') { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const newrelicPino = require('@newrelic/pino-enricher'); +} @Module({ imports: [ - LoggerModule.forRoot(), + ...(process.env.NODE_ENV !== 'test' ? [LoggerModule.forRoot()] : []), ConfigModule.forRoot({ isGlobal: true, load: [config] }), PrismaModule.forRoot({ isGlobal: true, @@ -29,7 +31,9 @@ const newrelicPino = require('@newrelic/pino-enricher'); middlewares: [], // configure your prisma middleware }, }), - EventEmitterModule.forRoot(), + EventEmitterModule.forRoot({ + verboseMemoryLeak: true, + }), AuthModule, QueriesModule, QueryCollectionsModule, diff --git a/packages/altair-api/src/auth/auth.controller.ts b/packages/altair-api/src/auth/auth.controller.ts index 01aa6102ee..9de423293d 100644 --- a/packages/altair-api/src/auth/auth.controller.ts +++ b/packages/altair-api/src/auth/auth.controller.ts @@ -51,6 +51,11 @@ export class AuthController { @Get('slt') @UseGuards(JwtAuthGuard) getShortlivedEventsToken(@Req() req: Request) { - return { slt: this.authService.getShortLivedEventsToken(req.user.id) }; + const userId = req?.user?.id; + if (!userId) { + throw new BadRequestException('User not found'); + } + + return { slt: this.authService.getShortLivedEventsToken(userId) }; } } diff --git a/packages/altair-api/src/auth/auth.module.ts b/packages/altair-api/src/auth/auth.module.ts index d7f0f99177..fdecf97e1b 100644 --- a/packages/altair-api/src/auth/auth.module.ts +++ b/packages/altair-api/src/auth/auth.module.ts @@ -25,7 +25,7 @@ import { QueryCollectionsService } from 'src/query-collections/query-collections return { secret: configService.get('JWT_ACCESS_SECRET'), signOptions: { - expiresIn: securityConfig.expiresIn, + expiresIn: securityConfig?.expiresIn, }, }; }, diff --git a/packages/altair-api/src/auth/auth.service.ts b/packages/altair-api/src/auth/auth.service.ts index 9227e8964e..184514f235 100644 --- a/packages/altair-api/src/auth/auth.service.ts +++ b/packages/altair-api/src/auth/auth.service.ts @@ -32,7 +32,7 @@ export class AuthService { const passwordValid = await this.passwordService.validatePassword( password, - user.password + user.password ?? '' ); if (!passwordValid) { @@ -59,17 +59,17 @@ export class AuthService { }); } - validateUser(userId: string): Promise { + validateUser(userId: string): Promise { return this.prisma.user.findUnique({ where: { id: userId } }); } - getUserFromToken(token: string): Promise { + getUserFromToken(token: string): Promise { const decoded = this.jwtService.decode(token); if (typeof decoded === 'string') { throw new Error('Invalid JWT token'); } - const id = decoded['userId']; + const id = decoded?.['userId']; return this.prisma.user.findUnique({ where: { id } }); } @@ -126,7 +126,7 @@ export class AuthService { { userId }, { secret: this.configService.get('EVENTS_JWT_ACCESS_SECRET'), - expiresIn: securityConfig.shortExpiresIn, + expiresIn: securityConfig?.shortExpiresIn, } ); } @@ -139,7 +139,7 @@ export class AuthService { const securityConfig = this.configService.get('security'); return this.jwtService.sign(payload, { secret: this.configService.get('JWT_REFRESH_SECRET'), - expiresIn: securityConfig.refreshIn, + expiresIn: securityConfig?.refreshIn, }); } diff --git a/packages/altair-api/src/auth/password/password.service.ts b/packages/altair-api/src/auth/password/password.service.ts index 26455c0139..76739e08b1 100644 --- a/packages/altair-api/src/auth/password/password.service.ts +++ b/packages/altair-api/src/auth/password/password.service.ts @@ -9,7 +9,7 @@ export class PasswordService { get bcryptSaltRounds(): string | number { const securityConfig = this.configService.get('security'); - const saltOrRounds = securityConfig.bcryptSaltOrRound; + const saltOrRounds = securityConfig?.bcryptSaltOrRound ?? 10; return Number.isInteger(Number(saltOrRounds)) ? Number(saltOrRounds) diff --git a/packages/altair-api/src/auth/strategies/google.strategy.ts b/packages/altair-api/src/auth/strategies/google.strategy.ts index 722dca7b2a..80858a7969 100644 --- a/packages/altair-api/src/auth/strategies/google.strategy.ts +++ b/packages/altair-api/src/auth/strategies/google.strategy.ts @@ -52,12 +52,19 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { IdentityProvider.GOOGLE ); if (!identity) { + const email = profile.emails?.[0]?.value; + if (!email) { + throw new UnauthorizedException( + 'Google OAuth did not return an email address' + ); + } + return this.userService.createUser( { - email: profile.emails[0].value, - firstName: profile.name.givenName || profile.displayName, - lastName: profile.name.familyName, - picture: profile.photos[0].value, + email, + firstName: profile?.name?.givenName || profile.displayName, + lastName: profile?.name?.familyName, + picture: profile?.photos?.[0]?.value, }, { provider: IdentityProvider.GOOGLE, diff --git a/packages/altair-api/src/auth/user/user.controller.ts b/packages/altair-api/src/auth/user/user.controller.ts index a9b9084504..b621c7b164 100644 --- a/packages/altair-api/src/auth/user/user.controller.ts +++ b/packages/altair-api/src/auth/user/user.controller.ts @@ -19,30 +19,29 @@ export class UserController { @Get('billing') @UseGuards(JwtAuthGuard) async getBillingUrl(@Req() req: Request) { + const userId = req?.user?.id ?? ''; return { - url: await this.userService.getBillingUrl( - req.user.id, - req.headers.referer - ), + url: await this.userService.getBillingUrl(userId, req.headers.referer), }; } @Get('plan') @UseGuards(JwtAuthGuard) async getCurrentPlan(@Req() req: Request): Promise { - const cfg = await this.userService.getPlanConfig(req.user.id); + const userId = req?.user?.id ?? ''; + const cfg = await this.userService.getPlanConfig(userId); return { - max_query_count: cfg.maxQueryCount, - max_team_count: cfg.maxTeamCount, - max_team_member_count: cfg.maxTeamMemberCount, + max_query_count: cfg?.maxQueryCount ?? 0, + max_team_count: cfg?.maxTeamCount ?? 0, + max_team_member_count: cfg?.maxTeamMemberCount ?? 0, }; } @Get('stats') @UseGuards(JwtAuthGuard) async getStats(@Req() req: Request) { - const userId = req.user.id; + const userId = req?.user?.id ?? ''; return { queries: { diff --git a/packages/altair-api/src/auth/user/user.service.ts b/packages/altair-api/src/auth/user/user.service.ts index afc716e31c..18993c4467 100644 --- a/packages/altair-api/src/auth/user/user.service.ts +++ b/packages/altair-api/src/auth/user/user.service.ts @@ -64,6 +64,14 @@ export class UserService { return this.prisma.user.findUnique({ where: { id: userId } }); } + async mustGetUser(userId: string) { + const user = await this.getUser(userId); + if (!user) { + throw new Error(`User not found for id: ${userId}`); + } + return user; + } + getUserByStripeCustomerId(stripeCustomerId: string) { return this.prisma.user.findFirst({ where: { @@ -115,18 +123,25 @@ export class UserService { } async updateAllowedTeamMemberCount(userId: string, quantity: number) { - const user = await this.getUser(userId); + const user = await this.mustGetUser(userId); + // Check plan config const planConfig = await this.getPlanConfig(userId); // if allow additional team members - if (!planConfig.allowMoreTeamMembers) { + if (!planConfig?.allowMoreTeamMembers) { this.logger.warn( - `Cannot update allowed team member count since allowMoreTeamMembers is not enabled for this plan config (${planConfig.id})` + `Cannot update allowed team member count since allowMoreTeamMembers is not enabled for this plan config (${planConfig?.id})` ); return; } // update stripe subscription quantity + if (!user.stripeCustomerId) { + throw new Error( + `Cannot update subscription quantity since user (${userId}) does not have a stripe customer ID` + ); + } + await this.stripeService.updateSubscriptionQuantity( user.stripeCustomerId, quantity @@ -144,7 +159,7 @@ export class UserService { } async getStripeCustomerId(userId: string) { - const user = await this.getUser(userId); + const user = await this.mustGetUser(userId); if (user.stripeCustomerId) { return user.stripeCustomerId; diff --git a/packages/altair-api/src/main.ts b/packages/altair-api/src/main.ts index f6cb85bbaa..930c0d38f3 100644 --- a/packages/altair-api/src/main.ts +++ b/packages/altair-api/src/main.ts @@ -1,15 +1,13 @@ -// eslint-disable-next-line @typescript-eslint/no-var-requires -require('newrelic'); +if (process.env.NODE_ENV === 'production') { + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('newrelic'); +} -import { ValidationPipe } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { HttpAdapterHost, NestFactory } from '@nestjs/core'; -import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; -import { Logger, LoggerErrorInterceptor } from 'nestjs-pino'; -import { PrismaClientExceptionFilter, PrismaService } from 'nestjs-prisma'; +import { NestFactory } from '@nestjs/core'; +import { bootstrapApp } from './app-bootstrap'; import { AppModule } from './app.module'; -import { CorsConfig, NestConfig, SwaggerConfig } from './common/config'; -import { NewrelicInterceptor } from './newrelic/newrelic.interceptor'; +import { NestConfig } from './common/config'; async function bootstrap() { const app = await NestFactory.create(AppModule, { @@ -17,49 +15,10 @@ async function bootstrap() { rawBody: true, }); - // Logger - if (process.env.NODE_ENV === 'production') { - // Use pino logger in production - app.useLogger(app.get(Logger)); - } - - // Validation - app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); - - // Interceptors - app.useGlobalInterceptors(new NewrelicInterceptor()); - app.useGlobalInterceptors(new LoggerErrorInterceptor()); - - // enable shutdown hook - const prismaService: PrismaService = app.get(PrismaService); - await prismaService.enableShutdownHooks(app); - - // Prisma Client Exception Filter for unhandled exceptions - const { httpAdapter } = app.get(HttpAdapterHost); - app.useGlobalFilters(new PrismaClientExceptionFilter(httpAdapter)); + await bootstrapApp(app); const configService = app.get(ConfigService); const nestConfig = configService.get('nest'); - const corsConfig = configService.get('cors'); - const swaggerConfig = configService.get('swagger'); - - // Swagger Api - if (swaggerConfig.enabled) { - const options = new DocumentBuilder() - .setTitle(swaggerConfig.title || 'Altair') - .setDescription(swaggerConfig.description || 'The Altair API description') - .setVersion(swaggerConfig.version || '1.0') - .build(); - const document = SwaggerModule.createDocument(app, options); - - SwaggerModule.setup(swaggerConfig.path || 'swagger', app, document); - } - - // Cors - if (corsConfig.enabled) { - app.enableCors(); - } - const port = process.env.PORT || nestConfig.port; console.log('Listening on port', port); await app.listen(port); diff --git a/packages/altair-api/src/newrelic/newrelic.interceptor.spec.ts b/packages/altair-api/src/newrelic/newrelic.interceptor.spec.ts index 210e3a08ee..0a7425b395 100644 --- a/packages/altair-api/src/newrelic/newrelic.interceptor.spec.ts +++ b/packages/altair-api/src/newrelic/newrelic.interceptor.spec.ts @@ -1,7 +1,9 @@ +import { ConsoleLogger } from '@nestjs/common'; import { NewrelicInterceptor } from './newrelic.interceptor'; describe('NewrelicInterceptor', () => { it('should be defined', () => { - expect(new NewrelicInterceptor()).toBeDefined(); + const logger = new ConsoleLogger(); + expect(new NewrelicInterceptor(logger)).toBeDefined(); }); }); diff --git a/packages/altair-api/src/newrelic/newrelic.interceptor.ts b/packages/altair-api/src/newrelic/newrelic.interceptor.ts index 86de01bab7..ef5aa5dd0f 100644 --- a/packages/altair-api/src/newrelic/newrelic.interceptor.ts +++ b/packages/altair-api/src/newrelic/newrelic.interceptor.ts @@ -2,6 +2,7 @@ import { CallHandler, ExecutionContext, Injectable, + LoggerService, NestInterceptor, } from '@nestjs/common'; import { Observable, tap } from 'rxjs'; @@ -9,20 +10,22 @@ import { inspect } from 'util'; @Injectable() export class NewrelicInterceptor implements NestInterceptor { + constructor(private readonly logger: LoggerService) {} intercept(context: ExecutionContext, next: CallHandler): Observable { + const log = this.logger; if (!process.env.NEW_RELIC_APP_NAME) { return next.handle(); } // eslint-disable-next-line @typescript-eslint/no-var-requires const newrelic = require('newrelic'); - console.log( + this.logger.log( `Newrelic Interceptor before: ${inspect(context.getHandler().name)}` ); return newrelic.startWebTransaction(context.getHandler().name, function () { const transaction = newrelic.getTransaction(); return next.handle().pipe( tap(() => { - console.log( + log.log( `Newrelic Interceptor after: ${inspect(context.getHandler().name)}` ); return transaction.end(); diff --git a/packages/altair-api/src/queries/dto/create-query.dto.ts b/packages/altair-api/src/queries/dto/create-query.dto.ts index 13082b13f5..0158c93c50 100644 --- a/packages/altair-api/src/queries/dto/create-query.dto.ts +++ b/packages/altair-api/src/queries/dto/create-query.dto.ts @@ -5,16 +5,16 @@ import { Allow, IsString } from 'class-validator'; export class CreateQueryDto implements ICreateQueryDto { @IsString() @ApiProperty() - name: string; + name!: string; // TODO: Define stricter validator @Allow() @ApiProperty() - content: IQueryContentDto; + content!: IQueryContentDto; @IsString() @ApiProperty() - collectionId: string; + collectionId!: string; } export class CreateQuerySansCollectionIdDto extends OmitType(CreateQueryDto, [ diff --git a/packages/altair-api/src/queries/queries.controller.ts b/packages/altair-api/src/queries/queries.controller.ts index a8938016b7..8532e13cb8 100644 --- a/packages/altair-api/src/queries/queries.controller.ts +++ b/packages/altair-api/src/queries/queries.controller.ts @@ -8,6 +8,7 @@ import { Delete, UseGuards, Req, + NotFoundException, } from '@nestjs/common'; import { QueriesService } from './queries.service'; import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard'; @@ -22,30 +23,47 @@ export class QueriesController { @Post() create(@Req() req: Request, @Body() createQueryDto: CreateQueryDto) { - return this.queriesService.create(req.user.id, createQueryDto); + const userId = req?.user?.id ?? ''; + return this.queriesService.create(userId, createQueryDto); } @Get() findAll(@Req() req: Request) { - return this.queriesService.findAll(req.user.id); + const userId = req?.user?.id ?? ''; + return this.queriesService.findAll(userId); } @Get(':id') findOne(@Req() req: Request, @Param('id') id: string) { - return this.queriesService.findOne(req.user.id, id); + const userId = req?.user?.id ?? ''; + return this.queriesService.findOne(userId, id); } @Patch(':id') - update( + async update( @Req() req: Request, @Param('id') id: string, @Body() updateQueryDto: UpdateQueryDto ) { - return this.queriesService.update(req.user.id, id, updateQueryDto); + const userId = req?.user?.id ?? ''; + const res = await this.queriesService.update(userId, id, updateQueryDto); + + if (!res.count) { + throw new NotFoundException(); + } + + return res; } @Delete(':id') - remove(@Req() req: Request, @Param('id') id: string) { - return this.queriesService.remove(req.user.id, id); + async remove(@Req() req: Request, @Param('id') id: string) { + const userId = req?.user?.id ?? ''; + const res = await this.queriesService.remove(userId, id); + + if (!res.count) { + throw new NotFoundException(); + } + + return res; } } diff --git a/packages/altair-api/src/queries/queries.service.ts b/packages/altair-api/src/queries/queries.service.ts index f2fad4ceb9..410ef740ba 100644 --- a/packages/altair-api/src/queries/queries.service.ts +++ b/packages/altair-api/src/queries/queries.service.ts @@ -1,5 +1,10 @@ import { Prisma } from '@altairgraphql/db'; -import { ForbiddenException, Injectable } from '@nestjs/common'; +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { PrismaService } from 'nestjs-prisma'; import { UserService } from 'src/auth/user/user.service'; @@ -18,6 +23,7 @@ export class QueriesService { async create(userId: string, createQueryDto: CreateQueryDto) { const userPlanConfig = await this.userService.getPlanConfig(userId); + const userPlanMaxQueryCount = userPlanConfig?.maxQueryCount ?? 0; const queryCount = await this.prisma.queryItem.count({ where: { collection: { @@ -27,10 +33,21 @@ export class QueriesService { }, }, }); - if (queryCount >= userPlanConfig.maxQueryCount) { + if (queryCount >= userPlanMaxQueryCount) { throw new InvalidRequestException('ERR_MAX_QUERY_COUNT'); } + // TODO: validate the query content using class-validator + if ( + !createQueryDto.collectionId || + !createQueryDto.name || + !createQueryDto.content || + !createQueryDto.content.query || + createQueryDto.content.version !== 1 + ) { + throw new BadRequestException(); + } + // specified collection is owned by the user const validCollection = await this.prisma.queryCollection.findMany({ where: { @@ -70,13 +87,19 @@ export class QueriesService { }); } - findOne(userId: string, id: string) { - return this.prisma.queryItem.findFirst({ + async findOne(userId: string, id: string) { + const query = await this.prisma.queryItem.findFirst({ where: { id, ...this.ownerOrMemberWhere(userId), }, }); + + if (!query) { + throw new NotFoundException(); + } + + return query; } async update(userId: string, id: string, updateQueryDto: UpdateQueryDto) { diff --git a/packages/altair-api/src/query-collections/dto/create-query-collection.dto.ts b/packages/altair-api/src/query-collections/dto/create-query-collection.dto.ts index 84e9cb81b8..25727e0425 100644 --- a/packages/altair-api/src/query-collections/dto/create-query-collection.dto.ts +++ b/packages/altair-api/src/query-collections/dto/create-query-collection.dto.ts @@ -13,7 +13,7 @@ export class CreateQueryCollectionDto implements ICreateQueryCollectionDto { @IsString() @IsNotEmpty() @ApiProperty() - name: string; + name!: string; @IsOptional() @ValidateNested() diff --git a/packages/altair-api/src/query-collections/query-collections.controller.ts b/packages/altair-api/src/query-collections/query-collections.controller.ts index 5d81da27ff..2f63108856 100644 --- a/packages/altair-api/src/query-collections/query-collections.controller.ts +++ b/packages/altair-api/src/query-collections/query-collections.controller.ts @@ -8,6 +8,7 @@ import { Delete, Req, UseGuards, + NotFoundException, } from '@nestjs/common'; import { QueryCollectionsService } from './query-collections.service'; import { Request } from 'express'; @@ -27,37 +28,59 @@ export class QueryCollectionsController { @Req() req: Request, @Body() createQueryCollectionDto: CreateQueryCollectionDto ) { + const userId = req?.user?.id ?? ''; return this.queryCollectionsService.create( - req.user.id, + userId, createQueryCollectionDto ); } @Get() findAll(@Req() req: Request) { - return this.queryCollectionsService.findAll(req.user.id); + const userId = req?.user?.id ?? ''; + return this.queryCollectionsService.findAll(userId); } @Get(':id') - findOne(@Req() req: Request, @Param('id') id: string) { - return this.queryCollectionsService.findOne(req.user.id, id); + async findOne(@Req() req: Request, @Param('id') id: string) { + const userId = req?.user?.id ?? ''; + const collection = await this.queryCollectionsService.findOne(userId, id); + + if (!collection) { + throw new NotFoundException(); + } + + return collection; } @Patch(':id') - update( + async update( @Req() req: Request, @Param('id') id: string, @Body() updateQueryCollectionDto: UpdateQueryCollectionDto ) { - return this.queryCollectionsService.update( - req.user.id, + const userId = req?.user?.id ?? ''; + const res = await this.queryCollectionsService.update( + userId, id, updateQueryCollectionDto ); + + if (!res.count) { + throw new NotFoundException(); + } + + return res; } @Delete(':id') - remove(@Req() req: Request, @Param('id') id: string) { - return this.queryCollectionsService.remove(req.user.id, id); + async remove(@Req() req: Request, @Param('id') id: string) { + const userId = req?.user?.id ?? ''; + const res = await this.queryCollectionsService.remove(userId, id); + if (!res.count) { + throw new NotFoundException(); + } + + return res; } } diff --git a/packages/altair-api/src/query-collections/query-collections.service.ts b/packages/altair-api/src/query-collections/query-collections.service.ts index c29a2427df..b95a4ddcef 100644 --- a/packages/altair-api/src/query-collections/query-collections.service.ts +++ b/packages/altair-api/src/query-collections/query-collections.service.ts @@ -25,6 +25,7 @@ export class QueryCollectionsService { let workspaceId = createQueryCollectionDto.workspaceId; const teamId = createQueryCollectionDto.teamId; const userPlanConfig = await this.userService.getPlanConfig(userId); + const userPlanMaxQueryCount = userPlanConfig?.maxQueryCount ?? 0; const userWorkspace = await this.prisma.workspace.findFirst({ where: { ownerId: userId, @@ -32,7 +33,7 @@ export class QueryCollectionsService { }); if (!workspaceId) { - workspaceId = userWorkspace.id; + workspaceId = userWorkspace?.id; if (teamId) { // check team workspace @@ -52,7 +53,7 @@ export class QueryCollectionsService { }, }); - workspaceId = teamWorkspace.id; + workspaceId = teamWorkspace?.id; } } @@ -86,9 +87,11 @@ export class QueryCollectionsService { }, }); + const createQueryCollectionDtoQueries = + createQueryCollectionDto.queries || []; if ( - queryItems.length + createQueryCollectionDto.queries.length > - userPlanConfig.maxQueryCount + queryItems.length + createQueryCollectionDtoQueries.length > + userPlanMaxQueryCount ) { throw new InvalidRequestException('ERR_MAX_QUERY_COUNT'); } @@ -98,7 +101,7 @@ export class QueryCollectionsService { name: createQueryCollectionDto.name, workspaceId, queries: { - create: createQueryCollectionDto.queries, + create: createQueryCollectionDtoQueries, }, }, }); diff --git a/packages/altair-api/src/stripe-webhook/stripe-webhook.controller.ts b/packages/altair-api/src/stripe-webhook/stripe-webhook.controller.ts index 9c7a5946a8..1f8d6a4d44 100644 --- a/packages/altair-api/src/stripe-webhook/stripe-webhook.controller.ts +++ b/packages/altair-api/src/stripe-webhook/stripe-webhook.controller.ts @@ -33,7 +33,7 @@ export class StripeWebhookController { } const event = this.stripeService.createWebhookEvent( - req.rawBody, + req.rawBody!, stripeSignature ); diff --git a/packages/altair-api/src/stripe/stripe.service.ts b/packages/altair-api/src/stripe/stripe.service.ts index 32fd036f8d..6e3ddd4923 100644 --- a/packages/altair-api/src/stripe/stripe.service.ts +++ b/packages/altair-api/src/stripe/stripe.service.ts @@ -7,7 +7,7 @@ export class StripeService { private readonly logger = new Logger(StripeService.name); constructor() { - this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { + this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2022-11-15', }); } @@ -45,7 +45,7 @@ export class StripeService { return this.stripe.webhooks.constructEvent( payload, signature, - process.env.STRIPE_WEBHOOK_SECRET + process.env.STRIPE_WEBHOOK_SECRET! ); } @@ -69,7 +69,14 @@ export class StripeService { } // update first item only - const itemId = res.data.at(0).items.data.at(0).id; + const itemId = res.data.at(0)?.items.data.at(0)?.id; + + if (!itemId) { + throw new Error( + `Cannot update subscription quantity since customer (${stripeCustomerId}) does not have a subscription item ID` + ); + } + return this.stripe.subscriptionItems.update(itemId, { quantity, }); diff --git a/packages/altair-api/src/team-memberships/dto/create-team-membership.dto.ts b/packages/altair-api/src/team-memberships/dto/create-team-membership.dto.ts index 765ea687bb..aa3263bee5 100644 --- a/packages/altair-api/src/team-memberships/dto/create-team-membership.dto.ts +++ b/packages/altair-api/src/team-memberships/dto/create-team-membership.dto.ts @@ -8,12 +8,12 @@ export class CreateTeamMembershipDto implements ICreateTeamMembershipDto { @IsNotEmpty() @IsEmail() @ApiProperty() - email: string; + email!: string; @IsString() @IsNotEmpty() @ApiProperty() - teamId: string; + teamId!: string; @IsString() @IsOptional() diff --git a/packages/altair-api/src/team-memberships/team-memberships.controller.ts b/packages/altair-api/src/team-memberships/team-memberships.controller.ts index 7de340c733..90f9ef862e 100644 --- a/packages/altair-api/src/team-memberships/team-memberships.controller.ts +++ b/packages/altair-api/src/team-memberships/team-memberships.controller.ts @@ -27,20 +27,20 @@ export class TeamMembershipsController { @Req() req: Request, @Body() createTeamMembershipDto: CreateTeamMembershipDto ) { - return this.teamMembershipsService.create( - req.user.id, - createTeamMembershipDto - ); + const userId = req?.user?.id ?? ''; + return this.teamMembershipsService.create(userId, createTeamMembershipDto); } @Get('team/:id') findAll(@Req() req: Request, @Param('id') teamId: string) { - return this.teamMembershipsService.findAll(req.user.id, teamId); + const userId = req?.user?.id ?? ''; + return this.teamMembershipsService.findAll(userId, teamId); } @Get(':id') findOne(@Req() req: Request, @Param('id') id: string) { - return this.teamMembershipsService.findOne(req.user.id, id); + const userId = req?.user?.id ?? ''; + return this.teamMembershipsService.findOne(userId, id); } @Patch(':id') @@ -49,8 +49,9 @@ export class TeamMembershipsController { @Param('id') id: string, @Body() updateTeamMembershipDto: UpdateTeamMembershipDto ) { + const userId = req?.user?.id ?? ''; return this.teamMembershipsService.update( - req.user.id, + userId, id, updateTeamMembershipDto ); @@ -62,6 +63,7 @@ export class TeamMembershipsController { @Param('teamId') teamId: string, @Param('memberId') memberId: string ) { - return this.teamMembershipsService.remove(req.user.id, teamId, memberId); + const userId = req?.user?.id ?? ''; + return this.teamMembershipsService.remove(userId, teamId, memberId); } } diff --git a/packages/altair-api/src/team-memberships/team-memberships.service.ts b/packages/altair-api/src/team-memberships/team-memberships.service.ts index c1230c9572..76213d9408 100644 --- a/packages/altair-api/src/team-memberships/team-memberships.service.ts +++ b/packages/altair-api/src/team-memberships/team-memberships.service.ts @@ -21,6 +21,7 @@ export class TeamMembershipsService { createTeamMembershipDto: CreateTeamMembershipDto ) { const userPlanConfig = await this.userService.getPlanConfig(userId); + const userPlanMaxTeamMemberCount = userPlanConfig?.maxTeamMemberCount ?? 0; const teamMembershipCount = await this.prisma.teamMembership.count({ where: { @@ -32,14 +33,14 @@ export class TeamMembershipsService { }); if ( - !userPlanConfig.allowMoreTeamMembers && - teamMembershipCount >= userPlanConfig.maxTeamMemberCount + !userPlanConfig?.allowMoreTeamMembers && + teamMembershipCount >= userPlanMaxTeamMemberCount ) { throw new InvalidRequestException('ERR_MAX_TEAM_MEMBER_COUNT'); } // Update stripe subscription item quantity - if (userPlanConfig.allowMoreTeamMembers) { + if (userPlanConfig?.allowMoreTeamMembers) { await this.userService.updateAllowedTeamMemberCount( userId, teamMembershipCount + 1 // increment team membership count diff --git a/packages/altair-api/src/teams/dto/create-team.dto.ts b/packages/altair-api/src/teams/dto/create-team.dto.ts index 5a91731f9f..ffdb3e607a 100644 --- a/packages/altair-api/src/teams/dto/create-team.dto.ts +++ b/packages/altair-api/src/teams/dto/create-team.dto.ts @@ -6,7 +6,7 @@ export class CreateTeamDto implements ICreateTeamDto { @IsString() @IsNotEmpty() @ApiProperty() - name: string; + name!: string; @IsString() @IsOptional() diff --git a/packages/altair-api/src/teams/teams.controller.ts b/packages/altair-api/src/teams/teams.controller.ts index 905e11f031..0bf1ab0c81 100644 --- a/packages/altair-api/src/teams/teams.controller.ts +++ b/packages/altair-api/src/teams/teams.controller.ts @@ -8,6 +8,7 @@ import { Delete, UseGuards, Req, + NotFoundException, } from '@nestjs/common'; import { TeamsService } from './teams.service'; import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard'; @@ -22,30 +23,51 @@ export class TeamsController { @Post() create(@Req() req: Request, @Body() createTeamDto: CreateTeamDto) { - return this.teamsService.create(req.user.id, createTeamDto); + const userId = req?.user?.id ?? ''; + return this.teamsService.create(userId, createTeamDto); } @Get() findAll(@Req() req: Request) { - return this.teamsService.findAll(req.user.id); + const userId = req?.user?.id ?? ''; + return this.teamsService.findAll(userId); } @Get(':id') - findOne(@Req() req: Request, @Param('id') id: string) { - return this.teamsService.findOne(req.user.id, id); + async findOne(@Req() req: Request, @Param('id') id: string) { + const userId = req?.user?.id ?? ''; + const res = await this.teamsService.findOne(userId, id); + + if (!res) { + throw new NotFoundException(); + } + + return res; } @Patch(':id') - update( + async update( @Req() req: Request, @Param('id') id: string, @Body() updateTeamDto: UpdateTeamDto ) { - return this.teamsService.update(req.user.id, id, updateTeamDto); + const userId = req?.user?.id ?? ''; + const res = await this.teamsService.update(userId, id, updateTeamDto); + if (!res.count) { + throw new NotFoundException(); + } + + return res; } @Delete(':id') - remove(@Req() req: Request, @Param('id') id: string) { - return this.teamsService.remove(req.user.id, id); + async remove(@Req() req: Request, @Param('id') id: string) { + const userId = req?.user?.id ?? ''; + const res = await this.teamsService.remove(userId, id); + if (!res.count) { + throw new NotFoundException(); + } + + return res; } } diff --git a/packages/altair-api/src/teams/teams.service.ts b/packages/altair-api/src/teams/teams.service.ts index 9fadf7fc96..d75ab4289a 100644 --- a/packages/altair-api/src/teams/teams.service.ts +++ b/packages/altair-api/src/teams/teams.service.ts @@ -15,13 +15,14 @@ export class TeamsService { async create(userId: string, createTeamDto: CreateTeamDto) { const userPlanConfig = await this.userService.getPlanConfig(userId); + const userPlanMaxTeamCount = userPlanConfig?.maxTeamCount ?? 0; const teamCount = await this.prisma.team.count({ where: { ownerId: userId, }, }); - if (teamCount >= userPlanConfig.maxTeamCount) { + if (teamCount >= userPlanMaxTeamCount) { throw new InvalidRequestException( 'ERR_MAX_TEAM_COUNT', 'You have reached the limit of the number of teams for your plan.' diff --git a/packages/altair-api/src/workspaces/workspaces.controller.ts b/packages/altair-api/src/workspaces/workspaces.controller.ts index 16d0ca2517..72e9bc9663 100644 --- a/packages/altair-api/src/workspaces/workspaces.controller.ts +++ b/packages/altair-api/src/workspaces/workspaces.controller.ts @@ -8,6 +8,7 @@ import { Delete, UseGuards, Req, + NotFoundException, } from '@nestjs/common'; import { WorkspacesService } from './workspaces.service'; import { CreateWorkspaceDto } from './dto/create-workspace.dto'; @@ -31,8 +32,13 @@ export class WorkspacesController { } @Get(':id') - findOne(@Req() req: Request, @Param('id') id: string) { - return this.workspacesService.findOne(req.user.id, id); + async findOne(@Req() req: Request, @Param('id') id: string) { + const res = await this.workspacesService.findOne(req.user.id, id); + if (!res) { + throw new NotFoundException(); + } + + return res; } @Patch(':id') diff --git a/packages/altair-api/test/app.e2e-spec.ts b/packages/altair-api/test/app.e2e-spec.ts index 50cda62332..7f56841b6b 100644 --- a/packages/altair-api/test/app.e2e-spec.ts +++ b/packages/altair-api/test/app.e2e-spec.ts @@ -1,24 +1,39 @@ -import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import * as request from 'supertest'; -import { AppModule } from './../src/app.module'; +import { + afterAllCleanup, + beforeAllSetup, + createTestApp, + mockUserFn, +} from './e2e-test-utils'; +import { PrismaService } from 'nestjs-prisma'; describe('AppController (e2e)', () => { let app: INestApplication; + let prismaService: PrismaService; + + beforeAll(async () => { + await beforeAllSetup(); + ({ app, prismaService } = await createTestApp()); + }); beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); + // reset mocks + mockUserFn.mockReturnValue(undefined); + }); - app = moduleFixture.createNestApplication(); - await app.init(); + afterAll(async () => { + await afterAllCleanup(app, prismaService); }); it('/ (GET)', () => { return request(app.getHttpServer()) .get('/') - .expect(200) - .expect('Hello World!'); + .expect(302) + .expect('Location', 'https://altairgraphql.dev'); }); + + // TODO: add tests that check that users can only access their own data + // TODO: add tests for the authentication flows (use jwtService to generate tokens) + // TODO: add test for the addition of new team members when the plan allows it }); diff --git a/packages/altair-api/test/auth.e2e-spec.ts b/packages/altair-api/test/auth.e2e-spec.ts new file mode 100644 index 0000000000..6062206765 --- /dev/null +++ b/packages/altair-api/test/auth.e2e-spec.ts @@ -0,0 +1,63 @@ +import { INestApplication } from '@nestjs/common'; +import { PrismaService } from 'nestjs-prisma'; +import * as request from 'supertest'; +import { + afterAllCleanup, + beforeAllSetup, + createTestApp, + mockUserFn, + testUser, +} from './e2e-test-utils'; + +// - GET /auth/me (returns the user profile) +// - GET /auth/slt (returns the short-lived token for events) + +describe('AuthController', () => { + let app: INestApplication; + let prismaService: PrismaService; + + beforeAll(async () => { + await beforeAllSetup(); + ({ app, prismaService } = await createTestApp()); + }); + + beforeEach(async () => { + // reset mocks + mockUserFn.mockReturnValue(undefined); + }); + afterAll(async () => { + await afterAllCleanup(app, prismaService); + }); + + it('/auth/me (GET) should return 401 when not authenticated', () => { + return request(app.getHttpServer()).get('/auth/me').expect(401); + }); + + it('/auth/me (GET) should return 200 with user profile when authenticated', () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + return request(app.getHttpServer()) + .get('/auth/me') + .expect(200) + .expect((res) => { + expect(res.body).toMatchObject({ + id: testUser.id, + }); + }); + }); + + it('/auth/slt (GET) should return 401 when not authenticated', () => { + return request(app.getHttpServer()).get('/auth/slt').expect(401); + }); + + it('/auth/slt (GET) should return 200 with short-lived token when authenticated', () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + return request(app.getHttpServer()) + .get('/auth/slt') + .expect(200) + .expect((res) => { + expect(res.body).toMatchObject({ + slt: expect.any(String), + }); + }); + }); +}); diff --git a/packages/altair-api/test/e2e-test-utils.ts b/packages/altair-api/test/e2e-test-utils.ts new file mode 100644 index 0000000000..2d9a639c46 --- /dev/null +++ b/packages/altair-api/test/e2e-test-utils.ts @@ -0,0 +1,221 @@ +import * as request from 'supertest'; +import { PrismaClient } from '@altairgraphql/db'; +import { ConsoleLogger, INestApplication, Logger } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AppModule } from 'src/app.module'; +import { JwtStrategy } from 'src/auth/strategies/jwt.strategy'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy } from 'passport-custom'; +import { bootstrapApp } from 'src/app-bootstrap'; +import { CreateQuerySansCollectionIdDto } from 'src/queries/dto/create-query.dto'; +import { PrismaService } from 'nestjs-prisma'; +import { + ICreateQueryDto, + ICreateTeamDto, + ICreateTeamMembershipDto, + ICreateWorkspaceDto, +} from '@altairgraphql/api-utils'; +import { Logger as PinoLogger } from 'nestjs-pino'; + +const prisma = new PrismaClient(); +(prisma as any).enableShutdownHooks = () => { + // do nothing +}; + +const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); +export const testUser = { + id: 'test-user', + email: 'user@test.com', + picture: 'https://example.com/picture.png', + firstName: 'Test', + lastName: 'User', + Workspace: { + create: { + name: 'Test Workspace', + }, + }, +}; +const defaultMockUser: any = undefined; +export const mockUserFn = jest.fn(() => defaultMockUser); + +export const cleanupDatabase = async (prisma: PrismaClient) => { + const tablenames = await prisma.$queryRaw< + Array<{ tablename: string }> + >`SELECT tablename FROM pg_tables WHERE schemaname='public'`; + + const tables = tablenames + .map(({ tablename }) => tablename) + .filter((name) => name !== '_prisma_migrations') + .map((name) => `"public"."${name}"`) + .join(', '); + + try { + await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tables} CASCADE;`); + } catch (error) { + console.log({ error }); + } + + await wait(100); +}; + +export const createTeam = async (app: INestApplication, name = 'test team') => { + const data: ICreateTeamDto = { + name, + description: 'test team description', + }; + + const res = await request(app.getHttpServer()) + .post('/teams') + .send(data) + .expect(201) + .expect((res) => { + expect(res.body).toMatchObject(data); + }); + + return res.body; +}; + +export const createTeamMembership = async ( + app: INestApplication, + teamId: string, + email: string +) => { + const data: ICreateTeamMembershipDto = { + teamId, + email, + }; + const res = await request(app.getHttpServer()) + .post('/team-memberships') + .send(data) + .expect(201) + .expect((res) => { + expect(res.body).toMatchObject({ + teamId, + userId: testUser.id, + role: 'MEMBER', + }); + }); + + return res.body; +}; + +export const createQueryCollection = async ( + app: INestApplication, + collectionName = 'test collection', + queries: CreateQuerySansCollectionIdDto[] | undefined = undefined +) => { + const res = await request(app.getHttpServer()) + .post('/query-collections') + .send({ name: collectionName, queries }) + .expect(201) + .expect((res) => { + expect(res.body).toMatchObject({ name: collectionName }); + }); + + return res.body; +}; + +export const createQuery = async ( + app: INestApplication, + collectionId: string +) => { + const data: ICreateQueryDto = { + name: 'test query', + collectionId, + content: { + query: '{ hello }', + variables: '{}', + apiUrl: 'http://localhost:3000/graphql', + headers: [], + subscriptionUrl: 'http://localhost:3000/graphql', + type: 'window', + version: 1, + windowName: 'Test window', + }, + }; + const res = await request(app.getHttpServer()) + .post('/queries') + .send(data) + .expect(201) + .expect((res) => { + expect(res.body).toMatchObject({ + name: 'test query', + queryVersion: 1, + }); + }); + + return res.body; +}; + +export const beforeAllSetup = async () => { + await cleanupDatabase(prisma); + + // seed e2e test database + const basicPlan = { + id: 'basic', + maxQueryCount: 5, + maxTeamCount: 2, + maxTeamMemberCount: 2, + allowMoreTeamMembers: false, + }; + await prisma.planConfig.upsert({ + update: basicPlan, + create: basicPlan, + where: { + id: 'basic', + }, + }); + + await prisma.user.upsert({ + update: testUser, + create: testUser, + where: { + id: testUser.id, + }, + }); +}; + +export const createTestApp = async () => { + const logger = new ConsoleLogger('test', { + logLevels: ['error', 'warn', 'log', 'debug', 'verbose'], + }); + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }) + .overrideProvider(JwtStrategy) + .useClass( + class MockJwtStrategy extends PassportStrategy(Strategy, 'jwt') { + constructor() { + super((req: any, done: any) => { + done(null, mockUserFn()); + }); + } + } + ) + .overrideProvider(Logger) + .useValue(logger) + .overrideProvider(PinoLogger) + .useValue(logger) + .overrideProvider(PrismaService) + .useValue(prisma) + .compile(); + + expect(process.env.NODE_ENV).toBe('test'); + const app = moduleFixture.createNestApplication(); + await bootstrapApp(app); + await app.init(); + + // wait for app to be ready + // await wait(100); + + return { app, prismaService: moduleFixture.get(PrismaService) }; +}; + +export const afterAllCleanup = async ( + app: INestApplication, + prismaService: PrismaService +) => { + // await prismaService?.$disconnect(); + // await prisma.$disconnect(); + await app.close(); +}; diff --git a/packages/altair-api/test/jest-e2e.config.js b/packages/altair-api/test/jest-e2e.config.js new file mode 100644 index 0000000000..f7759cb3b4 --- /dev/null +++ b/packages/altair-api/test/jest-e2e.config.js @@ -0,0 +1,8 @@ +const parentConfig = require('../jest.config'); + +module.exports = { + ...parentConfig, + testRegex: '.e2e-spec.ts$', + rootDir: '../', + collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts', '!src/**/*.spec.ts'], +}; diff --git a/packages/altair-api/test/jest-e2e.json b/packages/altair-api/test/jest-e2e.json deleted file mode 100644 index e9d912f3e3..0000000000 --- a/packages/altair-api/test/jest-e2e.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "moduleFileExtensions": ["js", "json", "ts"], - "rootDir": ".", - "testEnvironment": "node", - "testRegex": ".e2e-spec.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - } -} diff --git a/packages/altair-api/test/queries.e2e-spec.ts b/packages/altair-api/test/queries.e2e-spec.ts new file mode 100644 index 0000000000..967e5f822e --- /dev/null +++ b/packages/altair-api/test/queries.e2e-spec.ts @@ -0,0 +1,146 @@ +import { IUpdateQueryDto } from '@altairgraphql/api-utils'; +import { INestApplication } from '@nestjs/common'; +import { PrismaService } from 'nestjs-prisma'; +import * as request from 'supertest'; +import { + afterAllCleanup, + beforeAllSetup, + createQuery, + createQueryCollection, + createTestApp, + mockUserFn, + testUser, +} from './e2e-test-utils'; + +describe('QueriesController', () => { + let app: INestApplication; + let prismaService: PrismaService; + + beforeAll(async () => { + await beforeAllSetup(); + ({ app, prismaService } = await createTestApp()); + }); + + beforeEach(async () => { + // reset mocks + mockUserFn.mockReturnValue(undefined); + }); + afterAll(async () => { + await afterAllCleanup(app, prismaService); + }); + + it('/queries (GET) should return 401 when not authenticated', () => { + return request(app.getHttpServer()).get('/queries').expect(401); + }); + + it('/queries (GET) should return 200 with empty list first time when authenticated', () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + return request(app.getHttpServer()).get('/queries').expect(200).expect([]); + }); + + it('/queries (POST) should return 401 when not authenticated', () => { + return request(app.getHttpServer()).post('/queries').expect(401); + }); + + it('/queries (POST) should return 400 when body is invalid', () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + return request(app.getHttpServer()) + .post('/queries') + .send({ name: 'test' }) + .expect(400); + }); + + it('/queries (POST) should return 201 with query when authenticated and body is valid', async () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + const testCollection = await createQueryCollection(app); + return createQuery(app, testCollection.id); + }); + + it('/queries/:id (GET) should return 401 when not authenticated', () => { + return request(app.getHttpServer()).get('/queries/1').expect(401); + }); + + it('/queries/:id (GET) should return 404 when query does not exist', () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + return request(app.getHttpServer()).get('/queries/1').expect(404); + }); + + it('/queries/:id (GET) should return 200 with query when authenticated and query exists', async () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + const testCollection = await createQueryCollection(app); + const testQuery = await createQuery(app, testCollection.id); + return request(app.getHttpServer()) + .get(`/queries/${testQuery.id}`) + .expect(200) + .expect((res) => { + expect(res.body).toMatchObject(testQuery); + }); + }); + + it('/queries/:id (PATCH) should return 401 when not authenticated', () => { + return request(app.getHttpServer()).patch('/queries/1').expect(401); + }); + + it('/queries/:id (PATCH) should return 404 when query does not exist', () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + return request(app.getHttpServer()).patch('/queries/1').expect(404); + }); + + it('/queries/:id (PATCH) should return 400 when body is invalid', async () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + const testCollection = await createQueryCollection(app); + const testQuery = await createQuery(app, testCollection.id); + return request(app.getHttpServer()) + .patch(`/queries/${testQuery.id}`) + .send({ name: true }) + .expect(400); + }); + + it('/queries/:id (PATCH) should return 200 with query when authenticated and body is valid', async () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + const testCollection = await createQueryCollection(app); + const testQuery = await createQuery(app, testCollection.id); + const updateQueryDto: IUpdateQueryDto = { + name: 'test', + content: { + version: 1, + query: 'query { test }', + variables: '{}', + apiUrl: 'http://localhost:3000/graphql', + headers: [], + subscriptionUrl: 'ws://localhost:3000/graphql', + type: 'window', + windowName: 'test', + }, + collectionId: testCollection.id, + }; + return request(app.getHttpServer()) + .patch(`/queries/${testQuery.id}`) + .send(updateQueryDto) + .expect(200) + .expect((res) => { + expect(res.body).toMatchObject({ count: 1 }); + }); + }); + + it('/queries/:id (DELETE) should return 401 when not authenticated', () => { + return request(app.getHttpServer()).delete('/queries/1').expect(401); + }); + + it('/queries/:id (DELETE) should return 404 when query does not exist', () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + return request(app.getHttpServer()).delete('/queries/1').expect(404); + }); + + it('/queries/:id (DELETE) should return 204 when authenticated and query exists', async () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + const testCollection = await createQueryCollection(app); + const testQuery = await createQuery(app, testCollection.id); + return request(app.getHttpServer()) + .delete(`/queries/${testQuery.id}`) + .expect(200) + .expect((res) => { + expect(res.body).toMatchObject({ count: 1 }); + }); + }); +}); diff --git a/packages/altair-api/test/query-collections.e2e-spec.ts b/packages/altair-api/test/query-collections.e2e-spec.ts new file mode 100644 index 0000000000..7f3fff5675 --- /dev/null +++ b/packages/altair-api/test/query-collections.e2e-spec.ts @@ -0,0 +1,179 @@ +import { INestApplication } from '@nestjs/common'; +import { PrismaService } from 'nestjs-prisma'; +import * as request from 'supertest'; +import { + afterAllCleanup, + beforeAllSetup, + createQueryCollection, + createTestApp, + mockUserFn, + testUser, +} from './e2e-test-utils'; + +describe('QueryCollectionsController', () => { + let app: INestApplication; + let prismaService: PrismaService; + + beforeAll(async () => { + await beforeAllSetup(); + ({ app, prismaService } = await createTestApp()); + }); + + beforeEach(async () => { + // reset mocks + mockUserFn.mockReturnValue(undefined); + }); + afterAll(async () => { + await afterAllCleanup(app, prismaService); + }); + + it('/query-collections (GET) should return 401 when not authenticated', () => { + return request(app.getHttpServer()).get('/query-collections').expect(401); + }); + + it('/query-collections (GET) should return 200 with empty list first time when authenticated', () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + return request(app.getHttpServer()) + .get('/query-collections') + .expect(200) + .expect([]); + }); + + it('/query-collections (GET) should return 200 with list of authorized query collections when authenticated', async () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + const testQueryCollection = await createQueryCollection(app); + return request(app.getHttpServer()) + .get('/query-collections') + .expect(200) + .expect((res) => { + expect(res.body).toMatchObject([testQueryCollection]); + }); + }); + + it('/query-collections (POST) should return 401 when not authenticated', () => { + return request(app.getHttpServer()).post('/query-collections').expect(401); + }); + + it('/query-collections (POST) should return 400 when body is invalid', () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + return request(app.getHttpServer()) + .post('/query-collections') + .send({ invalidName: 'test' }) + .expect(400); + }); + + it('/query-collections (POST) should return 201 with query collection when authenticated and body is valid without query', () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + return createQueryCollection(app); + }); + + it('/query-collections (POST) should return 201 with query collection when authenticated and body is valid', () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + return createQueryCollection(app, 'test collection', [ + { + name: 'test query', + content: { + version: 1, + apiUrl: 'https://api.spacex.land/graphql/', + subscriptionUrl: 'wss://api.spacex.land/graphql/', + type: 'window', + windowName: 'test window', + query: 'query { test }', + variables: '{}', + headers: [], + }, + }, + ]); + }); + + it('/query-collections/:id (GET) should return 401 when not authenticated', async () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + const testQueryCollection = await createQueryCollection(app); + mockUserFn.mockReturnValue(undefined); + return request(app.getHttpServer()) + .get(`/query-collections/${testQueryCollection.id}`) + .expect(401); + }); + + it('/query-collections/:id (GET) should return 404 when query collection does not exist', () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + return request(app.getHttpServer()).get('/query-collections/1').expect(404); + }); + + it('/query-collections/:id (GET) should return 200 with query collection when authenticated and query collection exists', async () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + const testQueryCollection = await createQueryCollection(app); + return request(app.getHttpServer()) + .get(`/query-collections/${testQueryCollection.id}`) + .expect(200) + .expect((res) => { + expect(res.body).toMatchObject(testQueryCollection); + }); + }); + + it('/query-collections/:id (PATCH) should return 401 when not authenticated', async () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + const testQueryCollection = await createQueryCollection(app); + mockUserFn.mockReturnValue(undefined); + return request(app.getHttpServer()) + .patch(`/query-collections/${testQueryCollection.id}`) + .expect(401); + }); + + it('/query-collections/:id (PATCH) should return 404 when query collection does not exist', () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + return request(app.getHttpServer()) + .patch('/query-collections/1') + .expect(404); + }); + + it('/query-collections/:id (PATCH) should return 400 when body is invalid', async () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + const testQueryCollection = await createQueryCollection(app); + return request(app.getHttpServer()) + .patch(`/query-collections/${testQueryCollection.id}`) + .send({ name: true }) + .expect(400); + }); + + it('/query-collections/:id (PATCH) should return 200 with query collection when authenticated and body is valid', async () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + const testQueryCollection = await createQueryCollection(app); + return request(app.getHttpServer()) + .patch(`/query-collections/${testQueryCollection.id}`) + .send({ name: 'updated name' }) + .expect(200) + .expect((res) => { + expect(res.body).toMatchObject({ + count: 1, + }); + }); + }); + + it('/query-collections/:id (DELETE) should return 401 when not authenticated', async () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + const testQueryCollection = await createQueryCollection(app); + mockUserFn.mockReturnValue(undefined); + return request(app.getHttpServer()) + .delete(`/query-collections/${testQueryCollection.id}`) + .expect(401); + }); + + it('/query-collections/:id (DELETE) should return 404 when query collection does not exist', () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + return request(app.getHttpServer()) + .delete('/query-collections/1') + .expect(404); + }); + + it('/query-collections/:id (DELETE) should return 200 with query collection when authenticated and query collection exists', async () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + const testQueryCollection = await createQueryCollection(app); + return request(app.getHttpServer()) + .delete(`/query-collections/${testQueryCollection.id}`) + .expect(200) + .expect((res) => { + expect(res.body).toMatchObject({ count: 1 }); + }); + }); +}); diff --git a/packages/altair-api/test/team-memberships.e2e-spec.ts b/packages/altair-api/test/team-memberships.e2e-spec.ts new file mode 100644 index 0000000000..7100395097 --- /dev/null +++ b/packages/altair-api/test/team-memberships.e2e-spec.ts @@ -0,0 +1,97 @@ +import { INestApplication } from '@nestjs/common'; +import { PrismaService } from 'nestjs-prisma'; +import * as request from 'supertest'; +import { + afterAllCleanup, + beforeAllSetup, + createTeam, + createTeamMembership, + createTestApp, + mockUserFn, + testUser, +} from './e2e-test-utils'; + +describe('TeamMembershipsController', () => { + let app: INestApplication; + let prismaService: PrismaService; + + beforeEach(async () => { + await beforeAllSetup(); + ({ app, prismaService } = await createTestApp()); + }); + + beforeEach(async () => { + // reset mocks + mockUserFn.mockReturnValue(undefined); + }); + afterAll(async () => { + await afterAllCleanup(app, prismaService); + }); + + it('/team-memberships/team/:id (GET) should return 401 when not authenticated', () => { + return request(app.getHttpServer()) + .get('/team-memberships/team/1') + .expect(401); + }); + + it('/team-memberships/team/:id (GET) should return 400 when team does not exist or user is not allowed to access the team', () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + return request(app.getHttpServer()) + .get('/team-memberships/team/100') + .expect(400); + }); + + it('/team-memberships/team/:id (GET) should return 200 with list of team memberships when authenticated and team exists', async () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + const team = await createTeam(app); + + return request(app.getHttpServer()) + .get(`/team-memberships/team/${team.id}`) + .expect(200) + .expect((res) => { + expect(res.body).toEqual([]); + }); + }); + + it('/team-memberships (POST) should return 401 when not authenticated', () => { + return request(app.getHttpServer()).post('/team-memberships').expect(401); + }); + + it('/team-memberships (POST) should return 400 when body is invalid', () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + return request(app.getHttpServer()) + .post('/team-memberships') + .send({ teamId: 1 }) + .expect(400); + }); + + it('/team-memberships (POST) should return 201 with team membership when authenticated and body is valid', async () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + const team = await createTeam(app); + + return createTeamMembership(app, team.id, testUser.email); + }); + + it('/team-memberships/team/:teamId/member/:memberId (DELETE) should return 401 when not authenticated', () => { + return request(app.getHttpServer()) + .delete('/team-memberships/team/1/member/1') + .expect(401); + }); + + it('/team-memberships/team/:teamId/member/:memberId (DELETE) should return 400 when team membership does not exist or user is not allowed to access the team', () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + return request(app.getHttpServer()) + .delete('/team-memberships/team/100/member/1') + .expect(400); + }); + + it('/team-memberships/team/:teamId/member/:memberId (DELETE) should return 200 when authenticated and team membership exists', async () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + const team = await createTeam(app); + const membership = await createTeamMembership(app, team.id, testUser.email); + + return request(app.getHttpServer()) + .delete(`/team-memberships/team/${team.id}/member/${membership.userId}`) + .expect(200); + }); +}); diff --git a/packages/altair-api/test/teams.e2e-spec.ts b/packages/altair-api/test/teams.e2e-spec.ts new file mode 100644 index 0000000000..f5c212bb8b --- /dev/null +++ b/packages/altair-api/test/teams.e2e-spec.ts @@ -0,0 +1,134 @@ +import { INestApplication } from '@nestjs/common'; +import { PrismaService } from 'nestjs-prisma'; +import * as request from 'supertest'; +import { + afterAllCleanup, + beforeAllSetup, + createTeam, + createTestApp, + mockUserFn, + testUser, +} from './e2e-test-utils'; + +describe('TeamsController', () => { + let app: INestApplication; + let prismaService: PrismaService; + + beforeEach(async () => { + await beforeAllSetup(); + ({ app, prismaService } = await createTestApp()); + }); + + beforeEach(async () => { + // reset mocks + mockUserFn.mockReturnValue(undefined); + }); + afterAll(async () => { + await afterAllCleanup(app, prismaService); + }); + + it('/teams (GET) should return 401 when not authenticated', () => { + return request(app.getHttpServer()).get('/teams').expect(401); + }); + + it('/teams (GET) should return 200 with list of authorized teams when authenticated', () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + return request(app.getHttpServer()) + .get('/teams') + .expect(200) + .expect((res) => { + expect(res.body).toEqual([]); + }); + }); + + it('/teams/:id (GET) should return 401 when not authenticated', () => { + return request(app.getHttpServer()).get('/teams/1').expect(401); + }); + + it('/teams/:id (GET) should return 404 when team does not exist', () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + return request(app.getHttpServer()).get('/teams/100').expect(404); + }); + + it('/teams/:id (GET) should return 200 with team when authenticated and team exists', async () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + const team = await createTeam(app); + + return request(app.getHttpServer()) + .get(`/teams/${team.id}`) + .expect(200) + .expect((res) => { + expect(res.body).toMatchObject(team); + }); + }); + + it('/teams (POST) should return 401 when not authenticated', () => { + return request(app.getHttpServer()).post('/teams').expect(401); + }); + + it('/teams (POST) should return 400 when body is invalid', () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + return request(app.getHttpServer()) + .post('/teams') + .send({ invalid: 'test' }) + .expect(400); + }); + + it('/teams (POST) should return 201 with team when authenticated and body is valid', () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + return createTeam(app); + }); + + it('/teams/:id (PATCH) should return 401 when not authenticated', () => { + return request(app.getHttpServer()).patch('/teams/1').expect(401); + }); + + it('/teams/:id (PATCH) should return 404 when team does not exist', () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + return request(app.getHttpServer()).patch('/teams/100').expect(404); + }); + + it('/teams/:id (PATCH) should return 400 when body is invalid', async () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + const team = await createTeam(app); + + return request(app.getHttpServer()) + .patch(`/teams/${team.id}`) + .send({ name: true }) + .expect(400); + }); + + it('/teams/:id (PATCH) should return 200 with team when authenticated and body is valid', async () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + const team = await createTeam(app); + + return request(app.getHttpServer()) + .patch(`/teams/${team.id}`) + .send({ name: 'new name' }) + .expect(200) + .expect((res) => { + expect(res.body).toMatchObject({ count: 1 }); + }); + }); + + it('/teams/:id (DELETE) should return 401 when not authenticated', () => { + return request(app.getHttpServer()).delete('/teams/1').expect(401); + }); + + it('/teams/:id (DELETE) should return 404 when team does not exist', () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + return request(app.getHttpServer()).delete('/teams/100').expect(404); + }); + + it('/teams/:id (DELETE) should return 200 when authenticated and team exists', async () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + const team = await createTeam(app); + + return request(app.getHttpServer()) + .delete(`/teams/${team.id}`) + .expect(200) + .expect((res) => { + expect(res.body).toMatchObject({ count: 1 }); + }); + }); +}); diff --git a/packages/altair-api/test/users.e2e-spec.ts b/packages/altair-api/test/users.e2e-spec.ts new file mode 100644 index 0000000000..7cc81ad962 --- /dev/null +++ b/packages/altair-api/test/users.e2e-spec.ts @@ -0,0 +1,76 @@ +import { INestApplication } from '@nestjs/common'; +import { PrismaService } from 'nestjs-prisma'; +import * as request from 'supertest'; +import { + afterAllCleanup, + beforeAllSetup, + createTestApp, + mockUserFn, + testUser, +} from './e2e-test-utils'; + +// - GET /user/plan (returns the plan of the user) +// - GET /user/stats (returns the stats of the user) + +describe('UsersController', () => { + let app: INestApplication; + let prismaService: PrismaService; + + beforeAll(async () => { + await beforeAllSetup(); + ({ app, prismaService } = await createTestApp()); + }); + + beforeEach(async () => { + // reset mocks + mockUserFn.mockReturnValue(undefined); + }); + afterAll(async () => { + await afterAllCleanup(app, prismaService); + }); + + it('/user/plan (GET) should return 401 when not authenticated', () => { + return request(app.getHttpServer()).get('/user/plan').expect(401); + }); + + it('/user/plan (GET) should return 200 with plan when authenticated', () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + return request(app.getHttpServer()) + .get('/user/plan') + .expect(200) + .expect((res) => { + expect(res.body).toEqual({ + max_query_count: 5, + max_team_count: 2, + max_team_member_count: 2, + }); + }); + }); + + it('/user/stats (GET) should return 401 when not authenticated', () => { + return request(app.getHttpServer()).get('/user/stats').expect(401); + }); + + it('/user/stats (GET) should return 200 with stats when authenticated', () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + return request(app.getHttpServer()) + .get('/user/stats') + .expect(200) + .expect((res) => { + expect(res.body).toEqual({ + collections: { + access: 0, + own: 0, + }, + queries: { + access: 0, + own: 0, + }, + teams: { + access: 0, + own: 0, + }, + }); + }); + }); +}); diff --git a/packages/altair-api/test/workspaces.e2e-spec.ts b/packages/altair-api/test/workspaces.e2e-spec.ts new file mode 100644 index 0000000000..c2b2a3e4e6 --- /dev/null +++ b/packages/altair-api/test/workspaces.e2e-spec.ts @@ -0,0 +1,71 @@ +import { INestApplication } from '@nestjs/common'; +import { PrismaService } from 'nestjs-prisma'; +import * as request from 'supertest'; +import { + afterAllCleanup, + beforeAllSetup, + createTestApp, + mockUserFn, + testUser, +} from './e2e-test-utils'; + +describe('WorkspacesController', () => { + let app: INestApplication; + let prismaService: PrismaService; + + beforeAll(async () => { + await beforeAllSetup(); + ({ app, prismaService } = await createTestApp()); + }); + + beforeEach(async () => { + // reset mocks + mockUserFn.mockReturnValue(undefined); + }); + afterAll(async () => { + await afterAllCleanup(app, prismaService); + }); + + it('/workspaces (GET) should return 401 when not authenticated', () => { + return request(app.getHttpServer()).get('/workspaces').expect(401); + }); + + it('/workspaces (GET) should return 200 with list of authorized workspaces when authenticated', () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + return request(app.getHttpServer()) + .get('/workspaces') + .expect(200) + .expect((res) => { + expect(res.body).toMatchObject([ + { + name: 'Test Workspace', + }, + ]); + }); + }); + + it('/workspaces/:id (GET) should return 401 when not authenticated', () => { + return request(app.getHttpServer()).get('/workspaces/1').expect(401); + }); + + it('/workspaces/:id (GET) should return 404 when workspace does not exist', () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + return request(app.getHttpServer()).get('/workspaces/100').expect(404); + }); + + it('/workspaces/:id (GET) should return 200 with workspace when authenticated and workspace exists', async () => { + mockUserFn.mockReturnValue({ id: testUser.id }); + const { body: workspaces } = await request(app.getHttpServer()) + .get('/workspaces') + .expect(200); + + return request(app.getHttpServer()) + .get(`/workspaces/${workspaces[0].id}`) + .expect(200) + .expect((res) => { + expect(res.body).toMatchObject({ + name: 'Test Workspace', + }); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 3ed4977eb9..d84c1cb9bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5224,6 +5224,20 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== +"@quramy/jest-prisma-core@^1.0.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@quramy/jest-prisma-core/-/jest-prisma-core-1.4.0.tgz#c6939461cb92f2f3d33db4e51b2b2a22b6a8f865" + integrity sha512-JMrhoGEe7GVhbQPjS4QHpToiXlP5IS1ds12PLl4yHugvwKUDuxxz43O+t2FmWSyo/isFkUN1FzWEc9W4Bnl+Jw== + dependencies: + chalk "^4.0.0" + +"@quramy/jest-prisma@^1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@quramy/jest-prisma/-/jest-prisma-1.4.0.tgz#25d2bc1c761dc38664b919e1684f95d961c5e883" + integrity sha512-TX34ZdBIMckmZecqhjOst9/IjtOqYeFYB0CjHjGBPMk95v7pekEU1gqeezion4gD8BuenFe/7KUSDrcW/xw/Bg== + dependencies: + "@quramy/jest-prisma-core" "^1.0.0" + "@radix-ui/number@1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@radix-ui/number/-/number-1.0.0.tgz#4c536161d0de750b3f5d55860fc3de46264f897b" @@ -12169,11 +12183,26 @@ dot-prop@^6.0.1: dependencies: is-obj "^2.0.0" +dotenv-cli@^7.2.1: + version "7.2.1" + resolved "https://registry.yarnpkg.com/dotenv-cli/-/dotenv-cli-7.2.1.tgz#e595afd9ebfb721df9da809a435b9aa966c92062" + integrity sha512-ODHbGTskqRtXAzZapDPvgNuDVQApu4oKX8lZW7Y0+9hKA6le1ZJlyRS687oU9FXjOVEDU/VFV6zI125HzhM1UQ== + dependencies: + cross-spawn "^7.0.3" + dotenv "^16.0.0" + dotenv-expand "^10.0.0" + minimist "^1.2.6" + dotenv-expand@8.0.3: version "8.0.3" resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-8.0.3.tgz#29016757455bcc748469c83a19b36aaf2b83dd6e" integrity sha512-SErOMvge0ZUyWd5B0NXMQlDkN+8r+HhVUsxgOO7IoPDOdDRD2JjExpN6y3KnFR66jsJMwSn1pqIivhU5rcJiNg== +dotenv-expand@^10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-10.0.0.tgz#12605d00fb0af6d0a592e6558585784032e4ef37" + integrity sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A== + dotenv-expand@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-5.1.0.tgz#3fbaf020bfd794884072ea26b1e9791d45a629f0" @@ -12189,6 +12218,11 @@ dotenv@^10.0.0, dotenv@~10.0.0: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-10.0.0.tgz#3d4227b8fb95f81096cdd2b66653fb2c7085ba81" integrity sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q== +dotenv@^16.0.0: + version "16.0.3" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.0.3.tgz#115aec42bac5053db3c456db30cc243a5a836a07" + integrity sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ== + dotenv@^8.1.0, dotenv@^8.2.0: version "8.6.0" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.6.0.tgz#061af664d19f7f4d8fc6e4ff9b584ce237adcb8b" @@ -21516,6 +21550,13 @@ pascalcase@^0.1.1: resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" integrity sha512-XHXfu/yOQRy9vYOtUDVMN60OEJjW013GoObG1o+xwQTpB9eYJX/BjXMsdW13ZDPruFhYYn0AG22w0xgQMwl3Nw== +passport-custom@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/passport-custom/-/passport-custom-1.1.1.tgz#71db3d7ec1d7d0085e8768507f61b26d88051c0a" + integrity sha512-/2m7jUGxmCYvoqenLB9UrmkCgPt64h8ZtV+UtuQklZ/Tn1NpKBeOorCYkB/8lMRoiZ5hUrCoMmDtxCS/d38mlg== + dependencies: + passport-strategy "1.x.x" + passport-google-oauth1@1.x.x: version "1.0.0" resolved "https://registry.yarnpkg.com/passport-google-oauth1/-/passport-google-oauth1-1.0.0.tgz#af74a803df51ec646f66a44d82282be6f108e0cc"