Merge branch 'main' of github.com:hoppscotch/hoppscotch
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hoppscotch-backend",
|
||||
"version": "2024.3.3",
|
||||
"version": "2024.7.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -35,11 +35,14 @@
|
||||
"@nestjs/passport": "10.0.2",
|
||||
"@nestjs/platform-express": "10.2.7",
|
||||
"@nestjs/schedule": "4.0.1",
|
||||
"@nestjs/swagger": "7.4.0",
|
||||
"@nestjs/terminus": "10.2.3",
|
||||
"@nestjs/throttler": "5.0.1",
|
||||
"@prisma/client": "5.8.1",
|
||||
"argon2": "0.30.3",
|
||||
"bcrypt": "5.1.0",
|
||||
"class-transformer": "0.5.1",
|
||||
"class-validator": "0.14.1",
|
||||
"cookie": "0.5.0",
|
||||
"cookie-parser": "1.4.6",
|
||||
"cron": "3.1.6",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "lastLoggedOn" TIMESTAMP(3);
|
||||
@@ -0,0 +1,19 @@
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PersonalAccessToken" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userUid" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"expiresOn" TIMESTAMP(3),
|
||||
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedOn" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "PersonalAccessToken_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PersonalAccessToken_token_key" ON "PersonalAccessToken"("token");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PersonalAccessToken" ADD CONSTRAINT "PersonalAccessToken_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "lastActiveOn" TIMESTAMP(3);
|
||||
@@ -0,0 +1,15 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "InfraToken" (
|
||||
"id" TEXT NOT NULL,
|
||||
"creatorUid" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"expiresOn" TIMESTAMP(3),
|
||||
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "InfraToken_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "InfraToken_token_key" ON "InfraToken"("token");
|
||||
@@ -41,31 +41,31 @@ model TeamInvitation {
|
||||
}
|
||||
|
||||
model TeamCollection {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
parentID String?
|
||||
data Json?
|
||||
parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id])
|
||||
children TeamCollection[] @relation("TeamCollectionChildParent")
|
||||
parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id])
|
||||
children TeamCollection[] @relation("TeamCollectionChildParent")
|
||||
requests TeamRequest[]
|
||||
teamID String
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
title String
|
||||
orderIndex Int
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||
}
|
||||
|
||||
model TeamRequest {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
collectionID String
|
||||
collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade)
|
||||
collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade)
|
||||
teamID String
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
title String
|
||||
request Json
|
||||
orderIndex Int
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||
}
|
||||
|
||||
model Shortcode {
|
||||
@@ -89,24 +89,27 @@ model TeamEnvironment {
|
||||
}
|
||||
|
||||
model User {
|
||||
uid String @id @default(cuid())
|
||||
displayName String?
|
||||
email String? @unique
|
||||
photoURL String?
|
||||
isAdmin Boolean @default(false)
|
||||
refreshToken String?
|
||||
providerAccounts Account[]
|
||||
VerificationToken VerificationToken[]
|
||||
settings UserSettings?
|
||||
UserHistory UserHistory[]
|
||||
UserEnvironments UserEnvironment[]
|
||||
userCollections UserCollection[]
|
||||
userRequests UserRequest[]
|
||||
currentRESTSession Json?
|
||||
currentGQLSession Json?
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
invitedUsers InvitedUsers[]
|
||||
shortcodes Shortcode[]
|
||||
uid String @id @default(cuid())
|
||||
displayName String?
|
||||
email String? @unique
|
||||
photoURL String?
|
||||
isAdmin Boolean @default(false)
|
||||
refreshToken String?
|
||||
providerAccounts Account[]
|
||||
VerificationToken VerificationToken[]
|
||||
settings UserSettings?
|
||||
UserHistory UserHistory[]
|
||||
UserEnvironments UserEnvironment[]
|
||||
userCollections UserCollection[]
|
||||
userRequests UserRequest[]
|
||||
currentRESTSession Json?
|
||||
currentGQLSession Json?
|
||||
lastLoggedOn DateTime? @db.Timestamp(3)
|
||||
lastActiveOn DateTime? @db.Timestamp(3)
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
invitedUsers InvitedUsers[]
|
||||
shortcodes Shortcode[]
|
||||
personalAccessTokens PersonalAccessToken[]
|
||||
}
|
||||
|
||||
model Account {
|
||||
@@ -218,3 +221,24 @@ model InfraConfig {
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||
}
|
||||
|
||||
model PersonalAccessToken {
|
||||
id String @id @default(cuid())
|
||||
userUid String
|
||||
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
||||
label String
|
||||
token String @unique @default(uuid())
|
||||
expiresOn DateTime? @db.Timestamp(3)
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||
}
|
||||
|
||||
model InfraToken {
|
||||
id String @id @default(cuid())
|
||||
creatorUid String
|
||||
label String
|
||||
token String @unique @default(uuid())
|
||||
expiresOn DateTime? @db.Timestamp(3)
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
updatedOn DateTime @default(now()) @db.Timestamp(3)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { AccessTokenService } from './access-token.service';
|
||||
import { CreateAccessTokenDto } from './dto/create-access-token.dto';
|
||||
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { throwHTTPErr } from 'src/utils';
|
||||
import { GqlUser } from 'src/decorators/gql-user.decorator';
|
||||
import { AuthUser } from 'src/types/AuthUser';
|
||||
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
|
||||
import { PATAuthGuard } from 'src/guards/rest-pat-auth.guard';
|
||||
import { AccessTokenInterceptor } from 'src/interceptors/access-token.interceptor';
|
||||
import { TeamEnvironmentsService } from 'src/team-environments/team-environments.service';
|
||||
import { TeamCollectionService } from 'src/team-collection/team-collection.service';
|
||||
import { ACCESS_TOKENS_INVALID_DATA_ID } from 'src/errors';
|
||||
import { createCLIErrorResponse } from './helper';
|
||||
|
||||
@UseGuards(ThrottlerBehindProxyGuard)
|
||||
@Controller({ path: 'access-tokens', version: '1' })
|
||||
export class AccessTokenController {
|
||||
constructor(
|
||||
private readonly accessTokenService: AccessTokenService,
|
||||
private readonly teamCollectionService: TeamCollectionService,
|
||||
private readonly teamEnvironmentsService: TeamEnvironmentsService,
|
||||
) {}
|
||||
|
||||
@Post('create')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async createPAT(
|
||||
@GqlUser() user: AuthUser,
|
||||
@Body() createAccessTokenDto: CreateAccessTokenDto,
|
||||
) {
|
||||
const result = await this.accessTokenService.createPAT(
|
||||
createAccessTokenDto,
|
||||
user,
|
||||
);
|
||||
if (E.isLeft(result)) throwHTTPErr(result.left);
|
||||
return result.right;
|
||||
}
|
||||
|
||||
@Delete('revoke')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async deletePAT(@Query('id') id: string) {
|
||||
const result = await this.accessTokenService.deletePAT(id);
|
||||
|
||||
if (E.isLeft(result)) throwHTTPErr(result.left);
|
||||
return result.right;
|
||||
}
|
||||
|
||||
@Get('list')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async listAllUserPAT(
|
||||
@GqlUser() user: AuthUser,
|
||||
@Query('offset', ParseIntPipe) offset: number,
|
||||
@Query('limit', ParseIntPipe) limit: number,
|
||||
) {
|
||||
return await this.accessTokenService.listAllUserPAT(
|
||||
user.uid,
|
||||
offset,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('collection/:id')
|
||||
@UseGuards(PATAuthGuard)
|
||||
@UseInterceptors(AccessTokenInterceptor)
|
||||
async fetchCollection(@GqlUser() user: AuthUser, @Param('id') id: string) {
|
||||
const res = await this.teamCollectionService.getCollectionForCLI(
|
||||
id,
|
||||
user.uid,
|
||||
);
|
||||
|
||||
if (E.isLeft(res))
|
||||
throw new BadRequestException(
|
||||
createCLIErrorResponse(ACCESS_TOKENS_INVALID_DATA_ID),
|
||||
);
|
||||
return res.right;
|
||||
}
|
||||
|
||||
@Get('environment/:id')
|
||||
@UseGuards(PATAuthGuard)
|
||||
@UseInterceptors(AccessTokenInterceptor)
|
||||
async fetchEnvironment(@GqlUser() user: AuthUser, @Param('id') id: string) {
|
||||
const res = await this.teamEnvironmentsService.getTeamEnvironmentForCLI(
|
||||
id,
|
||||
user.uid,
|
||||
);
|
||||
|
||||
if (E.isLeft(res))
|
||||
throw new BadRequestException(
|
||||
createCLIErrorResponse(ACCESS_TOKENS_INVALID_DATA_ID),
|
||||
);
|
||||
return res.right;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AccessTokenController } from './access-token.controller';
|
||||
import { PrismaModule } from 'src/prisma/prisma.module';
|
||||
import { AccessTokenService } from './access-token.service';
|
||||
import { TeamCollectionModule } from 'src/team-collection/team-collection.module';
|
||||
import { TeamEnvironmentsModule } from 'src/team-environments/team-environments.module';
|
||||
import { TeamModule } from 'src/team/team.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PrismaModule,
|
||||
TeamCollectionModule,
|
||||
TeamEnvironmentsModule,
|
||||
TeamModule,
|
||||
],
|
||||
controllers: [AccessTokenController],
|
||||
providers: [AccessTokenService],
|
||||
exports: [AccessTokenService],
|
||||
})
|
||||
export class AccessTokenModule {}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { AccessTokenService } from './access-token.service';
|
||||
import { mockDeep, mockReset } from 'jest-mock-extended';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import {
|
||||
ACCESS_TOKEN_EXPIRY_INVALID,
|
||||
ACCESS_TOKEN_LABEL_SHORT,
|
||||
ACCESS_TOKEN_NOT_FOUND,
|
||||
} from 'src/errors';
|
||||
import { AuthUser } from 'src/types/AuthUser';
|
||||
import { PersonalAccessToken } from '@prisma/client';
|
||||
import { AccessToken } from 'src/types/AccessToken';
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
|
||||
const mockPrisma = mockDeep<PrismaService>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const accessTokenService = new AccessTokenService(mockPrisma);
|
||||
|
||||
const currentTime = new Date();
|
||||
|
||||
const user: AuthUser = {
|
||||
uid: '123344',
|
||||
email: 'dwight@dundermifflin.com',
|
||||
displayName: 'Dwight Schrute',
|
||||
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
|
||||
isAdmin: false,
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
createdOn: currentTime,
|
||||
currentGQLSession: {},
|
||||
currentRESTSession: {},
|
||||
lastLoggedOn: currentTime,
|
||||
lastActiveOn: currentTime,
|
||||
};
|
||||
|
||||
const PATCreatedOn = new Date();
|
||||
const expiryInDays = 7;
|
||||
const PATExpiresOn = new Date(
|
||||
PATCreatedOn.getTime() + expiryInDays * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
const userAccessToken: PersonalAccessToken = {
|
||||
id: 'skfvhj8uvdfivb',
|
||||
userUid: user.uid,
|
||||
label: 'test',
|
||||
token: '0140e328-b187-4823-ae4b-ed4bec832ac2',
|
||||
expiresOn: PATExpiresOn,
|
||||
createdOn: PATCreatedOn,
|
||||
updatedOn: new Date(),
|
||||
};
|
||||
|
||||
const userAccessTokenCasted: AccessToken = {
|
||||
id: userAccessToken.id,
|
||||
label: userAccessToken.label,
|
||||
createdOn: userAccessToken.createdOn,
|
||||
lastUsedOn: userAccessToken.updatedOn,
|
||||
expiresOn: userAccessToken.expiresOn,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockReset(mockPrisma);
|
||||
});
|
||||
|
||||
describe('AccessTokenService', () => {
|
||||
describe('createPAT', () => {
|
||||
test('should throw ACCESS_TOKEN_LABEL_SHORT if label is too short', async () => {
|
||||
const result = await accessTokenService.createPAT(
|
||||
{
|
||||
label: 'a',
|
||||
expiryInDays: 7,
|
||||
},
|
||||
user,
|
||||
);
|
||||
expect(result).toEqualLeft({
|
||||
message: ACCESS_TOKEN_LABEL_SHORT,
|
||||
statusCode: HttpStatus.BAD_REQUEST,
|
||||
});
|
||||
});
|
||||
|
||||
test('should throw ACCESS_TOKEN_EXPIRY_INVALID if expiry date is invalid', async () => {
|
||||
const result = await accessTokenService.createPAT(
|
||||
{
|
||||
label: 'test',
|
||||
expiryInDays: 9,
|
||||
},
|
||||
user,
|
||||
);
|
||||
expect(result).toEqualLeft({
|
||||
message: ACCESS_TOKEN_EXPIRY_INVALID,
|
||||
statusCode: HttpStatus.BAD_REQUEST,
|
||||
});
|
||||
});
|
||||
|
||||
test('should successfully create a new Access Token', async () => {
|
||||
mockPrisma.personalAccessToken.create.mockResolvedValueOnce(
|
||||
userAccessToken,
|
||||
);
|
||||
|
||||
const result = await accessTokenService.createPAT(
|
||||
{
|
||||
label: userAccessToken.label,
|
||||
expiryInDays,
|
||||
},
|
||||
user,
|
||||
);
|
||||
expect(result).toEqualRight({
|
||||
token: `pat-${userAccessToken.token}`,
|
||||
info: userAccessTokenCasted,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deletePAT', () => {
|
||||
test('should throw ACCESS_TOKEN_NOT_FOUND if Access Token is not found', async () => {
|
||||
mockPrisma.personalAccessToken.delete.mockRejectedValueOnce(
|
||||
'RecordNotFound',
|
||||
);
|
||||
|
||||
const result = await accessTokenService.deletePAT(userAccessToken.id);
|
||||
expect(result).toEqualLeft({
|
||||
message: ACCESS_TOKEN_NOT_FOUND,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
});
|
||||
|
||||
test('should successfully delete a new Access Token', async () => {
|
||||
mockPrisma.personalAccessToken.delete.mockResolvedValueOnce(
|
||||
userAccessToken,
|
||||
);
|
||||
|
||||
const result = await accessTokenService.deletePAT(userAccessToken.id);
|
||||
expect(result).toEqualRight(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listAllUserPAT', () => {
|
||||
test('should successfully return a list of user Access Tokens', async () => {
|
||||
mockPrisma.personalAccessToken.findMany.mockResolvedValueOnce([
|
||||
userAccessToken,
|
||||
]);
|
||||
|
||||
const result = await accessTokenService.listAllUserPAT(user.uid, 0, 10);
|
||||
expect(result).toEqual([userAccessTokenCasted]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserPAT', () => {
|
||||
test('should throw ACCESS_TOKEN_NOT_FOUND if Access Token is not found', async () => {
|
||||
mockPrisma.personalAccessToken.findUniqueOrThrow.mockRejectedValueOnce(
|
||||
'NotFoundError',
|
||||
);
|
||||
|
||||
const result = await accessTokenService.getUserPAT(userAccessToken.token);
|
||||
expect(result).toEqualLeft(ACCESS_TOKEN_NOT_FOUND);
|
||||
});
|
||||
|
||||
test('should successfully return a user Access Tokens', async () => {
|
||||
mockPrisma.personalAccessToken.findUniqueOrThrow.mockResolvedValueOnce({
|
||||
...userAccessToken,
|
||||
user,
|
||||
} as any);
|
||||
|
||||
const result = await accessTokenService.getUserPAT(
|
||||
`pat-${userAccessToken.token}`,
|
||||
);
|
||||
expect(result).toEqualRight({
|
||||
user,
|
||||
...userAccessToken,
|
||||
} as any);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateLastUsedforPAT', () => {
|
||||
test('should throw ACCESS_TOKEN_NOT_FOUND if Access Token is not found', async () => {
|
||||
mockPrisma.personalAccessToken.update.mockRejectedValueOnce(
|
||||
'RecordNotFound',
|
||||
);
|
||||
|
||||
const result = await accessTokenService.updateLastUsedForPAT(
|
||||
userAccessToken.token,
|
||||
);
|
||||
expect(result).toEqualLeft(ACCESS_TOKEN_NOT_FOUND);
|
||||
});
|
||||
|
||||
test('should successfully update lastUsedOn for a user Access Tokens', async () => {
|
||||
mockPrisma.personalAccessToken.update.mockResolvedValueOnce(
|
||||
userAccessToken,
|
||||
);
|
||||
|
||||
const result = await accessTokenService.updateLastUsedForPAT(
|
||||
`pat-${userAccessToken.token}`,
|
||||
);
|
||||
expect(result).toEqualRight(userAccessTokenCasted);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { CreateAccessTokenDto } from './dto/create-access-token.dto';
|
||||
import { AuthUser } from 'src/types/AuthUser';
|
||||
import { calculateExpirationDate, isValidLength } from 'src/utils';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import {
|
||||
ACCESS_TOKEN_EXPIRY_INVALID,
|
||||
ACCESS_TOKEN_LABEL_SHORT,
|
||||
ACCESS_TOKEN_NOT_FOUND,
|
||||
} from 'src/errors';
|
||||
import { CreateAccessTokenResponse } from './helper';
|
||||
import { PersonalAccessToken } from '@prisma/client';
|
||||
import { AccessToken } from 'src/types/AccessToken';
|
||||
@Injectable()
|
||||
export class AccessTokenService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
TITLE_LENGTH = 3;
|
||||
VALID_TOKEN_DURATIONS = [7, 30, 60, 90];
|
||||
TOKEN_PREFIX = 'pat-';
|
||||
|
||||
/**
|
||||
* Validate the expiration date of the token
|
||||
*
|
||||
* @param expiresOn Number of days the token is valid for
|
||||
* @returns Boolean indicating if the expiration date is valid
|
||||
*/
|
||||
private validateExpirationDate(expiresOn: null | number) {
|
||||
if (expiresOn === null || this.VALID_TOKEN_DURATIONS.includes(expiresOn))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typecast a database PersonalAccessToken to a AccessToken model
|
||||
* @param token database PersonalAccessToken
|
||||
* @returns AccessToken model
|
||||
*/
|
||||
private cast(token: PersonalAccessToken): AccessToken {
|
||||
return <AccessToken>{
|
||||
id: token.id,
|
||||
label: token.label,
|
||||
createdOn: token.createdOn,
|
||||
expiresOn: token.expiresOn,
|
||||
lastUsedOn: token.updatedOn,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract UUID from the token
|
||||
*
|
||||
* @param token Personal Access Token
|
||||
* @returns UUID of the token
|
||||
*/
|
||||
private extractUUID(token): string | null {
|
||||
if (!token.startsWith(this.TOKEN_PREFIX)) return null;
|
||||
return token.slice(this.TOKEN_PREFIX.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Personal Access Token
|
||||
*
|
||||
* @param createAccessTokenDto DTO for creating a Personal Access Token
|
||||
* @param user AuthUser object
|
||||
* @returns Either of the created token or error message
|
||||
*/
|
||||
async createPAT(createAccessTokenDto: CreateAccessTokenDto, user: AuthUser) {
|
||||
const isTitleValid = isValidLength(
|
||||
createAccessTokenDto.label,
|
||||
this.TITLE_LENGTH,
|
||||
);
|
||||
if (!isTitleValid)
|
||||
return E.left({
|
||||
message: ACCESS_TOKEN_LABEL_SHORT,
|
||||
statusCode: HttpStatus.BAD_REQUEST,
|
||||
});
|
||||
|
||||
if (!this.validateExpirationDate(createAccessTokenDto.expiryInDays))
|
||||
return E.left({
|
||||
message: ACCESS_TOKEN_EXPIRY_INVALID,
|
||||
statusCode: HttpStatus.BAD_REQUEST,
|
||||
});
|
||||
|
||||
const createdPAT = await this.prisma.personalAccessToken.create({
|
||||
data: {
|
||||
userUid: user.uid,
|
||||
label: createAccessTokenDto.label,
|
||||
expiresOn: calculateExpirationDate(createAccessTokenDto.expiryInDays),
|
||||
},
|
||||
});
|
||||
|
||||
const res: CreateAccessTokenResponse = {
|
||||
token: `${this.TOKEN_PREFIX}${createdPAT.token}`,
|
||||
info: this.cast(createdPAT),
|
||||
};
|
||||
|
||||
return E.right(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a Personal Access Token
|
||||
*
|
||||
* @param accessTokenID ID of the Personal Access Token
|
||||
* @returns Either of true or error message
|
||||
*/
|
||||
async deletePAT(accessTokenID: string) {
|
||||
try {
|
||||
await this.prisma.personalAccessToken.delete({
|
||||
where: { id: accessTokenID },
|
||||
});
|
||||
return E.right(true);
|
||||
} catch {
|
||||
return E.left({
|
||||
message: ACCESS_TOKEN_NOT_FOUND,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all Personal Access Tokens of a user
|
||||
*
|
||||
* @param userUid UID of the user
|
||||
* @param offset Offset for pagination
|
||||
* @param limit Limit for pagination
|
||||
* @returns Either of the list of Personal Access Tokens or error message
|
||||
*/
|
||||
async listAllUserPAT(userUid: string, offset: number, limit: number) {
|
||||
const userPATs = await this.prisma.personalAccessToken.findMany({
|
||||
where: {
|
||||
userUid: userUid,
|
||||
},
|
||||
skip: offset,
|
||||
take: limit,
|
||||
orderBy: {
|
||||
createdOn: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
const userAccessTokenList = userPATs.map((pat) => this.cast(pat));
|
||||
|
||||
return userAccessTokenList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a Personal Access Token
|
||||
*
|
||||
* @param accessToken Personal Access Token
|
||||
* @returns Either of the Personal Access Token or error message
|
||||
*/
|
||||
async getUserPAT(accessToken: string) {
|
||||
const extractedToken = this.extractUUID(accessToken);
|
||||
if (!extractedToken) return E.left(ACCESS_TOKEN_NOT_FOUND);
|
||||
|
||||
try {
|
||||
const userPAT = await this.prisma.personalAccessToken.findUniqueOrThrow({
|
||||
where: { token: extractedToken },
|
||||
include: { user: true },
|
||||
});
|
||||
return E.right(userPAT);
|
||||
} catch {
|
||||
return E.left(ACCESS_TOKEN_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the last used date of a Personal Access Token
|
||||
*
|
||||
* @param token Personal Access Token
|
||||
* @returns Either of the updated Personal Access Token or error message
|
||||
*/
|
||||
async updateLastUsedForPAT(token: string) {
|
||||
const extractedToken = this.extractUUID(token);
|
||||
if (!extractedToken) return E.left(ACCESS_TOKEN_NOT_FOUND);
|
||||
|
||||
try {
|
||||
const updatedAccessToken = await this.prisma.personalAccessToken.update({
|
||||
where: { token: extractedToken },
|
||||
data: {
|
||||
updatedOn: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return E.right(this.cast(updatedAccessToken));
|
||||
} catch {
|
||||
return E.left(ACCESS_TOKEN_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Inputs to create a new PAT
|
||||
export class CreateAccessTokenDto {
|
||||
label: string;
|
||||
expiryInDays: number | null;
|
||||
}
|
||||
17
packages/hoppscotch-backend/src/access-token/helper.ts
Normal file
17
packages/hoppscotch-backend/src/access-token/helper.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { AccessToken } from 'src/types/AccessToken';
|
||||
|
||||
// Response type of PAT creation method
|
||||
export type CreateAccessTokenResponse = {
|
||||
token: string;
|
||||
info: AccessToken;
|
||||
};
|
||||
|
||||
// Response type of any error in PAT module
|
||||
export type CLIErrorResponse = {
|
||||
reason: string;
|
||||
};
|
||||
|
||||
// Return a CLIErrorResponse object
|
||||
export function createCLIErrorResponse(reason: string): CLIErrorResponse {
|
||||
return { reason };
|
||||
}
|
||||
@@ -74,6 +74,8 @@ const dbAdminUsers: DbUser[] = [
|
||||
refreshToken: 'refreshToken',
|
||||
currentRESTSession: '',
|
||||
currentGQLSession: '',
|
||||
lastLoggedOn: new Date(),
|
||||
lastActiveOn: new Date(),
|
||||
createdOn: new Date(),
|
||||
},
|
||||
{
|
||||
@@ -85,20 +87,11 @@ const dbAdminUsers: DbUser[] = [
|
||||
refreshToken: 'refreshToken',
|
||||
currentRESTSession: '',
|
||||
currentGQLSession: '',
|
||||
lastLoggedOn: new Date(),
|
||||
lastActiveOn: new Date(),
|
||||
createdOn: new Date(),
|
||||
},
|
||||
];
|
||||
const dbNonAminUser: DbUser = {
|
||||
uid: 'uid 3',
|
||||
displayName: 'displayName',
|
||||
email: 'email@email.com',
|
||||
photoURL: 'photoURL',
|
||||
isAdmin: false,
|
||||
refreshToken: 'refreshToken',
|
||||
currentRESTSession: '',
|
||||
currentGQLSession: '',
|
||||
createdOn: new Date(),
|
||||
};
|
||||
|
||||
describe('AdminService', () => {
|
||||
describe('fetchInvitedUsers', () => {
|
||||
@@ -121,6 +114,7 @@ describe('AdminService', () => {
|
||||
NOT: {
|
||||
inviteeEmail: {
|
||||
in: [dbAdminUsers[0].email],
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -229,7 +223,10 @@ describe('AdminService', () => {
|
||||
|
||||
expect(mockPrisma.invitedUsers.deleteMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
inviteeEmail: { in: [invitedUsers[0].inviteeEmail] },
|
||||
inviteeEmail: {
|
||||
in: [invitedUsers[0].inviteeEmail],
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result).toEqualRight(true);
|
||||
|
||||
@@ -89,12 +89,17 @@ export class AdminService {
|
||||
adminEmail: string,
|
||||
inviteeEmail: string,
|
||||
) {
|
||||
if (inviteeEmail == adminEmail) return E.left(DUPLICATE_EMAIL);
|
||||
if (inviteeEmail.toLowerCase() == adminEmail.toLowerCase()) {
|
||||
return E.left(DUPLICATE_EMAIL);
|
||||
}
|
||||
if (!validateEmail(inviteeEmail)) return E.left(INVALID_EMAIL);
|
||||
|
||||
const alreadyInvitedUser = await this.prisma.invitedUsers.findFirst({
|
||||
where: {
|
||||
inviteeEmail: inviteeEmail,
|
||||
inviteeEmail: {
|
||||
equals: inviteeEmail,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
if (alreadyInvitedUser != null) return E.left(USER_ALREADY_INVITED);
|
||||
@@ -156,10 +161,17 @@ export class AdminService {
|
||||
* @returns an Either of boolean or error string
|
||||
*/
|
||||
async revokeUserInvitations(inviteeEmails: string[]) {
|
||||
const areAllEmailsValid = inviteeEmails.every((email) =>
|
||||
validateEmail(email),
|
||||
);
|
||||
if (!areAllEmailsValid) {
|
||||
return E.left(INVALID_EMAIL);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.prisma.invitedUsers.deleteMany({
|
||||
where: {
|
||||
inviteeEmail: { in: inviteeEmails },
|
||||
inviteeEmail: { in: inviteeEmails, mode: 'insensitive' },
|
||||
},
|
||||
});
|
||||
return E.right(true);
|
||||
@@ -189,6 +201,7 @@ export class AdminService {
|
||||
NOT: {
|
||||
inviteeEmail: {
|
||||
in: userEmailObjs.map((user) => user.email),
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -359,4 +359,23 @@ export class InfraResolver {
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Enable or Disable SMTP for sending emails',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async toggleSMTP(
|
||||
@Args({
|
||||
name: 'status',
|
||||
type: () => ServiceStatus,
|
||||
description: 'Toggle SMTP',
|
||||
})
|
||||
status: ServiceStatus,
|
||||
) {
|
||||
const isUpdated = await this.infraConfigService.enableAndDisableSMTP(
|
||||
status,
|
||||
);
|
||||
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ import { MailerModule } from './mailer/mailer.module';
|
||||
import { PosthogModule } from './posthog/posthog.module';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { AccessTokenModule } from './access-token/access-token.module';
|
||||
import { UserLastActiveOnInterceptor } from './interceptors/user-last-active-on.interceptor';
|
||||
import { InfraTokenModule } from './infra-token/infra-token.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -102,8 +105,13 @@ import { HealthModule } from './health/health.module';
|
||||
PosthogModule,
|
||||
ScheduleModule.forRoot(),
|
||||
HealthModule,
|
||||
AccessTokenModule,
|
||||
InfraTokenModule,
|
||||
],
|
||||
providers: [
|
||||
GQLComplexityPlugin,
|
||||
{ provide: 'APP_INTERCEPTOR', useClass: UserLastActiveOnInterceptor },
|
||||
],
|
||||
providers: [GQLComplexityPlugin],
|
||||
controllers: [AppController],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Request,
|
||||
Res,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { SignInMagicDto } from './dto/signin-magic.dto';
|
||||
@@ -27,6 +28,7 @@ import { SkipThrottle } from '@nestjs/throttler';
|
||||
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { throwHTTPErr } from 'src/utils';
|
||||
import { UserLastLoginInterceptor } from 'src/interceptors/user-last-login.interceptor';
|
||||
|
||||
@UseGuards(ThrottlerBehindProxyGuard)
|
||||
@Controller({ path: 'auth', version: '1' })
|
||||
@@ -110,6 +112,7 @@ export class AuthController {
|
||||
@Get('google/callback')
|
||||
@SkipThrottle()
|
||||
@UseGuards(GoogleSSOGuard)
|
||||
@UseInterceptors(UserLastLoginInterceptor)
|
||||
async googleAuthRedirect(@Request() req, @Res() res) {
|
||||
const authTokens = await this.authService.generateAuthTokens(req.user.uid);
|
||||
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
|
||||
@@ -135,6 +138,7 @@ export class AuthController {
|
||||
@Get('github/callback')
|
||||
@SkipThrottle()
|
||||
@UseGuards(GithubSSOGuard)
|
||||
@UseInterceptors(UserLastLoginInterceptor)
|
||||
async githubAuthRedirect(@Request() req, @Res() res) {
|
||||
const authTokens = await this.authService.generateAuthTokens(req.user.uid);
|
||||
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
|
||||
@@ -160,6 +164,7 @@ export class AuthController {
|
||||
@Get('microsoft/callback')
|
||||
@SkipThrottle()
|
||||
@UseGuards(MicrosoftSSOGuard)
|
||||
@UseInterceptors(UserLastLoginInterceptor)
|
||||
async microsoftAuthRedirect(@Request() req, @Res() res) {
|
||||
const authTokens = await this.authService.generateAuthTokens(req.user.uid);
|
||||
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
|
||||
|
||||
@@ -51,6 +51,8 @@ const user: AuthUser = {
|
||||
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
|
||||
isAdmin: false,
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
lastActiveOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
currentGQLSession: {},
|
||||
currentRESTSession: {},
|
||||
@@ -172,9 +174,11 @@ describe('verifyMagicLinkTokens', () => {
|
||||
// generateAuthTokens
|
||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user));
|
||||
mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user));
|
||||
// deletePasswordlessVerificationToken
|
||||
mockPrisma.verificationToken.delete.mockResolvedValueOnce(passwordlessData);
|
||||
// usersService.updateUserLastLoggedOn
|
||||
mockUser.updateUserLastLoggedOn.mockResolvedValue(E.right(true));
|
||||
|
||||
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
|
||||
expect(result).toEqualRight({
|
||||
@@ -197,9 +201,11 @@ describe('verifyMagicLinkTokens', () => {
|
||||
// generateAuthTokens
|
||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user));
|
||||
mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user));
|
||||
// deletePasswordlessVerificationToken
|
||||
mockPrisma.verificationToken.delete.mockResolvedValueOnce(passwordlessData);
|
||||
// usersService.updateUserLastLoggedOn
|
||||
mockUser.updateUserLastLoggedOn.mockResolvedValue(E.right(true));
|
||||
|
||||
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
|
||||
expect(result).toEqualRight({
|
||||
@@ -239,7 +245,7 @@ describe('verifyMagicLinkTokens', () => {
|
||||
// generateAuthTokens
|
||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
|
||||
mockUser.updateUserRefreshToken.mockResolvedValueOnce(
|
||||
E.left(USER_NOT_FOUND),
|
||||
);
|
||||
|
||||
@@ -264,7 +270,7 @@ describe('verifyMagicLinkTokens', () => {
|
||||
// generateAuthTokens
|
||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user));
|
||||
mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user));
|
||||
// deletePasswordlessVerificationToken
|
||||
mockPrisma.verificationToken.delete.mockRejectedValueOnce('RecordNotFound');
|
||||
|
||||
@@ -280,7 +286,7 @@ describe('generateAuthTokens', () => {
|
||||
test('Should successfully generate tokens with valid inputs', async () => {
|
||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user));
|
||||
mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user));
|
||||
|
||||
const result = await authService.generateAuthTokens(user.uid);
|
||||
expect(result).toEqualRight({
|
||||
@@ -292,7 +298,7 @@ describe('generateAuthTokens', () => {
|
||||
test('Should throw USER_NOT_FOUND when updating refresh tokens fails', async () => {
|
||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
|
||||
mockUser.updateUserRefreshToken.mockResolvedValueOnce(
|
||||
E.left(USER_NOT_FOUND),
|
||||
);
|
||||
|
||||
@@ -319,7 +325,7 @@ describe('refreshAuthTokens', () => {
|
||||
// generateAuthTokens
|
||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
|
||||
mockUser.updateUserRefreshToken.mockResolvedValueOnce(
|
||||
E.left(USER_NOT_FOUND),
|
||||
);
|
||||
|
||||
@@ -348,7 +354,7 @@ describe('refreshAuthTokens', () => {
|
||||
// generateAuthTokens
|
||||
mockJWT.sign.mockReturnValue('sdhjcbjsdhcbshjdcb');
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
|
||||
mockUser.updateUserRefreshToken.mockResolvedValueOnce(
|
||||
E.right({
|
||||
...user,
|
||||
refreshToken: 'sdhjcbjsdhcbshjdcb',
|
||||
|
||||
@@ -112,7 +112,7 @@ export class AuthService {
|
||||
|
||||
const refreshTokenHash = await argon2.hash(refreshToken);
|
||||
|
||||
const updatedUser = await this.usersService.UpdateUserRefreshToken(
|
||||
const updatedUser = await this.usersService.updateUserRefreshToken(
|
||||
refreshTokenHash,
|
||||
userUid,
|
||||
);
|
||||
@@ -320,6 +320,8 @@ export class AuthService {
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
|
||||
this.usersService.updateUserLastLoggedOn(passwordlessTokens.value.userUid);
|
||||
|
||||
return E.right(tokens.right);
|
||||
}
|
||||
|
||||
|
||||
@@ -52,13 +52,13 @@ export const authCookieHandler = (
|
||||
|
||||
res.cookie(AuthTokenType.ACCESS_TOKEN, authTokens.access_token, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
secure: configService.get('ALLOW_SECURE_COOKIES') === 'true',
|
||||
sameSite: 'lax',
|
||||
maxAge: accessTokenValidity,
|
||||
});
|
||||
res.cookie(AuthTokenType.REFRESH_TOKEN, authTokens.refresh_token, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
secure: configService.get('ALLOW_SECURE_COOKIES') === 'true',
|
||||
sameSite: 'lax',
|
||||
maxAge: refreshTokenValidity,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
** Decorator to fetch refresh_token from cookie
|
||||
*/
|
||||
export const BearerToken = createParamDecorator(
|
||||
(data: unknown, context: ExecutionContext) => {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
|
||||
// authorization token will be "Bearer <token>"
|
||||
const authorization = request.headers['authorization'];
|
||||
// Remove "Bearer " and return the token only
|
||||
return authorization.split(' ')[1];
|
||||
},
|
||||
);
|
||||
@@ -678,6 +678,19 @@ export const MAILER_SMTP_URL_UNDEFINED = 'mailer/smtp_url_undefined' as const;
|
||||
export const MAILER_FROM_ADDRESS_UNDEFINED =
|
||||
'mailer/from_address_undefined' as const;
|
||||
|
||||
/**
|
||||
* MAILER_SMTP_USER environment variable is not defined
|
||||
* (MailerModule)
|
||||
*/
|
||||
export const MAILER_SMTP_USER_UNDEFINED = 'mailer/smtp_user_undefined' as const;
|
||||
|
||||
/**
|
||||
* MAILER_SMTP_PASSWORD environment variable is not defined
|
||||
* (MailerModule)
|
||||
*/
|
||||
export const MAILER_SMTP_PASSWORD_UNDEFINED =
|
||||
'mailer/smtp_password_undefined' as const;
|
||||
|
||||
/**
|
||||
* SharedRequest invalid request JSON format
|
||||
* (ShortcodeService)
|
||||
@@ -761,3 +774,82 @@ export const POSTHOG_CLIENT_NOT_INITIALIZED = 'posthog/client_not_initialized';
|
||||
* Inputs supplied are invalid
|
||||
*/
|
||||
export const INVALID_PARAMS = 'invalid_parameters' as const;
|
||||
|
||||
/**
|
||||
* The provided label for the access-token is short (less than 3 characters)
|
||||
* (AccessTokenService)
|
||||
*/
|
||||
export const ACCESS_TOKEN_LABEL_SHORT = 'access_token/label_too_short';
|
||||
|
||||
/**
|
||||
* The provided expiryInDays value is not valid
|
||||
* (AccessTokenService)
|
||||
*/
|
||||
export const ACCESS_TOKEN_EXPIRY_INVALID = 'access_token/expiry_days_invalid';
|
||||
|
||||
/**
|
||||
* The provided PAT ID is invalid
|
||||
* (AccessTokenService)
|
||||
*/
|
||||
export const ACCESS_TOKEN_NOT_FOUND = 'access_token/access_token_not_found';
|
||||
|
||||
/**
|
||||
* AccessTokens is expired
|
||||
* (AccessTokenService)
|
||||
*/
|
||||
export const ACCESS_TOKEN_EXPIRED = 'TOKEN_EXPIRED';
|
||||
|
||||
/**
|
||||
* AccessTokens is invalid
|
||||
* (AccessTokenService)
|
||||
*/
|
||||
export const ACCESS_TOKEN_INVALID = 'TOKEN_INVALID';
|
||||
|
||||
/**
|
||||
* AccessTokens is invalid
|
||||
* (AccessTokenService)
|
||||
*/
|
||||
export const ACCESS_TOKENS_INVALID_DATA_ID = 'INVALID_ID';
|
||||
|
||||
/**
|
||||
* The provided label for the infra-token is short (less than 3 characters)
|
||||
* (InfraTokenService)
|
||||
*/
|
||||
export const INFRA_TOKEN_LABEL_SHORT = 'infra_token/label_too_short';
|
||||
|
||||
/**
|
||||
* The provided expiryInDays value is not valid
|
||||
* (InfraTokenService)
|
||||
*/
|
||||
export const INFRA_TOKEN_EXPIRY_INVALID = 'infra_token/expiry_days_invalid';
|
||||
|
||||
/**
|
||||
* The provided Infra Token ID is invalid
|
||||
* (InfraTokenService)
|
||||
*/
|
||||
export const INFRA_TOKEN_NOT_FOUND = 'infra_token/infra_token_not_found';
|
||||
|
||||
/**
|
||||
* Authorization missing in header (Check 'Authorization' Header)
|
||||
* (InfraTokenGuard)
|
||||
*/
|
||||
export const INFRA_TOKEN_HEADER_MISSING =
|
||||
'infra_token/authorization_token_missing';
|
||||
|
||||
/**
|
||||
* Infra Token is invalid
|
||||
* (InfraTokenGuard)
|
||||
*/
|
||||
export const INFRA_TOKEN_INVALID_TOKEN = 'infra_token/invalid_token';
|
||||
|
||||
/**
|
||||
* Infra Token is expired
|
||||
* (InfraTokenGuard)
|
||||
*/
|
||||
export const INFRA_TOKEN_EXPIRED = 'infra_token/expired';
|
||||
|
||||
/**
|
||||
* Token creator not found
|
||||
* (InfraTokenService)
|
||||
*/
|
||||
export const INFRA_TOKEN_CREATOR_NOT_FOUND = 'infra_token/creator_not_found';
|
||||
|
||||
@@ -28,6 +28,8 @@ import { UserEnvsUserResolver } from './user-environment/user.resolver';
|
||||
import { UserHistoryUserResolver } from './user-history/user.resolver';
|
||||
import { UserSettingsUserResolver } from './user-settings/user.resolver';
|
||||
import { InfraResolver } from './admin/infra.resolver';
|
||||
import { InfraConfigResolver } from './infra-config/infra-config.resolver';
|
||||
import { InfraTokenResolver } from './infra-token/infra-token.resolver';
|
||||
|
||||
/**
|
||||
* All the resolvers present in the application.
|
||||
@@ -58,6 +60,8 @@ const RESOLVERS = [
|
||||
UserRequestUserCollectionResolver,
|
||||
UserSettingsResolver,
|
||||
UserSettingsUserResolver,
|
||||
InfraConfigResolver,
|
||||
InfraTokenResolver,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
47
packages/hoppscotch-backend/src/guards/infra-token.guard.ts
Normal file
47
packages/hoppscotch-backend/src/guards/infra-token.guard.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { DateTime } from 'luxon';
|
||||
import {
|
||||
INFRA_TOKEN_EXPIRED,
|
||||
INFRA_TOKEN_HEADER_MISSING,
|
||||
INFRA_TOKEN_INVALID_TOKEN,
|
||||
} from 'src/errors';
|
||||
|
||||
@Injectable()
|
||||
export class InfraTokenGuard implements CanActivate {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const authorization = request.headers['authorization'];
|
||||
|
||||
if (!authorization)
|
||||
throw new UnauthorizedException(INFRA_TOKEN_HEADER_MISSING);
|
||||
|
||||
if (!authorization.startsWith('Bearer '))
|
||||
throw new UnauthorizedException(INFRA_TOKEN_INVALID_TOKEN);
|
||||
|
||||
const token = authorization.split(' ')[1];
|
||||
|
||||
if (!token) throw new UnauthorizedException(INFRA_TOKEN_INVALID_TOKEN);
|
||||
|
||||
const infraToken = await this.prisma.infraToken.findUnique({
|
||||
where: { token },
|
||||
});
|
||||
|
||||
if (infraToken === null)
|
||||
throw new UnauthorizedException(INFRA_TOKEN_INVALID_TOKEN);
|
||||
|
||||
const currentTime = DateTime.now().toISO();
|
||||
if (currentTime > infraToken.expiresOn?.toISOString()) {
|
||||
throw new UnauthorizedException(INFRA_TOKEN_EXPIRED);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { AccessTokenService } from 'src/access-token/access-token.service';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { DateTime } from 'luxon';
|
||||
import { ACCESS_TOKEN_EXPIRED, ACCESS_TOKEN_INVALID } from 'src/errors';
|
||||
import { createCLIErrorResponse } from 'src/access-token/helper';
|
||||
@Injectable()
|
||||
export class PATAuthGuard implements CanActivate {
|
||||
constructor(private accessTokenService: AccessTokenService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
if (!token) {
|
||||
throw new BadRequestException(
|
||||
createCLIErrorResponse(ACCESS_TOKEN_INVALID),
|
||||
);
|
||||
}
|
||||
|
||||
const userAccessToken = await this.accessTokenService.getUserPAT(token);
|
||||
if (E.isLeft(userAccessToken))
|
||||
throw new BadRequestException(
|
||||
createCLIErrorResponse(ACCESS_TOKEN_INVALID),
|
||||
);
|
||||
request.user = userAccessToken.right.user;
|
||||
|
||||
const accessToken = userAccessToken.right;
|
||||
if (accessToken.expiresOn === null) return true;
|
||||
|
||||
const today = DateTime.now().toISO();
|
||||
if (accessToken.expiresOn.toISOString() > today) return true;
|
||||
|
||||
throw new BadRequestException(
|
||||
createCLIErrorResponse(ACCESS_TOKEN_EXPIRED),
|
||||
);
|
||||
}
|
||||
|
||||
private extractTokenFromHeader(request: Request): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
}
|
||||
@@ -33,10 +33,17 @@ const AuthProviderConfigurations = {
|
||||
InfraConfigEnum.MICROSOFT_SCOPE,
|
||||
InfraConfigEnum.MICROSOFT_TENANT,
|
||||
],
|
||||
[AuthProvider.EMAIL]: [
|
||||
InfraConfigEnum.MAILER_SMTP_URL,
|
||||
InfraConfigEnum.MAILER_ADDRESS_FROM,
|
||||
],
|
||||
[AuthProvider.EMAIL]: !!process.env.MAILER_USE_CUSTOM_CONFIGS
|
||||
? [
|
||||
InfraConfigEnum.MAILER_SMTP_HOST,
|
||||
InfraConfigEnum.MAILER_SMTP_PORT,
|
||||
InfraConfigEnum.MAILER_SMTP_SECURE,
|
||||
InfraConfigEnum.MAILER_SMTP_USER,
|
||||
InfraConfigEnum.MAILER_SMTP_PASSWORD,
|
||||
InfraConfigEnum.MAILER_TLS_REJECT_UNAUTHORIZED,
|
||||
InfraConfigEnum.MAILER_ADDRESS_FROM,
|
||||
]
|
||||
: [InfraConfigEnum.MAILER_SMTP_URL, InfraConfigEnum.MAILER_ADDRESS_FROM],
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -75,6 +82,14 @@ export async function getDefaultInfraConfigs(): Promise<
|
||||
|
||||
// Prepare rows for 'infra_config' table with default values (from .env) for each 'name'
|
||||
const infraConfigDefaultObjs: { name: InfraConfigEnum; value: string }[] = [
|
||||
{
|
||||
name: InfraConfigEnum.MAILER_SMTP_ENABLE,
|
||||
value: process.env.MAILER_SMTP_ENABLE ?? 'true',
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MAILER_USE_CUSTOM_CONFIGS,
|
||||
value: process.env.MAILER_USE_CUSTOM_CONFIGS ?? 'false',
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MAILER_SMTP_URL,
|
||||
value: process.env.MAILER_SMTP_URL,
|
||||
@@ -83,6 +98,30 @@ export async function getDefaultInfraConfigs(): Promise<
|
||||
name: InfraConfigEnum.MAILER_ADDRESS_FROM,
|
||||
value: process.env.MAILER_ADDRESS_FROM,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MAILER_SMTP_HOST,
|
||||
value: process.env.MAILER_SMTP_HOST,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MAILER_SMTP_PORT,
|
||||
value: process.env.MAILER_SMTP_PORT,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MAILER_SMTP_SECURE,
|
||||
value: process.env.MAILER_SMTP_SECURE,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MAILER_SMTP_USER,
|
||||
value: process.env.MAILER_SMTP_USER,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MAILER_SMTP_PASSWORD,
|
||||
value: process.env.MAILER_SMTP_PASSWORD,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MAILER_TLS_REJECT_UNAUTHORIZED,
|
||||
value: process.env.MAILER_TLS_REJECT_UNAUTHORIZED,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.GOOGLE_CLIENT_ID,
|
||||
value: process.env.GOOGLE_CLIENT_ID,
|
||||
|
||||
@@ -2,10 +2,11 @@ import { Module } from '@nestjs/common';
|
||||
import { InfraConfigService } from './infra-config.service';
|
||||
import { PrismaModule } from 'src/prisma/prisma.module';
|
||||
import { SiteController } from './infra-config.controller';
|
||||
import { InfraConfigResolver } from './infra-config.resolver';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [InfraConfigService],
|
||||
providers: [InfraConfigResolver, InfraConfigService],
|
||||
exports: [InfraConfigService],
|
||||
controllers: [SiteController],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { Query, Resolver } from '@nestjs/graphql';
|
||||
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
|
||||
import { InfraConfig } from './infra-config.model';
|
||||
import { InfraConfigService } from './infra-config.service';
|
||||
import { GqlAuthGuard } from 'src/guards/gql-auth.guard';
|
||||
|
||||
@UseGuards(GqlThrottlerGuard)
|
||||
@Resolver(() => InfraConfig)
|
||||
export class InfraConfigResolver {
|
||||
constructor(private infraConfigService: InfraConfigService) {}
|
||||
|
||||
@Query(() => Boolean, {
|
||||
description: 'Check if the SMTP is enabled or not',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard)
|
||||
isSMTPEnabled() {
|
||||
return this.infraConfigService.isSMTPEnabled();
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ export class InfraConfigService implements OnModuleInit {
|
||||
InfraConfigEnum.ALLOW_ANALYTICS_COLLECTION,
|
||||
InfraConfigEnum.ANALYTICS_USER_ID,
|
||||
InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
|
||||
InfraConfigEnum.MAILER_SMTP_ENABLE,
|
||||
];
|
||||
// Following fields can not be fetched by `infraConfigs` Query. Use dedicated queries for these fields instead.
|
||||
EXCLUDE_FROM_FETCH_CONFIGS = [
|
||||
@@ -196,7 +197,20 @@ export class InfraConfigService implements OnModuleInit {
|
||||
configMap.MICROSOFT_TENANT
|
||||
);
|
||||
case AuthProvider.EMAIL:
|
||||
return configMap.MAILER_SMTP_URL && configMap.MAILER_ADDRESS_FROM;
|
||||
if (configMap.MAILER_SMTP_ENABLE !== 'true') return false;
|
||||
if (configMap.MAILER_USE_CUSTOM_CONFIGS === 'true') {
|
||||
return (
|
||||
configMap.MAILER_SMTP_HOST &&
|
||||
configMap.MAILER_SMTP_PORT &&
|
||||
configMap.MAILER_SMTP_SECURE &&
|
||||
configMap.MAILER_SMTP_USER &&
|
||||
configMap.MAILER_SMTP_PASSWORD &&
|
||||
configMap.MAILER_TLS_REJECT_UNAUTHORIZED &&
|
||||
configMap.MAILER_ADDRESS_FROM
|
||||
);
|
||||
} else {
|
||||
return configMap.MAILER_SMTP_URL && configMap.MAILER_ADDRESS_FROM;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@@ -218,6 +232,47 @@ export class InfraConfigService implements OnModuleInit {
|
||||
return E.right(isUpdated.right.value === 'true');
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or Disable SMTP
|
||||
* @param status Status to enable or disable
|
||||
* @returns Either true or an error
|
||||
*/
|
||||
async enableAndDisableSMTP(status: ServiceStatus) {
|
||||
const isUpdated = await this.toggleServiceStatus(
|
||||
InfraConfigEnum.MAILER_SMTP_ENABLE,
|
||||
status,
|
||||
true,
|
||||
);
|
||||
if (E.isLeft(isUpdated)) return E.left(isUpdated.left);
|
||||
|
||||
if (status === ServiceStatus.DISABLE) {
|
||||
this.enableAndDisableSSO([{ provider: AuthProvider.EMAIL, status }]);
|
||||
}
|
||||
return E.right(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or Disable Service (i.e. ALLOW_AUDIT_LOGS, ALLOW_ANALYTICS_COLLECTION, ALLOW_DOMAIN_WHITELISTING, SITE_PROTECTION)
|
||||
* @param configName Name of the InfraConfigEnum
|
||||
* @param status Status to enable or disable
|
||||
* @param restartEnabled If true, restart the app after updating the InfraConfig
|
||||
* @returns Either true or an error
|
||||
*/
|
||||
async toggleServiceStatus(
|
||||
configName: InfraConfigEnum,
|
||||
status: ServiceStatus,
|
||||
restartEnabled = false,
|
||||
) {
|
||||
const isUpdated = await this.update(
|
||||
configName,
|
||||
status === ServiceStatus.ENABLE ? 'true' : 'false',
|
||||
restartEnabled,
|
||||
);
|
||||
if (E.isLeft(isUpdated)) return E.left(isUpdated.left);
|
||||
|
||||
return E.right(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or Disable SSO for login/signup
|
||||
* @param provider Auth Provider to enable or disable
|
||||
@@ -316,6 +371,16 @@ export class InfraConfigService implements OnModuleInit {
|
||||
.split(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if SMTP is enabled or not
|
||||
* @returns boolean
|
||||
*/
|
||||
isSMTPEnabled() {
|
||||
return (
|
||||
this.configService.get<string>('INFRA.MAILER_SMTP_ENABLE') === 'true'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all the InfraConfigs to their default values (from .env)
|
||||
*/
|
||||
@@ -363,6 +428,20 @@ export class InfraConfigService implements OnModuleInit {
|
||||
) {
|
||||
for (let i = 0; i < infraConfigs.length; i++) {
|
||||
switch (infraConfigs[i].name) {
|
||||
case InfraConfigEnum.MAILER_SMTP_ENABLE:
|
||||
if (
|
||||
infraConfigs[i].value !== 'true' &&
|
||||
infraConfigs[i].value !== 'false'
|
||||
)
|
||||
return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
case InfraConfigEnum.MAILER_USE_CUSTOM_CONFIGS:
|
||||
if (
|
||||
infraConfigs[i].value !== 'true' &&
|
||||
infraConfigs[i].value !== 'false'
|
||||
)
|
||||
return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
case InfraConfigEnum.MAILER_SMTP_URL:
|
||||
const isValidUrl = validateSMTPUrl(infraConfigs[i].value);
|
||||
if (!isValidUrl) return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
@@ -371,6 +450,32 @@ export class InfraConfigService implements OnModuleInit {
|
||||
const isValidEmail = validateSMTPEmail(infraConfigs[i].value);
|
||||
if (!isValidEmail) return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
case InfraConfigEnum.MAILER_SMTP_HOST:
|
||||
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
case InfraConfigEnum.MAILER_SMTP_PORT:
|
||||
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
case InfraConfigEnum.MAILER_SMTP_SECURE:
|
||||
if (
|
||||
infraConfigs[i].value !== 'true' &&
|
||||
infraConfigs[i].value !== 'false'
|
||||
)
|
||||
return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
case InfraConfigEnum.MAILER_SMTP_USER:
|
||||
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
case InfraConfigEnum.MAILER_SMTP_PASSWORD:
|
||||
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
case InfraConfigEnum.MAILER_TLS_REJECT_UNAUTHORIZED:
|
||||
if (
|
||||
infraConfigs[i].value !== 'true' &&
|
||||
infraConfigs[i].value !== 'false'
|
||||
)
|
||||
return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
case InfraConfigEnum.GOOGLE_CLIENT_ID:
|
||||
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { AdminService } from 'src/admin/admin.service';
|
||||
import { InfraTokenGuard } from 'src/guards/infra-token.guard';
|
||||
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
|
||||
import {
|
||||
DeleteUserInvitationRequest,
|
||||
DeleteUserInvitationResponse,
|
||||
ExceptionResponse,
|
||||
GetUserInvitationResponse,
|
||||
GetUsersRequestQuery,
|
||||
GetUserResponse,
|
||||
UpdateUserRequest,
|
||||
UpdateUserAdminStatusRequest,
|
||||
UpdateUserAdminStatusResponse,
|
||||
CreateUserInvitationRequest,
|
||||
CreateUserInvitationResponse,
|
||||
} from './request-response.dto';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import * as O from 'fp-ts/Option';
|
||||
import { OffsetPaginationArgs } from 'src/types/input-types.args';
|
||||
import {
|
||||
ApiBadRequestResponse,
|
||||
ApiCreatedResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiSecurity,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { throwHTTPErr } from 'src/utils';
|
||||
import { UserService } from 'src/user/user.service';
|
||||
import {
|
||||
INFRA_TOKEN_CREATOR_NOT_FOUND,
|
||||
USER_NOT_FOUND,
|
||||
USERS_NOT_FOUND,
|
||||
} from 'src/errors';
|
||||
import { InfraTokenService } from './infra-token.service';
|
||||
import { InfraTokenInterceptor } from 'src/interceptors/infra-token.interceptor';
|
||||
import { BearerToken } from 'src/decorators/bearer-token.decorator';
|
||||
|
||||
@ApiTags('User Management API')
|
||||
@ApiSecurity('infra-token')
|
||||
@UseGuards(ThrottlerBehindProxyGuard, InfraTokenGuard)
|
||||
@UseInterceptors(InfraTokenInterceptor)
|
||||
@Controller({ path: 'infra', version: '1' })
|
||||
export class InfraTokensController {
|
||||
constructor(
|
||||
private readonly infraTokenService: InfraTokenService,
|
||||
private readonly adminService: AdminService,
|
||||
private readonly userService: UserService,
|
||||
) {}
|
||||
|
||||
@Post('user-invitations')
|
||||
@ApiCreatedResponse({
|
||||
description: 'Create a user invitation',
|
||||
type: CreateUserInvitationResponse,
|
||||
})
|
||||
@ApiBadRequestResponse({ type: ExceptionResponse })
|
||||
@ApiNotFoundResponse({ type: ExceptionResponse })
|
||||
async createUserInvitation(
|
||||
@BearerToken() token: string,
|
||||
@Body() dto: CreateUserInvitationRequest,
|
||||
) {
|
||||
const createdInvitations =
|
||||
await this.infraTokenService.createUserInvitation(token, dto);
|
||||
|
||||
if (E.isLeft(createdInvitations)) {
|
||||
const statusCode =
|
||||
(createdInvitations.left as string) === INFRA_TOKEN_CREATOR_NOT_FOUND
|
||||
? HttpStatus.NOT_FOUND
|
||||
: HttpStatus.BAD_REQUEST;
|
||||
|
||||
throwHTTPErr({ message: createdInvitations.left, statusCode });
|
||||
}
|
||||
|
||||
return plainToInstance(
|
||||
CreateUserInvitationResponse,
|
||||
{ invitationLink: process.env.VITE_BASE_URL },
|
||||
{
|
||||
excludeExtraneousValues: true,
|
||||
enableImplicitConversion: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@Get('user-invitations')
|
||||
@ApiOkResponse({
|
||||
description: 'Get pending user invitations',
|
||||
type: [GetUserInvitationResponse],
|
||||
})
|
||||
async getPendingUserInvitation(
|
||||
@Query() paginationQuery: OffsetPaginationArgs,
|
||||
) {
|
||||
const pendingInvitedUsers = await this.adminService.fetchInvitedUsers(
|
||||
paginationQuery,
|
||||
);
|
||||
|
||||
return plainToInstance(GetUserInvitationResponse, pendingInvitedUsers, {
|
||||
excludeExtraneousValues: true,
|
||||
enableImplicitConversion: true,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete('user-invitations')
|
||||
@ApiOkResponse({
|
||||
description: 'Delete a pending user invitation',
|
||||
type: DeleteUserInvitationResponse,
|
||||
})
|
||||
@ApiBadRequestResponse({ type: ExceptionResponse })
|
||||
async deleteUserInvitation(@Body() dto: DeleteUserInvitationRequest) {
|
||||
const isDeleted = await this.adminService.revokeUserInvitations(
|
||||
dto.inviteeEmails,
|
||||
);
|
||||
|
||||
if (E.isLeft(isDeleted)) {
|
||||
throwHTTPErr({
|
||||
message: isDeleted.left,
|
||||
statusCode: HttpStatus.BAD_REQUEST,
|
||||
});
|
||||
}
|
||||
|
||||
return plainToInstance(
|
||||
DeleteUserInvitationResponse,
|
||||
{ message: isDeleted.right },
|
||||
{
|
||||
excludeExtraneousValues: true,
|
||||
enableImplicitConversion: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@Get('users')
|
||||
@ApiOkResponse({
|
||||
description: 'Get users list',
|
||||
type: [GetUserResponse],
|
||||
})
|
||||
async getUsers(@Query() query: GetUsersRequestQuery) {
|
||||
const users = await this.userService.fetchAllUsersV2(query.searchString, {
|
||||
take: query.take,
|
||||
skip: query.skip,
|
||||
});
|
||||
|
||||
return plainToInstance(GetUserResponse, users, {
|
||||
excludeExtraneousValues: true,
|
||||
enableImplicitConversion: true,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('users/:uid')
|
||||
@ApiOkResponse({
|
||||
description: 'Get user details',
|
||||
type: GetUserResponse,
|
||||
})
|
||||
@ApiNotFoundResponse({ type: ExceptionResponse })
|
||||
async getUser(@Param('uid') uid: string) {
|
||||
const user = await this.userService.findUserById(uid);
|
||||
|
||||
if (O.isNone(user)) {
|
||||
throwHTTPErr({
|
||||
message: USER_NOT_FOUND,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
}
|
||||
|
||||
return plainToInstance(GetUserResponse, user.value, {
|
||||
excludeExtraneousValues: true,
|
||||
enableImplicitConversion: true,
|
||||
});
|
||||
}
|
||||
|
||||
@Patch('users/:uid')
|
||||
@ApiOkResponse({
|
||||
description: 'Update user display name',
|
||||
type: GetUserResponse,
|
||||
})
|
||||
@ApiBadRequestResponse({ type: ExceptionResponse })
|
||||
@ApiNotFoundResponse({ type: ExceptionResponse })
|
||||
async updateUser(@Param('uid') uid: string, @Body() body: UpdateUserRequest) {
|
||||
const updatedUser = await this.userService.updateUserDisplayName(
|
||||
uid,
|
||||
body.displayName,
|
||||
);
|
||||
|
||||
if (E.isLeft(updatedUser)) {
|
||||
const statusCode =
|
||||
(updatedUser.left as string) === USER_NOT_FOUND
|
||||
? HttpStatus.NOT_FOUND
|
||||
: HttpStatus.BAD_REQUEST;
|
||||
|
||||
throwHTTPErr({ message: updatedUser.left, statusCode });
|
||||
}
|
||||
|
||||
return plainToInstance(GetUserResponse, updatedUser.right, {
|
||||
excludeExtraneousValues: true,
|
||||
enableImplicitConversion: true,
|
||||
});
|
||||
}
|
||||
|
||||
@Patch('users/:uid/admin-status')
|
||||
@ApiOkResponse({
|
||||
description: 'Update user admin status',
|
||||
type: UpdateUserAdminStatusResponse,
|
||||
})
|
||||
@ApiBadRequestResponse({ type: ExceptionResponse })
|
||||
@ApiNotFoundResponse({ type: ExceptionResponse })
|
||||
async updateUserAdminStatus(
|
||||
@Param('uid') uid: string,
|
||||
@Body() body: UpdateUserAdminStatusRequest,
|
||||
) {
|
||||
let updatedUser;
|
||||
|
||||
if (body.isAdmin) {
|
||||
updatedUser = await this.adminService.makeUsersAdmin([uid]);
|
||||
} else {
|
||||
updatedUser = await this.adminService.demoteUsersByAdmin([uid]);
|
||||
}
|
||||
|
||||
if (E.isLeft(updatedUser)) {
|
||||
const statusCode =
|
||||
(updatedUser.left as string) === USERS_NOT_FOUND
|
||||
? HttpStatus.NOT_FOUND
|
||||
: HttpStatus.BAD_REQUEST;
|
||||
|
||||
throwHTTPErr({ message: updatedUser.left as string, statusCode });
|
||||
}
|
||||
|
||||
return plainToInstance(
|
||||
UpdateUserAdminStatusResponse,
|
||||
{ message: updatedUser.right },
|
||||
{
|
||||
excludeExtraneousValues: true,
|
||||
enableImplicitConversion: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Field, ID, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType()
|
||||
export class InfraToken {
|
||||
@Field(() => ID, {
|
||||
description: 'ID of the infra token',
|
||||
})
|
||||
id: string;
|
||||
|
||||
@Field(() => String, {
|
||||
description: 'Label of the infra token',
|
||||
})
|
||||
label: string;
|
||||
|
||||
@Field(() => Date, {
|
||||
description: 'Date when the infra token was created',
|
||||
})
|
||||
createdOn: Date;
|
||||
|
||||
@Field(() => Date, {
|
||||
description: 'Date when the infra token expires',
|
||||
nullable: true,
|
||||
})
|
||||
expiresOn: Date;
|
||||
|
||||
@Field(() => Date, {
|
||||
description: 'Date when the infra token was last used',
|
||||
})
|
||||
lastUsedOn: Date;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class CreateInfraTokenResponse {
|
||||
@Field(() => String, {
|
||||
description: 'The infra token',
|
||||
})
|
||||
token: string;
|
||||
|
||||
@Field(() => InfraToken, {
|
||||
description: 'Infra token info',
|
||||
})
|
||||
info: InfraToken;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from 'src/prisma/prisma.module';
|
||||
import { InfraTokenResolver } from './infra-token.resolver';
|
||||
import { InfraTokenService } from './infra-token.service';
|
||||
import { InfraTokensController } from './infra-token.controller';
|
||||
import { AdminModule } from 'src/admin/admin.module';
|
||||
import { UserModule } from 'src/user/user.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, AdminModule, UserModule],
|
||||
controllers: [InfraTokensController],
|
||||
providers: [InfraTokenResolver, InfraTokenService],
|
||||
})
|
||||
export class InfraTokenModule {}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Args, ID, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
import { CreateInfraTokenResponse, InfraToken } from './infra-token.model';
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
|
||||
import { InfraTokenService } from './infra-token.service';
|
||||
import { GqlAuthGuard } from 'src/guards/gql-auth.guard';
|
||||
import { GqlAdminGuard } from 'src/admin/guards/gql-admin.guard';
|
||||
import { OffsetPaginationArgs } from 'src/types/input-types.args';
|
||||
import { GqlAdmin } from 'src/admin/decorators/gql-admin.decorator';
|
||||
import { Admin } from 'src/admin/admin.model';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { throwErr } from 'src/utils';
|
||||
|
||||
@UseGuards(GqlThrottlerGuard)
|
||||
@Resolver(() => InfraToken)
|
||||
export class InfraTokenResolver {
|
||||
constructor(private readonly infraTokenService: InfraTokenService) {}
|
||||
|
||||
/* Query */
|
||||
|
||||
@Query(() => [InfraToken], {
|
||||
description: 'Get list of infra tokens',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
infraTokens(@Args() args: OffsetPaginationArgs) {
|
||||
return this.infraTokenService.getAll(args.take, args.skip);
|
||||
}
|
||||
|
||||
/* Mutations */
|
||||
|
||||
@Mutation(() => CreateInfraTokenResponse, {
|
||||
description: 'Create a new infra token',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async createInfraToken(
|
||||
@GqlAdmin() admin: Admin,
|
||||
@Args({ name: 'label', description: 'Label of the token' }) label: string,
|
||||
@Args({
|
||||
name: 'expiryInDays',
|
||||
description: 'Number of days the token is valid for',
|
||||
nullable: true,
|
||||
})
|
||||
expiryInDays: number,
|
||||
) {
|
||||
const infraToken = await this.infraTokenService.create(
|
||||
label,
|
||||
expiryInDays,
|
||||
admin,
|
||||
);
|
||||
|
||||
if (E.isLeft(infraToken)) throwErr(infraToken.left);
|
||||
return infraToken.right;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Revoke an infra token',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async revokeInfraToken(
|
||||
@Args({ name: 'id', type: () => ID, description: 'ID of the infra token' })
|
||||
id: string,
|
||||
) {
|
||||
const res = await this.infraTokenService.revoke(id);
|
||||
|
||||
if (E.isLeft(res)) throwErr(res.left);
|
||||
return res.right;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InfraToken as dbInfraToken } from '@prisma/client';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { CreateInfraTokenResponse, InfraToken } from './infra-token.model';
|
||||
import { calculateExpirationDate, isValidLength } from 'src/utils';
|
||||
import { Admin } from 'src/admin/admin.model';
|
||||
import {
|
||||
INFRA_TOKEN_CREATOR_NOT_FOUND,
|
||||
INFRA_TOKEN_EXPIRY_INVALID,
|
||||
INFRA_TOKEN_LABEL_SHORT,
|
||||
INFRA_TOKEN_NOT_FOUND,
|
||||
} from 'src/errors';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { CreateUserInvitationRequest } from './request-response.dto';
|
||||
import { AdminService } from 'src/admin/admin.service';
|
||||
|
||||
@Injectable()
|
||||
export class InfraTokenService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly adminService: AdminService,
|
||||
) {}
|
||||
|
||||
TITLE_LENGTH = 3;
|
||||
VALID_TOKEN_DURATIONS = [7, 30, 60, 90];
|
||||
|
||||
/**
|
||||
* Validate the expiration date of the token
|
||||
*
|
||||
* @param expiresOn Number of days the token is valid for
|
||||
* @returns Boolean indicating if the expiration date is valid
|
||||
*/
|
||||
private validateExpirationDate(expiresOn: null | number) {
|
||||
if (expiresOn === null || this.VALID_TOKEN_DURATIONS.includes(expiresOn))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typecast a database InfraToken to a InfraToken model
|
||||
* @param dbInfraToken database InfraToken
|
||||
* @returns InfraToken model
|
||||
*/
|
||||
private cast(dbInfraToken: dbInfraToken): InfraToken {
|
||||
return {
|
||||
id: dbInfraToken.id,
|
||||
label: dbInfraToken.label,
|
||||
createdOn: dbInfraToken.createdOn,
|
||||
expiresOn: dbInfraToken.expiresOn,
|
||||
lastUsedOn: dbInfraToken.updatedOn,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all infra tokens with pagination
|
||||
* @param take take for pagination
|
||||
* @param skip skip for pagination
|
||||
* @returns List of InfraToken models
|
||||
*/
|
||||
async getAll(take = 10, skip = 0) {
|
||||
const infraTokens = await this.prisma.infraToken.findMany({
|
||||
take,
|
||||
skip,
|
||||
orderBy: { createdOn: 'desc' },
|
||||
});
|
||||
|
||||
return infraTokens.map((token) => this.cast(token));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new infra token
|
||||
* @param label label of the token
|
||||
* @param expiryInDays expiry duration of the token
|
||||
* @param admin admin who created the token
|
||||
* @returns Either of error message or CreateInfraTokenResponse
|
||||
*/
|
||||
async create(label: string, expiryInDays: number, admin: Admin) {
|
||||
if (!isValidLength(label, this.TITLE_LENGTH)) {
|
||||
return E.left(INFRA_TOKEN_LABEL_SHORT);
|
||||
}
|
||||
|
||||
if (!this.validateExpirationDate(expiryInDays ?? null)) {
|
||||
return E.left(INFRA_TOKEN_EXPIRY_INVALID);
|
||||
}
|
||||
|
||||
const createdInfraToken = await this.prisma.infraToken.create({
|
||||
data: {
|
||||
creatorUid: admin.uid,
|
||||
label,
|
||||
expiresOn: calculateExpirationDate(expiryInDays ?? null) ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const res: CreateInfraTokenResponse = {
|
||||
token: createdInfraToken.token,
|
||||
info: this.cast(createdInfraToken),
|
||||
};
|
||||
|
||||
return E.right(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke an infra token
|
||||
* @param id ID of the infra token
|
||||
* @returns Either of error or true
|
||||
*/
|
||||
async revoke(id: string) {
|
||||
try {
|
||||
await this.prisma.infraToken.delete({
|
||||
where: { id },
|
||||
});
|
||||
} catch (error) {
|
||||
return E.left(INFRA_TOKEN_NOT_FOUND);
|
||||
}
|
||||
return E.right(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the last used on of an infra token
|
||||
* @param token token to update
|
||||
* @returns Either of error or InfraToken
|
||||
*/
|
||||
async updateLastUsedOn(token: string) {
|
||||
try {
|
||||
const infraToken = await this.prisma.infraToken.update({
|
||||
where: { token },
|
||||
data: { updatedOn: new Date() },
|
||||
});
|
||||
return E.right(this.cast(infraToken));
|
||||
} catch (error) {
|
||||
return E.left(INFRA_TOKEN_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a user invitation using an infra token
|
||||
* @param token token used to create the invitation
|
||||
* @param dto CreateUserInvitationRequest
|
||||
* @returns Either of error or InvitedUser
|
||||
*/
|
||||
async createUserInvitation(token: string, dto: CreateUserInvitationRequest) {
|
||||
const infraToken = await this.prisma.infraToken.findUnique({
|
||||
where: { token },
|
||||
});
|
||||
|
||||
const tokenCreator = await this.prisma.user.findUnique({
|
||||
where: { uid: infraToken.creatorUid },
|
||||
});
|
||||
if (!tokenCreator) return E.left(INFRA_TOKEN_CREATOR_NOT_FOUND);
|
||||
|
||||
const invitedUser = await this.adminService.inviteUserToSignInViaEmail(
|
||||
tokenCreator.uid,
|
||||
tokenCreator.email,
|
||||
dto.inviteeEmail,
|
||||
);
|
||||
if (E.isLeft(invitedUser)) return E.left(invitedUser.left);
|
||||
|
||||
return E.right(invitedUser);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Expose, Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEmail,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { OffsetPaginationArgs } from 'src/types/input-types.args';
|
||||
|
||||
// POST v1/infra/user-invitations
|
||||
export class CreateUserInvitationRequest {
|
||||
@Type(() => String)
|
||||
@IsNotEmpty()
|
||||
@ApiProperty()
|
||||
inviteeEmail: string;
|
||||
}
|
||||
export class CreateUserInvitationResponse {
|
||||
@ApiProperty()
|
||||
@Expose()
|
||||
invitationLink: string;
|
||||
}
|
||||
|
||||
// GET v1/infra/user-invitations
|
||||
export class GetUserInvitationResponse {
|
||||
@ApiProperty()
|
||||
@Expose()
|
||||
inviteeEmail: string;
|
||||
|
||||
@ApiProperty()
|
||||
@Expose()
|
||||
invitedOn: Date;
|
||||
}
|
||||
|
||||
// DELETE v1/infra/user-invitations
|
||||
export class DeleteUserInvitationRequest {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@Type(() => String)
|
||||
@IsNotEmpty()
|
||||
@ApiProperty()
|
||||
inviteeEmails: string[];
|
||||
}
|
||||
export class DeleteUserInvitationResponse {
|
||||
@ApiProperty()
|
||||
@Expose()
|
||||
message: string;
|
||||
}
|
||||
|
||||
// POST v1/infra/users
|
||||
export class GetUsersRequestQuery extends OffsetPaginationArgs {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@ApiPropertyOptional()
|
||||
searchString: string;
|
||||
}
|
||||
export class GetUserResponse {
|
||||
@ApiProperty()
|
||||
@Expose()
|
||||
uid: string;
|
||||
|
||||
@ApiProperty()
|
||||
@Expose()
|
||||
displayName: string;
|
||||
|
||||
@ApiProperty()
|
||||
@Expose()
|
||||
email: string;
|
||||
|
||||
@ApiProperty()
|
||||
@Expose()
|
||||
photoURL: string;
|
||||
|
||||
@ApiProperty()
|
||||
@Expose()
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
// PATCH v1/infra/users/:uid
|
||||
export class UpdateUserRequest {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@ApiPropertyOptional()
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
// PATCH v1/infra/users/:uid/admin-status
|
||||
export class UpdateUserAdminStatusRequest {
|
||||
@IsBoolean()
|
||||
@IsNotEmpty()
|
||||
@ApiProperty()
|
||||
isAdmin: boolean;
|
||||
}
|
||||
export class UpdateUserAdminStatusResponse {
|
||||
@ApiProperty()
|
||||
@Expose()
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Used for Swagger doc only, in codebase throwHTTPErr function is used to throw errors
|
||||
export class ExceptionResponse {
|
||||
@ApiProperty()
|
||||
@Expose()
|
||||
message: string;
|
||||
|
||||
@ApiProperty()
|
||||
@Expose()
|
||||
statusCode: number;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable, map } from 'rxjs';
|
||||
import { AccessTokenService } from 'src/access-token/access-token.service';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { ACCESS_TOKEN_NOT_FOUND } from 'src/errors';
|
||||
|
||||
@Injectable()
|
||||
export class AccessTokenInterceptor implements NestInterceptor {
|
||||
constructor(private readonly accessTokenService: AccessTokenService) {}
|
||||
|
||||
intercept(context: ExecutionContext, handler: CallHandler): Observable<any> {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const authHeader = req.headers.authorization;
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
if (!token) {
|
||||
throw new BadRequestException(ACCESS_TOKEN_NOT_FOUND);
|
||||
}
|
||||
|
||||
return handler.handle().pipe(
|
||||
map(async (data) => {
|
||||
const userAccessToken =
|
||||
await this.accessTokenService.updateLastUsedForPAT(token);
|
||||
if (E.isLeft(userAccessToken))
|
||||
throw new BadRequestException(userAccessToken.left);
|
||||
|
||||
return data;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { INFRA_TOKEN_NOT_FOUND } from 'src/errors';
|
||||
import { InfraTokenService } from 'src/infra-token/infra-token.service';
|
||||
|
||||
@Injectable()
|
||||
export class InfraTokenInterceptor implements NestInterceptor {
|
||||
constructor(private readonly infraTokenService: InfraTokenService) {}
|
||||
|
||||
intercept(context: ExecutionContext, handler: CallHandler): Observable<any> {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
throw new BadRequestException(INFRA_TOKEN_NOT_FOUND);
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
|
||||
this.infraTokenService.updateLastUsedOn(token);
|
||||
|
||||
return handler.handle();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
} from '@nestjs/common';
|
||||
import { GqlContextType, GqlExecutionContext } from '@nestjs/graphql';
|
||||
import { Observable, throwError } from 'rxjs';
|
||||
import { catchError, tap } from 'rxjs/operators';
|
||||
import { AuthUser } from 'src/types/AuthUser';
|
||||
import { UserService } from 'src/user/user.service';
|
||||
|
||||
@Injectable()
|
||||
export class UserLastActiveOnInterceptor implements NestInterceptor {
|
||||
constructor(private userService: UserService) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
if (context.getType() === 'http') {
|
||||
return this.restHandler(context, next);
|
||||
} else if (context.getType<GqlContextType>() === 'graphql') {
|
||||
return this.graphqlHandler(context, next);
|
||||
}
|
||||
}
|
||||
|
||||
restHandler(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user: AuthUser = request.user;
|
||||
|
||||
return next.handle().pipe(
|
||||
tap(() => {
|
||||
if (user && typeof user === 'object') {
|
||||
this.userService.updateUserLastActiveOn(user.uid);
|
||||
}
|
||||
}),
|
||||
catchError((error) => {
|
||||
if (user && typeof user === 'object') {
|
||||
this.userService.updateUserLastActiveOn(user.uid);
|
||||
}
|
||||
return throwError(() => error);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
graphqlHandler(
|
||||
context: ExecutionContext,
|
||||
next: CallHandler,
|
||||
): Observable<any> {
|
||||
const contextObject = GqlExecutionContext.create(context).getContext();
|
||||
const user: AuthUser = contextObject?.req?.user;
|
||||
|
||||
return next.handle().pipe(
|
||||
tap(() => {
|
||||
if (user && typeof user === 'object') {
|
||||
this.userService.updateUserLastActiveOn(user.uid);
|
||||
}
|
||||
}),
|
||||
catchError((error) => {
|
||||
if (user && typeof user === 'object') {
|
||||
this.userService.updateUserLastActiveOn(user.uid);
|
||||
}
|
||||
return throwError(() => error);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { AuthUser } from 'src/types/AuthUser';
|
||||
import { UserService } from 'src/user/user.service';
|
||||
|
||||
@Injectable()
|
||||
export class UserLastLoginInterceptor implements NestInterceptor {
|
||||
constructor(private userService: UserService) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
const user: AuthUser = context.switchToHttp().getRequest().user;
|
||||
|
||||
return next.handle().pipe(
|
||||
tap(() => {
|
||||
this.userService.updateUserLastLoggedOn(user.uid);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
59
packages/hoppscotch-backend/src/mailer/helper.ts
Normal file
59
packages/hoppscotch-backend/src/mailer/helper.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { TransportType } from '@nestjs-modules/mailer/dist/interfaces/mailer-options.interface';
|
||||
import {
|
||||
MAILER_SMTP_PASSWORD_UNDEFINED,
|
||||
MAILER_SMTP_URL_UNDEFINED,
|
||||
MAILER_SMTP_USER_UNDEFINED,
|
||||
} from 'src/errors';
|
||||
import { throwErr } from 'src/utils';
|
||||
|
||||
function isSMTPCustomConfigsEnabled(value) {
|
||||
return value === 'true';
|
||||
}
|
||||
|
||||
export function getMailerAddressFrom(env, config): string {
|
||||
return (
|
||||
env.INFRA.MAILER_ADDRESS_FROM ??
|
||||
config.get('MAILER_ADDRESS_FROM') ??
|
||||
throwErr(MAILER_SMTP_URL_UNDEFINED)
|
||||
);
|
||||
}
|
||||
|
||||
export function getTransportOption(env, config): TransportType {
|
||||
const useCustomConfigs = isSMTPCustomConfigsEnabled(
|
||||
env.INFRA.MAILER_USE_CUSTOM_CONFIGS ??
|
||||
config.get('MAILER_USE_CUSTOM_CONFIGS'),
|
||||
);
|
||||
|
||||
if (!useCustomConfigs) {
|
||||
console.log('Using simple mailer configuration');
|
||||
return (
|
||||
env.INFRA.MAILER_SMTP_URL ??
|
||||
config.get('MAILER_SMTP_URL') ??
|
||||
throwErr(MAILER_SMTP_URL_UNDEFINED)
|
||||
);
|
||||
} else {
|
||||
console.log('Using advanced mailer configuration');
|
||||
return {
|
||||
host: env.INFRA.MAILER_SMTP_HOST ?? config.get('MAILER_SMTP_HOST'),
|
||||
port: +env.INFRA.MAILER_SMTP_PORT ?? +config.get('MAILER_SMTP_PORT'),
|
||||
secure:
|
||||
(env.INFRA.MAILER_SMTP_SECURE ?? config.get('MAILER_SMTP_SECURE')) ===
|
||||
'true',
|
||||
auth: {
|
||||
user:
|
||||
env.INFRA.MAILER_SMTP_USER ??
|
||||
config.get('MAILER_SMTP_USER') ??
|
||||
throwErr(MAILER_SMTP_USER_UNDEFINED),
|
||||
pass:
|
||||
env.INFRA.MAILER_SMTP_PASSWORD ??
|
||||
config.get('MAILER_SMTP_PASSWORD') ??
|
||||
throwErr(MAILER_SMTP_PASSWORD_UNDEFINED),
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized:
|
||||
(env.INFRA.MAILER_TLS_REJECT_UNAUTHORIZED ??
|
||||
config.get('MAILER_TLS_REJECT_UNAUTHORIZED')) === 'true',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,9 @@ import { Global, Module } from '@nestjs/common';
|
||||
import { MailerModule as NestMailerModule } from '@nestjs-modules/mailer';
|
||||
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
|
||||
import { MailerService } from './mailer.service';
|
||||
import { throwErr } from 'src/utils';
|
||||
import {
|
||||
MAILER_FROM_ADDRESS_UNDEFINED,
|
||||
MAILER_SMTP_URL_UNDEFINED,
|
||||
} from 'src/errors';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { loadInfraConfiguration } from 'src/infra-config/helper';
|
||||
import { getMailerAddressFrom, getTransportOption } from './helper';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
@@ -18,24 +14,31 @@ import { loadInfraConfiguration } from 'src/infra-config/helper';
|
||||
})
|
||||
export class MailerModule {
|
||||
static async register() {
|
||||
const config = new ConfigService();
|
||||
const env = await loadInfraConfiguration();
|
||||
|
||||
let mailerSmtpUrl = env.INFRA.MAILER_SMTP_URL;
|
||||
let mailerAddressFrom = env.INFRA.MAILER_ADDRESS_FROM;
|
||||
|
||||
if (!env.INFRA.MAILER_SMTP_URL || !env.INFRA.MAILER_ADDRESS_FROM) {
|
||||
const config = new ConfigService();
|
||||
mailerSmtpUrl = config.get('MAILER_SMTP_URL');
|
||||
mailerAddressFrom = config.get('MAILER_ADDRESS_FROM');
|
||||
// If mailer SMTP is DISABLED, return the module without any configuration (service, listener, etc.)
|
||||
if (env.INFRA.MAILER_SMTP_ENABLE !== 'true') {
|
||||
console.log('Mailer module is disabled');
|
||||
return {
|
||||
module: MailerModule,
|
||||
};
|
||||
}
|
||||
|
||||
// If mailer is ENABLED, return the module with configuration (service, etc.)
|
||||
|
||||
// Determine transport configuration based on custom config flag
|
||||
let transportOption = getTransportOption(env, config);
|
||||
// Get mailer address from environment or config
|
||||
const mailerAddressFrom = getMailerAddressFrom(env, config);
|
||||
|
||||
return {
|
||||
module: MailerModule,
|
||||
imports: [
|
||||
NestMailerModule.forRoot({
|
||||
transport: mailerSmtpUrl ?? throwErr(MAILER_SMTP_URL_UNDEFINED),
|
||||
transport: transportOption,
|
||||
defaults: {
|
||||
from: mailerAddressFrom ?? throwErr(MAILER_FROM_ADDRESS_UNDEFINED),
|
||||
from: mailerAddressFrom,
|
||||
},
|
||||
template: {
|
||||
dir: __dirname + '/templates',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Optional } from '@nestjs/common';
|
||||
import {
|
||||
AdminUserInvitationMailDescription,
|
||||
MailDescription,
|
||||
@@ -7,10 +7,14 @@ import {
|
||||
import { throwErr } from 'src/utils';
|
||||
import { EMAIL_FAILED } from 'src/errors';
|
||||
import { MailerService as NestMailerService } from '@nestjs-modules/mailer';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class MailerService {
|
||||
constructor(private readonly nestMailerService: NestMailerService) {}
|
||||
constructor(
|
||||
@Optional() private readonly nestMailerService: NestMailerService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Takes an input mail description and spits out the Email subject required for it
|
||||
@@ -42,6 +46,8 @@ export class MailerService {
|
||||
to: string,
|
||||
mailDesc: MailDescription | UserMagicLinkMailDescription,
|
||||
) {
|
||||
if (this.configService.get('INFRA.MAILER_SMTP_ENABLE') !== 'true') return;
|
||||
|
||||
try {
|
||||
await this.nestMailerService.sendMail({
|
||||
to,
|
||||
@@ -50,6 +56,7 @@ export class MailerService {
|
||||
context: mailDesc.variables,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log('Error from sendEmail:', error);
|
||||
return throwErr(EMAIL_FAILED);
|
||||
}
|
||||
}
|
||||
@@ -64,6 +71,8 @@ export class MailerService {
|
||||
to: string,
|
||||
mailDesc: AdminUserInvitationMailDescription,
|
||||
) {
|
||||
if (this.configService.get('INFRA.MAILER_SMTP_ENABLE') !== 'true') return;
|
||||
|
||||
try {
|
||||
const res = await this.nestMailerService.sendMail({
|
||||
to,
|
||||
@@ -73,6 +82,7 @@ export class MailerService {
|
||||
});
|
||||
return res;
|
||||
} catch (error) {
|
||||
console.log('Error from sendUserInvitationEmail:', error);
|
||||
return throwErr(EMAIL_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,40 @@ import { NestFactory } from '@nestjs/core';
|
||||
import { json } from 'express';
|
||||
import { AppModule } from './app.module';
|
||||
import * as cookieParser from 'cookie-parser';
|
||||
import { VersioningType } from '@nestjs/common';
|
||||
import { ValidationPipe, VersioningType } from '@nestjs/common';
|
||||
import * as session from 'express-session';
|
||||
import { emitGQLSchemaFile } from './gql-schema';
|
||||
import { checkEnvironmentAuthProvider } from './utils';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { InfraTokensController } from './infra-token/infra-token.controller';
|
||||
import { InfraTokenModule } from './infra-token/infra-token.module';
|
||||
|
||||
function setupSwagger(app) {
|
||||
const swaggerDocPath = '/api-docs';
|
||||
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Hoppscotch API Documentation')
|
||||
.setDescription('APIs for external integration')
|
||||
.addApiKey(
|
||||
{
|
||||
type: 'apiKey',
|
||||
name: 'Authorization',
|
||||
in: 'header',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'Bearer',
|
||||
},
|
||||
'infra-token',
|
||||
)
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, config, {
|
||||
include: [InfraTokenModule],
|
||||
});
|
||||
SwaggerModule.setup(swaggerDocPath, app, document, {
|
||||
swaggerOptions: { persistAuthorization: true, ignoreGlobalPrefix: true },
|
||||
});
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
@@ -53,6 +82,14 @@ async function bootstrap() {
|
||||
type: VersioningType.URI,
|
||||
});
|
||||
app.use(cookieParser());
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await setupSwagger(app);
|
||||
|
||||
await app.listen(configService.get('PORT') || 3170);
|
||||
|
||||
// Graceful shutdown
|
||||
|
||||
@@ -48,6 +48,8 @@ const user: AuthUser = {
|
||||
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
|
||||
isAdmin: false,
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: createdOn,
|
||||
lastActiveOn: createdOn,
|
||||
createdOn: createdOn,
|
||||
currentGQLSession: {},
|
||||
currentRESTSession: {},
|
||||
|
||||
@@ -299,7 +299,10 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
|
||||
where: userEmail
|
||||
? {
|
||||
User: {
|
||||
email: userEmail,
|
||||
email: {
|
||||
equals: userEmail,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { TeamRequest } from '@prisma/client';
|
||||
|
||||
// Type of data returned from the query to obtain all search results
|
||||
export type SearchQueryReturnType = {
|
||||
id: string;
|
||||
@@ -12,3 +14,12 @@ export type ParentTreeQueryReturnType = {
|
||||
parentID: string;
|
||||
title: string;
|
||||
};
|
||||
// Type of data returned from the query to fetch collection details from CLI
|
||||
export type GetCollectionResponse = {
|
||||
id: string;
|
||||
data: string | null;
|
||||
title: string;
|
||||
parentID: string | null;
|
||||
folders: GetCollectionResponse[];
|
||||
requests: TeamRequest[];
|
||||
};
|
||||
|
||||
@@ -331,6 +331,26 @@ export class TeamCollectionResolver {
|
||||
return updatedTeamCollection.right;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Duplicate a Team Collection',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlCollectionTeamMemberGuard)
|
||||
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
|
||||
async duplicateTeamCollection(
|
||||
@Args({
|
||||
name: 'collectionID',
|
||||
description: 'ID of the collection',
|
||||
})
|
||||
collectionID: string,
|
||||
) {
|
||||
const duplicatedTeamCollection =
|
||||
await this.teamCollectionService.duplicateTeamCollection(collectionID);
|
||||
|
||||
if (E.isLeft(duplicatedTeamCollection))
|
||||
throwErr(duplicatedTeamCollection.left);
|
||||
return duplicatedTeamCollection.right;
|
||||
}
|
||||
|
||||
// Subscriptions
|
||||
|
||||
@Subscription(() => TeamCollection, {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
TEAM_COL_REORDERING_FAILED,
|
||||
TEAM_COL_SAME_NEXT_COLL,
|
||||
TEAM_INVALID_COLL_ID,
|
||||
TEAM_MEMBER_NOT_FOUND,
|
||||
TEAM_NOT_OWNER,
|
||||
} from 'src/errors';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
@@ -19,15 +20,18 @@ import { PubSubService } from 'src/pubsub/pubsub.service';
|
||||
import { AuthUser } from 'src/types/AuthUser';
|
||||
import { TeamCollectionService } from './team-collection.service';
|
||||
import { TeamCollection } from './team-collection.model';
|
||||
import { TeamService } from 'src/team/team.service';
|
||||
|
||||
const mockPrisma = mockDeep<PrismaService>();
|
||||
const mockPubSub = mockDeep<PubSubService>();
|
||||
const mockTeamService = mockDeep<TeamService>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const teamCollectionService = new TeamCollectionService(
|
||||
mockPrisma,
|
||||
mockPubSub as any,
|
||||
mockTeamService,
|
||||
);
|
||||
|
||||
const currentTime = new Date();
|
||||
@@ -39,6 +43,8 @@ const user: AuthUser = {
|
||||
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
|
||||
isAdmin: false,
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
lastActiveOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
currentGQLSession: {},
|
||||
currentRESTSession: {},
|
||||
@@ -1738,3 +1744,63 @@ describe('updateTeamCollection', () => {
|
||||
});
|
||||
|
||||
//ToDo: write test cases for exportCollectionsToJSON
|
||||
|
||||
describe('getCollectionForCLI', () => {
|
||||
test('should throw TEAM_COLL_NOT_FOUND if collectionID is invalid', async () => {
|
||||
mockPrisma.teamCollection.findUniqueOrThrow.mockRejectedValueOnce(
|
||||
'NotFoundError',
|
||||
);
|
||||
|
||||
const result = await teamCollectionService.getCollectionForCLI(
|
||||
'invalidID',
|
||||
user.uid,
|
||||
);
|
||||
expect(result).toEqualLeft(TEAM_COLL_NOT_FOUND);
|
||||
});
|
||||
|
||||
test('should throw TEAM_MEMBER_NOT_FOUND if user not in same team', async () => {
|
||||
mockPrisma.teamCollection.findUniqueOrThrow.mockResolvedValueOnce(
|
||||
rootTeamCollection,
|
||||
);
|
||||
mockTeamService.getTeamMember.mockResolvedValue(null);
|
||||
|
||||
const result = await teamCollectionService.getCollectionForCLI(
|
||||
rootTeamCollection.id,
|
||||
user.uid,
|
||||
);
|
||||
expect(result).toEqualLeft(TEAM_MEMBER_NOT_FOUND);
|
||||
});
|
||||
|
||||
// test('should return the TeamCollection data for CLI', async () => {
|
||||
// mockPrisma.teamCollection.findUniqueOrThrow.mockResolvedValueOnce(
|
||||
// rootTeamCollection,
|
||||
// );
|
||||
// mockTeamService.getTeamMember.mockResolvedValue({
|
||||
// membershipID: 'sdc3sfdv',
|
||||
// userUid: user.uid,
|
||||
// role: TeamMemberRole.OWNER,
|
||||
// });
|
||||
|
||||
// const result = await teamCollectionService.getCollectionForCLI(
|
||||
// rootTeamCollection.id,
|
||||
// user.uid,
|
||||
// );
|
||||
// expect(result).toEqualRight({
|
||||
// id: rootTeamCollection.id,
|
||||
// data: JSON.stringify(rootTeamCollection.data),
|
||||
// title: rootTeamCollection.title,
|
||||
// parentID: rootTeamCollection.parentID,
|
||||
// folders: [
|
||||
// {
|
||||
// id: childTeamCollection.id,
|
||||
// data: JSON.stringify(childTeamCollection.data),
|
||||
// title: childTeamCollection.title,
|
||||
// parentID: childTeamCollection.parentID,
|
||||
// folders: [],
|
||||
// requests: [],
|
||||
// },
|
||||
// ],
|
||||
// requests: [],
|
||||
// });
|
||||
// });
|
||||
});
|
||||
|
||||
@@ -18,23 +18,38 @@ import {
|
||||
TEAM_COL_SEARCH_FAILED,
|
||||
TEAM_REQ_PARENT_TREE_GEN_FAILED,
|
||||
TEAM_COLL_PARENT_TREE_GEN_FAILED,
|
||||
TEAM_MEMBER_NOT_FOUND,
|
||||
} from '../errors';
|
||||
import { PubSubService } from '../pubsub/pubsub.service';
|
||||
import { escapeSqlLikeString, isValidLength } from 'src/utils';
|
||||
import {
|
||||
escapeSqlLikeString,
|
||||
isValidLength,
|
||||
transformCollectionData,
|
||||
} from 'src/utils';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import * as O from 'fp-ts/Option';
|
||||
import { Prisma, TeamCollection as DBTeamCollection } from '@prisma/client';
|
||||
import {
|
||||
Prisma,
|
||||
TeamCollection as DBTeamCollection,
|
||||
TeamRequest,
|
||||
} from '@prisma/client';
|
||||
import { CollectionFolder } from 'src/types/CollectionFolder';
|
||||
import { stringToJson } from 'src/utils';
|
||||
import { CollectionSearchNode } from 'src/types/CollectionSearchNode';
|
||||
import { ParentTreeQueryReturnType, SearchQueryReturnType } from './helper';
|
||||
import {
|
||||
GetCollectionResponse,
|
||||
ParentTreeQueryReturnType,
|
||||
SearchQueryReturnType,
|
||||
} from './helper';
|
||||
import { RESTError } from 'src/types/RESTError';
|
||||
import { TeamService } from 'src/team/team.service';
|
||||
|
||||
@Injectable()
|
||||
export class TeamCollectionService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly pubsub: PubSubService,
|
||||
private readonly teamService: TeamService,
|
||||
) {}
|
||||
|
||||
TITLE_LENGTH = 3;
|
||||
@@ -123,11 +138,13 @@ export class TeamCollectionService {
|
||||
},
|
||||
});
|
||||
|
||||
const data = transformCollectionData(collection.right.data);
|
||||
|
||||
const result: CollectionFolder = {
|
||||
name: collection.right.title,
|
||||
folders: childrenCollectionObjects,
|
||||
requests: requests.map((x) => x.request),
|
||||
data: JSON.stringify(collection.right.data),
|
||||
data,
|
||||
};
|
||||
|
||||
return E.right(result);
|
||||
@@ -298,11 +315,13 @@ export class TeamCollectionService {
|
||||
* @returns TeamCollection model
|
||||
*/
|
||||
private cast(teamCollection: DBTeamCollection): TeamCollection {
|
||||
const data = transformCollectionData(teamCollection.data);
|
||||
|
||||
return <TeamCollection>{
|
||||
id: teamCollection.id,
|
||||
title: teamCollection.title,
|
||||
parentID: teamCollection.parentID,
|
||||
data: !teamCollection.data ? null : JSON.stringify(teamCollection.data),
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1344,4 +1363,126 @@ export class TeamCollectionService {
|
||||
return E.left(TEAM_REQ_PARENT_TREE_GEN_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all requests in a collection
|
||||
*
|
||||
* @param collectionID The Collection ID
|
||||
* @returns A list of all requests in the collection
|
||||
*/
|
||||
private async getAllRequestsInCollection(collectionID: string) {
|
||||
const dbTeamRequests = await this.prisma.teamRequest.findMany({
|
||||
where: {
|
||||
collectionID: collectionID,
|
||||
},
|
||||
orderBy: {
|
||||
orderIndex: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
const teamRequests = dbTeamRequests.map((tr) => {
|
||||
return <TeamRequest>{
|
||||
id: tr.id,
|
||||
collectionID: tr.collectionID,
|
||||
teamID: tr.teamID,
|
||||
title: tr.title,
|
||||
request: JSON.stringify(tr.request),
|
||||
};
|
||||
});
|
||||
|
||||
return teamRequests;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Collection Tree for CLI
|
||||
*
|
||||
* @param parentID The parent Collection ID
|
||||
* @returns Collection tree for CLI
|
||||
*/
|
||||
private async getCollectionTreeForCLI(parentID: string | null) {
|
||||
const childCollections = await this.prisma.teamCollection.findMany({
|
||||
where: { parentID },
|
||||
orderBy: { orderIndex: 'asc' },
|
||||
});
|
||||
|
||||
const response: GetCollectionResponse[] = [];
|
||||
|
||||
for (const collection of childCollections) {
|
||||
const folder: GetCollectionResponse = {
|
||||
id: collection.id,
|
||||
data: collection.data === null ? null : JSON.stringify(collection.data),
|
||||
title: collection.title,
|
||||
parentID: collection.parentID,
|
||||
folders: await this.getCollectionTreeForCLI(collection.id),
|
||||
requests: await this.getAllRequestsInCollection(collection.id),
|
||||
};
|
||||
|
||||
response.push(folder);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Collection for CLI
|
||||
*
|
||||
* @param collectionID The Collection ID
|
||||
* @param userUid The User UID
|
||||
* @returns An Either of the Collection details
|
||||
*/
|
||||
async getCollectionForCLI(collectionID: string, userUid: string) {
|
||||
try {
|
||||
const collection = await this.prisma.teamCollection.findUniqueOrThrow({
|
||||
where: { id: collectionID },
|
||||
});
|
||||
|
||||
const teamMember = await this.teamService.getTeamMember(
|
||||
collection.teamID,
|
||||
userUid,
|
||||
);
|
||||
if (!teamMember) return E.left(TEAM_MEMBER_NOT_FOUND);
|
||||
|
||||
return E.right(<GetCollectionResponse>{
|
||||
id: collection.id,
|
||||
data: collection.data === null ? null : JSON.stringify(collection.data),
|
||||
title: collection.title,
|
||||
parentID: collection.parentID,
|
||||
folders: await this.getCollectionTreeForCLI(collection.id),
|
||||
requests: await this.getAllRequestsInCollection(collection.id),
|
||||
});
|
||||
} catch (error) {
|
||||
return E.left(TEAM_COLL_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a Team Collection
|
||||
*
|
||||
* @param collectionID The Collection ID
|
||||
* @returns Boolean of duplication status
|
||||
*/
|
||||
async duplicateTeamCollection(collectionID: string) {
|
||||
const collection = await this.getCollection(collectionID);
|
||||
if (E.isLeft(collection)) return E.left(TEAM_INVALID_COLL_ID);
|
||||
|
||||
const collectionJSONObject = await this.exportCollectionToJSONObject(
|
||||
collection.right.teamID,
|
||||
collectionID,
|
||||
);
|
||||
if (E.isLeft(collectionJSONObject)) return E.left(TEAM_INVALID_COLL_ID);
|
||||
|
||||
const result = await this.importCollectionsFromJSON(
|
||||
JSON.stringify([
|
||||
{
|
||||
...collectionJSONObject.right,
|
||||
name: `${collection.right.title} - Duplicate`,
|
||||
},
|
||||
]),
|
||||
collection.right.teamID,
|
||||
collection.right.parentID,
|
||||
);
|
||||
if (E.isLeft(result)) return E.left(result.left as string);
|
||||
|
||||
return E.right(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,19 +6,24 @@ import {
|
||||
JSON_INVALID,
|
||||
TEAM_ENVIRONMENT_NOT_FOUND,
|
||||
TEAM_ENVIRONMENT_SHORT_NAME,
|
||||
TEAM_MEMBER_NOT_FOUND,
|
||||
} from 'src/errors';
|
||||
import { TeamService } from 'src/team/team.service';
|
||||
import { TeamMemberRole } from 'src/team/team.model';
|
||||
|
||||
const mockPrisma = mockDeep<PrismaService>();
|
||||
|
||||
const mockPubSub = {
|
||||
publish: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
const mockTeamService = mockDeep<TeamService>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const teamEnvironmentsService = new TeamEnvironmentsService(
|
||||
mockPrisma,
|
||||
mockPubSub as any,
|
||||
mockTeamService,
|
||||
);
|
||||
|
||||
const teamEnvironment = {
|
||||
@@ -380,4 +385,47 @@ describe('TeamEnvironmentsService', () => {
|
||||
expect(result).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTeamEnvironmentForCLI', () => {
|
||||
test('should successfully return a TeamEnvironment with valid ID', async () => {
|
||||
mockPrisma.teamEnvironment.findFirstOrThrow.mockResolvedValueOnce(
|
||||
teamEnvironment,
|
||||
);
|
||||
mockTeamService.getTeamMember.mockResolvedValue({
|
||||
membershipID: 'sdc3sfdv',
|
||||
userUid: '123454',
|
||||
role: TeamMemberRole.OWNER,
|
||||
});
|
||||
|
||||
const result = await teamEnvironmentsService.getTeamEnvironmentForCLI(
|
||||
teamEnvironment.id,
|
||||
'123454',
|
||||
);
|
||||
expect(result).toEqualRight(teamEnvironment);
|
||||
});
|
||||
|
||||
test('should throw TEAM_ENVIRONMENT_NOT_FOUND with invalid ID', async () => {
|
||||
mockPrisma.teamEnvironment.findFirstOrThrow.mockRejectedValueOnce(
|
||||
'RejectOnNotFound',
|
||||
);
|
||||
|
||||
const result = await teamEnvironmentsService.getTeamEnvironment(
|
||||
teamEnvironment.id,
|
||||
);
|
||||
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
|
||||
});
|
||||
|
||||
test('should throw TEAM_MEMBER_NOT_FOUND if user not in same team', async () => {
|
||||
mockPrisma.teamEnvironment.findFirstOrThrow.mockResolvedValueOnce(
|
||||
teamEnvironment,
|
||||
);
|
||||
mockTeamService.getTeamMember.mockResolvedValue(null);
|
||||
|
||||
const result = await teamEnvironmentsService.getTeamEnvironmentForCLI(
|
||||
teamEnvironment.id,
|
||||
'333',
|
||||
);
|
||||
expect(result).toEqualLeft(TEAM_MEMBER_NOT_FOUND);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,14 +6,17 @@ import { TeamEnvironment } from './team-environments.model';
|
||||
import {
|
||||
TEAM_ENVIRONMENT_NOT_FOUND,
|
||||
TEAM_ENVIRONMENT_SHORT_NAME,
|
||||
TEAM_MEMBER_NOT_FOUND,
|
||||
} from 'src/errors';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { isValidLength } from 'src/utils';
|
||||
import { TeamService } from 'src/team/team.service';
|
||||
@Injectable()
|
||||
export class TeamEnvironmentsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly pubsub: PubSubService,
|
||||
private readonly teamService: TeamService,
|
||||
) {}
|
||||
|
||||
TITLE_LENGTH = 3;
|
||||
@@ -242,4 +245,30 @@ export class TeamEnvironmentsService {
|
||||
});
|
||||
return envCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get details of a TeamEnvironment for CLI.
|
||||
*
|
||||
* @param id TeamEnvironment ID
|
||||
* @param userUid User UID
|
||||
* @returns Either of a TeamEnvironment or error message
|
||||
*/
|
||||
async getTeamEnvironmentForCLI(id: string, userUid: string) {
|
||||
try {
|
||||
const teamEnvironment =
|
||||
await this.prisma.teamEnvironment.findFirstOrThrow({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
const teamMember = await this.teamService.getTeamMember(
|
||||
teamEnvironment.teamID,
|
||||
userUid,
|
||||
);
|
||||
if (!teamMember) return E.left(TEAM_MEMBER_NOT_FOUND);
|
||||
|
||||
return E.right(teamEnvironment);
|
||||
} catch (error) {
|
||||
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,12 +75,13 @@ export class TeamInvitationService {
|
||||
if (!isEmailValid) return E.left(INVALID_EMAIL);
|
||||
|
||||
try {
|
||||
const teamInvite = await this.prisma.teamInvitation.findUniqueOrThrow({
|
||||
const teamInvite = await this.prisma.teamInvitation.findFirstOrThrow({
|
||||
where: {
|
||||
teamID_inviteeEmail: {
|
||||
inviteeEmail: inviteeEmail,
|
||||
teamID: teamID,
|
||||
inviteeEmail: {
|
||||
equals: inviteeEmail,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
teamID,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
7
packages/hoppscotch-backend/src/types/AccessToken.ts
Normal file
7
packages/hoppscotch-backend/src/types/AccessToken.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type AccessToken = {
|
||||
id: string;
|
||||
label: string;
|
||||
createdOn: Date;
|
||||
lastUsedOn: Date;
|
||||
expiresOn: null | Date;
|
||||
};
|
||||
@@ -1,7 +1,16 @@
|
||||
export enum InfraConfigEnum {
|
||||
MAILER_SMTP_ENABLE = 'MAILER_SMTP_ENABLE',
|
||||
MAILER_USE_CUSTOM_CONFIGS = 'MAILER_USE_CUSTOM_CONFIGS',
|
||||
MAILER_SMTP_URL = 'MAILER_SMTP_URL',
|
||||
MAILER_ADDRESS_FROM = 'MAILER_ADDRESS_FROM',
|
||||
|
||||
MAILER_SMTP_HOST = 'MAILER_SMTP_HOST',
|
||||
MAILER_SMTP_PORT = 'MAILER_SMTP_PORT',
|
||||
MAILER_SMTP_SECURE = 'MAILER_SMTP_SECURE',
|
||||
MAILER_SMTP_USER = 'MAILER_SMTP_USER',
|
||||
MAILER_SMTP_PASSWORD = 'MAILER_SMTP_PASSWORD',
|
||||
MAILER_TLS_REJECT_UNAUTHORIZED = 'MAILER_TLS_REJECT_UNAUTHORIZED',
|
||||
|
||||
GOOGLE_CLIENT_ID = 'GOOGLE_CLIENT_ID',
|
||||
GOOGLE_CLIENT_SECRET = 'GOOGLE_CLIENT_SECRET',
|
||||
GOOGLE_CALLBACK_URL = 'GOOGLE_CALLBACK_URL',
|
||||
|
||||
@@ -5,6 +5,6 @@ import { HttpStatus } from '@nestjs/common';
|
||||
** Since its REST we need to return the HTTP status code along with the error message
|
||||
*/
|
||||
export type RESTError = {
|
||||
message: string;
|
||||
message: string | Record<string, string>;
|
||||
statusCode: HttpStatus;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { ArgsType, Field, ID, InputType } from '@nestjs/graphql';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsNotEmpty, IsOptional } from 'class-validator';
|
||||
|
||||
@ArgsType()
|
||||
@InputType()
|
||||
@@ -21,6 +24,10 @@ export class PaginationArgs {
|
||||
@ArgsType()
|
||||
@InputType()
|
||||
export class OffsetPaginationArgs {
|
||||
@IsOptional()
|
||||
@IsNotEmpty()
|
||||
@Type(() => Number)
|
||||
@ApiPropertyOptional()
|
||||
@Field({
|
||||
nullable: true,
|
||||
defaultValue: 0,
|
||||
@@ -28,6 +35,10 @@ export class OffsetPaginationArgs {
|
||||
})
|
||||
skip: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNotEmpty()
|
||||
@Type(() => Number)
|
||||
@ApiPropertyOptional()
|
||||
@Field({
|
||||
nullable: true,
|
||||
defaultValue: 10,
|
||||
|
||||
@@ -390,6 +390,36 @@ export class UserCollectionResolver {
|
||||
return updatedUserCollection.right;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Duplicate a User Collection',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async duplicateUserCollection(
|
||||
@GqlUser() user: AuthUser,
|
||||
@Args({
|
||||
name: 'collectionID',
|
||||
description: 'ID of the collection',
|
||||
})
|
||||
collectionID: string,
|
||||
@Args({
|
||||
name: 'reqType',
|
||||
description: 'Type of UserCollection',
|
||||
type: () => ReqType,
|
||||
})
|
||||
reqType: ReqType,
|
||||
) {
|
||||
const duplicatedUserCollection =
|
||||
await this.userCollectionService.duplicateUserCollection(
|
||||
collectionID,
|
||||
user.uid,
|
||||
reqType,
|
||||
);
|
||||
|
||||
if (E.isLeft(duplicatedUserCollection))
|
||||
throwErr(duplicatedUserCollection.left);
|
||||
return duplicatedUserCollection.right;
|
||||
}
|
||||
|
||||
// Subscriptions
|
||||
@Subscription(() => UserCollection, {
|
||||
description: 'Listen for User Collection Creation',
|
||||
|
||||
@@ -38,6 +38,8 @@ const user: AuthUser = {
|
||||
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
|
||||
isAdmin: false,
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
lastActiveOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
currentGQLSession: {},
|
||||
currentRESTSession: {},
|
||||
|
||||
@@ -25,7 +25,11 @@ import {
|
||||
UserCollectionExportJSONData,
|
||||
} from './user-collections.model';
|
||||
import { ReqType } from 'src/types/RequestTypes';
|
||||
import { isValidLength, stringToJson } from 'src/utils';
|
||||
import {
|
||||
isValidLength,
|
||||
stringToJson,
|
||||
transformCollectionData,
|
||||
} from 'src/utils';
|
||||
import { CollectionFolder } from 'src/types/CollectionFolder';
|
||||
|
||||
@Injectable()
|
||||
@@ -43,13 +47,15 @@ export class UserCollectionService {
|
||||
* @returns UserCollection model
|
||||
*/
|
||||
private cast(collection: UserCollection) {
|
||||
const data = transformCollectionData(collection.data);
|
||||
|
||||
return <UserCollectionModel>{
|
||||
id: collection.id,
|
||||
title: collection.title,
|
||||
type: collection.type,
|
||||
parentID: collection.parentID,
|
||||
userID: collection.userUid,
|
||||
data: !collection.data ? null : JSON.stringify(collection.data),
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -871,6 +877,8 @@ export class UserCollectionService {
|
||||
},
|
||||
});
|
||||
|
||||
const data = transformCollectionData(collection.right.data);
|
||||
|
||||
const result: CollectionFolder = {
|
||||
id: collection.right.id,
|
||||
name: collection.right.title,
|
||||
@@ -882,7 +890,7 @@ export class UserCollectionService {
|
||||
...(x.request as Record<string, unknown>), // type casting x.request of type Prisma.JSONValue to an object to enable spread
|
||||
};
|
||||
}),
|
||||
data: JSON.stringify(collection.right.data),
|
||||
data,
|
||||
};
|
||||
|
||||
return E.right(result);
|
||||
@@ -1138,4 +1146,45 @@ export class UserCollectionService {
|
||||
return E.left(USER_COLL_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a User Collection
|
||||
*
|
||||
* @param collectionID The Collection ID
|
||||
* @returns Boolean of duplication status
|
||||
*/
|
||||
async duplicateUserCollection(
|
||||
collectionID: string,
|
||||
userID: string,
|
||||
reqType: DBReqType,
|
||||
) {
|
||||
const collection = await this.getUserCollection(collectionID);
|
||||
if (E.isLeft(collection)) return E.left(USER_COLL_NOT_FOUND);
|
||||
|
||||
if (collection.right.userUid !== userID) return E.left(USER_NOT_OWNER);
|
||||
if (collection.right.type !== reqType)
|
||||
return E.left(USER_COLL_NOT_SAME_TYPE);
|
||||
|
||||
const collectionJSONObject = await this.exportUserCollectionToJSONObject(
|
||||
collection.right.userUid,
|
||||
collectionID,
|
||||
);
|
||||
if (E.isLeft(collectionJSONObject))
|
||||
return E.left(collectionJSONObject.left);
|
||||
|
||||
const result = await this.importCollectionsFromJSON(
|
||||
JSON.stringify([
|
||||
{
|
||||
...collectionJSONObject.right,
|
||||
name: `${collection.right.title} - Duplicate`,
|
||||
},
|
||||
]),
|
||||
userID,
|
||||
collection.right.parentID,
|
||||
reqType,
|
||||
);
|
||||
if (E.isLeft(result)) return E.left(result.left as string);
|
||||
|
||||
return E.right(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ const user: AuthUser = {
|
||||
photoURL: 'https://example.com/photo.png',
|
||||
isAdmin: false,
|
||||
refreshToken: null,
|
||||
lastLoggedOn: new Date(),
|
||||
lastActiveOn: new Date(),
|
||||
createdOn: new Date(),
|
||||
currentGQLSession: null,
|
||||
currentRESTSession: null,
|
||||
|
||||
@@ -27,6 +27,8 @@ const user: AuthUser = {
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
currentGQLSession: {},
|
||||
currentRESTSession: {},
|
||||
lastLoggedOn: currentTime,
|
||||
lastActiveOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
};
|
||||
|
||||
|
||||
@@ -30,6 +30,18 @@ export class User {
|
||||
})
|
||||
isAdmin: boolean;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description: 'Date when the user last logged in',
|
||||
})
|
||||
lastLoggedOn: Date;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description: 'Date when the user last interacted with the app',
|
||||
})
|
||||
lastActiveOn: Date;
|
||||
|
||||
@Field({
|
||||
description: 'Date when the user account was created',
|
||||
})
|
||||
|
||||
@@ -42,6 +42,8 @@ const user: AuthUser = {
|
||||
currentRESTSession: {},
|
||||
currentGQLSession: {},
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
lastActiveOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
};
|
||||
|
||||
@@ -54,6 +56,8 @@ const adminUser: AuthUser = {
|
||||
currentRESTSession: {},
|
||||
currentGQLSession: {},
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
lastActiveOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
};
|
||||
|
||||
@@ -67,6 +71,8 @@ const users: AuthUser[] = [
|
||||
currentRESTSession: {},
|
||||
currentGQLSession: {},
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
lastActiveOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
},
|
||||
{
|
||||
@@ -78,6 +84,8 @@ const users: AuthUser[] = [
|
||||
currentRESTSession: {},
|
||||
currentGQLSession: {},
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
lastActiveOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
},
|
||||
{
|
||||
@@ -89,6 +97,8 @@ const users: AuthUser[] = [
|
||||
currentRESTSession: {},
|
||||
currentGQLSession: {},
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
lastActiveOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
},
|
||||
];
|
||||
@@ -103,6 +113,8 @@ const adminUsers: AuthUser[] = [
|
||||
currentRESTSession: {},
|
||||
currentGQLSession: {},
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
lastActiveOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
},
|
||||
{
|
||||
@@ -114,6 +126,8 @@ const adminUsers: AuthUser[] = [
|
||||
currentRESTSession: {},
|
||||
currentGQLSession: {},
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
lastActiveOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
},
|
||||
{
|
||||
@@ -125,6 +139,8 @@ const adminUsers: AuthUser[] = [
|
||||
currentRESTSession: {},
|
||||
currentGQLSession: {},
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
lastActiveOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
},
|
||||
];
|
||||
@@ -149,7 +165,7 @@ beforeEach(() => {
|
||||
describe('UserService', () => {
|
||||
describe('findUserByEmail', () => {
|
||||
test('should successfully return a valid user given a valid email', async () => {
|
||||
mockPrisma.user.findUniqueOrThrow.mockResolvedValueOnce(user);
|
||||
mockPrisma.user.findFirst.mockResolvedValueOnce(user);
|
||||
|
||||
const result = await userService.findUserByEmail(
|
||||
'dwight@dundermifflin.com',
|
||||
@@ -158,7 +174,7 @@ describe('UserService', () => {
|
||||
});
|
||||
|
||||
test('should return a null user given a invalid email', async () => {
|
||||
mockPrisma.user.findUniqueOrThrow.mockRejectedValueOnce('NotFoundError');
|
||||
mockPrisma.user.findFirst.mockResolvedValueOnce(null);
|
||||
|
||||
const result = await userService.findUserByEmail('jim@dundermifflin.com');
|
||||
expect(result).resolves.toBeNone;
|
||||
@@ -495,6 +511,26 @@ describe('UserService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateUserLastLoggedOn', () => {
|
||||
test('should resolve right and update user last logged on', async () => {
|
||||
const currentTime = new Date();
|
||||
mockPrisma.user.update.mockResolvedValueOnce({
|
||||
...user,
|
||||
lastLoggedOn: currentTime,
|
||||
});
|
||||
|
||||
const result = await userService.updateUserLastLoggedOn(user.uid);
|
||||
expect(result).toEqualRight(true);
|
||||
});
|
||||
|
||||
test('should resolve left and error when invalid user uid is passed', async () => {
|
||||
mockPrisma.user.update.mockRejectedValueOnce('NotFoundError');
|
||||
|
||||
const result = await userService.updateUserLastLoggedOn('invalidUserUid');
|
||||
expect(result).toEqualLeft(USER_NOT_FOUND);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchAllUsers', () => {
|
||||
test('should resolve right and return 20 users when cursor is null', async () => {
|
||||
mockPrisma.user.findMany.mockResolvedValueOnce(users);
|
||||
|
||||
@@ -62,16 +62,16 @@ export class UserService {
|
||||
* @returns Option of found User
|
||||
*/
|
||||
async findUserByEmail(email: string): Promise<O.None | O.Some<AuthUser>> {
|
||||
try {
|
||||
const user = await this.prisma.user.findUniqueOrThrow({
|
||||
where: {
|
||||
email: email,
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email: {
|
||||
equals: email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
});
|
||||
return O.some(user);
|
||||
} catch (error) {
|
||||
return O.none;
|
||||
}
|
||||
},
|
||||
});
|
||||
if (!user) return O.none;
|
||||
return O.some(user);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -114,7 +114,7 @@ export class UserService {
|
||||
* @param userUid User uid
|
||||
* @returns Either of User with updated refreshToken
|
||||
*/
|
||||
async UpdateUserRefreshToken(refreshTokenHash: string, userUid: string) {
|
||||
async updateUserRefreshToken(refreshTokenHash: string, userUid: string) {
|
||||
try {
|
||||
const user = await this.prisma.user.update({
|
||||
where: {
|
||||
@@ -174,6 +174,7 @@ export class UserService {
|
||||
displayName: userDisplayName,
|
||||
email: profile.emails[0].value,
|
||||
photoURL: userPhotoURL,
|
||||
lastLoggedOn: new Date(),
|
||||
providerAccounts: {
|
||||
create: {
|
||||
provider: profile.provider,
|
||||
@@ -221,7 +222,7 @@ export class UserService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update User displayName and photoURL
|
||||
* Update User displayName and photoURL when logged in via a SSO provider
|
||||
*
|
||||
* @param user User object
|
||||
* @param profile Data received from SSO provider on the users account
|
||||
@@ -236,6 +237,7 @@ export class UserService {
|
||||
data: {
|
||||
displayName: !profile.displayName ? null : profile.displayName,
|
||||
photoURL: !profile.photos ? null : profile.photos[0].value,
|
||||
lastLoggedOn: new Date(),
|
||||
},
|
||||
});
|
||||
return E.right(updatedUser);
|
||||
@@ -289,7 +291,7 @@ export class UserService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a user's data
|
||||
* Update a user's displayName
|
||||
* @param userUID User UID
|
||||
* @param displayName User's displayName
|
||||
* @returns a Either of User or error
|
||||
@@ -316,6 +318,38 @@ export class UserService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user's lastLoggedOn timestamp
|
||||
* @param userUID User UID
|
||||
*/
|
||||
async updateUserLastLoggedOn(userUid: string) {
|
||||
try {
|
||||
await this.prisma.user.update({
|
||||
where: { uid: userUid },
|
||||
data: { lastLoggedOn: new Date() },
|
||||
});
|
||||
return E.right(true);
|
||||
} catch (e) {
|
||||
return E.left(USER_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user's lastActiveOn timestamp
|
||||
* @param userUID User UID
|
||||
*/
|
||||
async updateUserLastActiveOn(userUid: string) {
|
||||
try {
|
||||
await this.prisma.user.update({
|
||||
where: { uid: userUid },
|
||||
data: { lastActiveOn: new Date() },
|
||||
});
|
||||
return E.right(true);
|
||||
} catch (e) {
|
||||
return E.left(USER_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and parse currentRESTSession and currentGQLSession
|
||||
* @param sessionData string of the session
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { ExecutionContext, HttpException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import * as A from 'fp-ts/Array';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { pipe } from 'fp-ts/lib/function';
|
||||
import * as O from 'fp-ts/Option';
|
||||
import * as TE from 'fp-ts/TaskEither';
|
||||
import * as T from 'fp-ts/Task';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import * as A from 'fp-ts/Array';
|
||||
import { TeamMemberRole } from './team/team.model';
|
||||
import { User } from './user/user.model';
|
||||
import * as TE from 'fp-ts/TaskEither';
|
||||
import { AuthProvider } from './auth/helper';
|
||||
import {
|
||||
ENV_EMPTY_AUTH_PROVIDERS,
|
||||
ENV_NOT_FOUND_KEY_AUTH_PROVIDERS,
|
||||
ENV_NOT_SUPPORT_AUTH_PROVIDERS,
|
||||
JSON_INVALID,
|
||||
} from './errors';
|
||||
import { AuthProvider } from './auth/helper';
|
||||
import { TeamMemberRole } from './team/team.model';
|
||||
import { RESTError } from './types/RESTError';
|
||||
|
||||
/**
|
||||
@@ -286,3 +286,33 @@ export function escapeSqlLikeString(str: string) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the expiration date of the token
|
||||
*
|
||||
* @param expiresOn Number of days the token is valid for
|
||||
* @returns Date object of the expiration date
|
||||
*/
|
||||
export function calculateExpirationDate(expiresOn: null | number) {
|
||||
if (expiresOn === null) return null;
|
||||
return new Date(Date.now() + expiresOn * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
/*
|
||||
* Transforms the collection level properties (authorization & headers) under the `data` field.
|
||||
* Preserves `null` values and prevents duplicate stringification.
|
||||
*
|
||||
* @param {Prisma.JsonValue} collectionData - The team collection data to transform.
|
||||
* @returns {string | null} The transformed team collection data as a string.
|
||||
*/
|
||||
export function transformCollectionData(
|
||||
collectionData: Prisma.JsonValue,
|
||||
): string | null {
|
||||
if (!collectionData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return typeof collectionData === 'string'
|
||||
? collectionData
|
||||
: JSON.stringify(collectionData);
|
||||
}
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
/*
|
||||
* For a detailed explanation regarding each configuration property, visit:
|
||||
* https://jestjs.io/docs/configuration
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
// All imported modules in your tests should be mocked automatically
|
||||
// automock: false,
|
||||
|
||||
// Stop running tests after `n` failures
|
||||
// bail: 0,
|
||||
|
||||
// The directory where Jest should store its cached dependency information
|
||||
// cacheDirectory: "/tmp/jest_rs",
|
||||
|
||||
// Automatically clear mock calls, instances and results before every test
|
||||
clearMocks: true,
|
||||
|
||||
// Indicates whether the coverage information should be collected while executing the test
|
||||
// collectCoverage: true,
|
||||
|
||||
// An array of glob patterns indicating a set of files for which coverage information should be collected
|
||||
// collectCoverageFrom: undefined,
|
||||
|
||||
// The directory where Jest should output its coverage files
|
||||
// coverageDirectory: undefined,
|
||||
|
||||
// An array of regexp pattern strings used to skip coverage collection
|
||||
// coveragePathIgnorePatterns: [
|
||||
// "/node_modules/"
|
||||
// ],
|
||||
|
||||
// Indicates which provider should be used to instrument code for coverage
|
||||
// coverageProvider: "babel",
|
||||
|
||||
// A list of reporter names that Jest uses when writing coverage reports
|
||||
// coverageReporters: [
|
||||
// "json",
|
||||
// "text",
|
||||
// "lcov",
|
||||
// "clover"
|
||||
// ],
|
||||
|
||||
// An object that configures minimum threshold enforcement for coverage results
|
||||
// coverageThreshold: undefined,
|
||||
|
||||
// A path to a custom dependency extractor
|
||||
// dependencyExtractor: undefined,
|
||||
|
||||
// Make calling deprecated APIs throw helpful error messages
|
||||
// errorOnDeprecated: false,
|
||||
|
||||
// Force coverage collection from ignored files using an array of glob patterns
|
||||
// forceCoverageMatch: [],
|
||||
|
||||
// A path to a module which exports an async function that is triggered once before all test suites
|
||||
// globalSetup: undefined,
|
||||
|
||||
// A path to a module which exports an async function that is triggered once after all test suites
|
||||
// globalTeardown: undefined,
|
||||
|
||||
// A set of global variables that need to be available in all test environments
|
||||
// globals: {
|
||||
// 'ts-jest': {
|
||||
// useESM: true,
|
||||
// },
|
||||
// },
|
||||
|
||||
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
|
||||
// maxWorkers: "50%",
|
||||
|
||||
// An array of directory names to be searched recursively up from the requiring module's location
|
||||
// moduleDirectories: [
|
||||
// "node_modules"
|
||||
// ],
|
||||
|
||||
// An array of file extensions your modules use
|
||||
moduleFileExtensions: ["js", "ts", "json"],
|
||||
|
||||
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
|
||||
// moduleNameMapper: {
|
||||
// '^(\\.{1,2}/.*)\\.js$': '$1',
|
||||
// },
|
||||
|
||||
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
|
||||
// modulePathIgnorePatterns: [],
|
||||
|
||||
// Activates notifications for test results
|
||||
// notify: false,
|
||||
|
||||
// An enum that specifies notification mode. Requires { notify: true }
|
||||
// notifyMode: "failure-change",
|
||||
|
||||
// A preset that is used as a base for Jest's configuration
|
||||
preset: "ts-jest/presets/js-with-babel",
|
||||
|
||||
// Run tests from one or more projects
|
||||
// projects: undefined,
|
||||
|
||||
// Use this configuration option to add custom reporters to Jest
|
||||
// reporters: undefined,
|
||||
|
||||
// Automatically reset mock state before every test
|
||||
// resetMocks: false,
|
||||
|
||||
// Reset the module registry before running each individual test
|
||||
// resetModules: false,
|
||||
|
||||
// A path to a custom resolver
|
||||
// resolver: undefined,
|
||||
|
||||
// Automatically restore mock state and implementation before every test
|
||||
// restoreMocks: false,
|
||||
|
||||
// The root directory that Jest should scan for tests and modules within
|
||||
// rootDir: undefined,
|
||||
|
||||
// A list of paths to directories that Jest should use to search for files in
|
||||
// roots: [
|
||||
// "<rootDir>"
|
||||
// ],
|
||||
|
||||
// Allows you to use a custom runner instead of Jest's default test runner
|
||||
// runner: "jest-runner",
|
||||
|
||||
// The paths to modules that run some code to configure or set up the testing environment before each test
|
||||
// setupFiles: [],
|
||||
|
||||
// A list of paths to modules that run some code to configure or set up the testing framework before each test
|
||||
setupFilesAfterEnv: ["./jest.setup.ts"],
|
||||
|
||||
// The number of seconds after which a test is considered as slow and reported as such in the results.
|
||||
// slowTestThreshold: 5,
|
||||
|
||||
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
|
||||
// snapshotSerializers: [],
|
||||
|
||||
// The test environment that will be used for testing
|
||||
testEnvironment: "node",
|
||||
|
||||
// Options that will be passed to the testEnvironment
|
||||
// testEnvironmentOptions: {},
|
||||
|
||||
// Adds a location field to test results
|
||||
// testLocationInResults: false,
|
||||
|
||||
// The glob patterns Jest uses to detect test files
|
||||
testMatch: [
|
||||
// "**/__tests__/**/*.[jt]s?(x)",
|
||||
"**/src/__tests__/commands/**/*.*.ts",
|
||||
],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
|
||||
testPathIgnorePatterns: ["/node_modules/", "/dist/"],
|
||||
|
||||
// The regexp pattern or array of patterns that Jest uses to detect test files
|
||||
// testRegex: [],
|
||||
|
||||
// This option allows the use of a custom results processor
|
||||
// testResultsProcessor: undefined,
|
||||
|
||||
// This option allows use of a custom test runner
|
||||
// testRunner: "jest-circus/runner",
|
||||
|
||||
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
|
||||
// testURL: "http://localhost",
|
||||
|
||||
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
|
||||
// timers: "real",
|
||||
|
||||
// A map from regular expressions to paths to transformers
|
||||
transform: {
|
||||
"^.+\\.ts$": "ts-jest",
|
||||
},
|
||||
|
||||
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
|
||||
// transformIgnorePatterns: [
|
||||
// "/node_modules/",
|
||||
// "\\.pnp\\.[^\\/]+$"
|
||||
// ],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
|
||||
// unmockedModulePathPatterns: undefined,
|
||||
|
||||
// Indicates whether each individual test should be reported during the run
|
||||
verbose: true,
|
||||
|
||||
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
|
||||
// watchPathIgnorePatterns: [],
|
||||
|
||||
// Whether to use watchman for file crawling
|
||||
// watchman: true,
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
import "@relmify/jest-fp-ts";
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hoppscotch/cli",
|
||||
"version": "0.8.0",
|
||||
"version": "0.10.1",
|
||||
"description": "A CLI to run Hoppscotch test scripts in CI environments.",
|
||||
"homepage": "https://hoppscotch.io",
|
||||
"type": "module",
|
||||
@@ -20,9 +20,9 @@
|
||||
"debugger": "node debugger.js 9999",
|
||||
"prepublish": "pnpm exec tsup",
|
||||
"prettier-format": "prettier --config .prettierrc 'src/**/*.ts' --write",
|
||||
"test": "pnpm run build && jest && rm -rf dist",
|
||||
"test": "pnpm run build && vitest run",
|
||||
"do-typecheck": "pnpm exec tsc --noEmit",
|
||||
"do-test": "pnpm test"
|
||||
"do-test": "pnpm run test"
|
||||
},
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -48,6 +48,7 @@
|
||||
"lodash-es": "4.17.21",
|
||||
"qs": "6.11.2",
|
||||
"verzod": "0.2.2",
|
||||
"xmlbuilder2": "3.1.1",
|
||||
"zod": "3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -55,15 +56,13 @@
|
||||
"@hoppscotch/js-sandbox": "workspace:^",
|
||||
"@relmify/jest-fp-ts": "2.1.1",
|
||||
"@swc/core": "1.4.2",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/lodash-es": "4.17.12",
|
||||
"@types/qs": "6.9.12",
|
||||
"fp-ts": "2.16.2",
|
||||
"jest": "29.7.0",
|
||||
"prettier": "3.2.5",
|
||||
"qs": "6.11.2",
|
||||
"ts-jest": "29.1.2",
|
||||
"tsup": "8.0.2",
|
||||
"typescript": "5.3.3"
|
||||
"typescript": "5.3.3",
|
||||
"vitest": "0.34.6"
|
||||
}
|
||||
}
|
||||
|
||||
15
packages/hoppscotch-cli/setupFiles.ts
Normal file
15
packages/hoppscotch-cli/setupFiles.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// Vitest doesn't work without globals
|
||||
// Ref: https://github.com/relmify/jest-fp-ts/issues/11
|
||||
|
||||
import decodeMatchers from "@relmify/jest-fp-ts/dist/decodeMatchers";
|
||||
import eitherMatchers from "@relmify/jest-fp-ts/dist/eitherMatchers";
|
||||
import optionMatchers from "@relmify/jest-fp-ts/dist/optionMatchers";
|
||||
import theseMatchers from "@relmify/jest-fp-ts/dist/theseMatchers";
|
||||
import eitherOrTheseMatchers from "@relmify/jest-fp-ts/dist/eitherOrTheseMatchers";
|
||||
import { expect } from "vitest";
|
||||
|
||||
expect.extend(decodeMatchers.matchers);
|
||||
expect.extend(eitherMatchers.matchers);
|
||||
expect.extend(optionMatchers.matchers);
|
||||
expect.extend(theseMatchers.matchers);
|
||||
expect.extend(eitherOrTheseMatchers.matchers);
|
||||
@@ -1,345 +0,0 @@
|
||||
import { ExecException } from "child_process";
|
||||
|
||||
import { HoppErrorCode } from "../../types/errors";
|
||||
import { runCLI, getErrorCode, getTestJsonFilePath } from "../utils";
|
||||
|
||||
describe("Test `hopp test <file>` command:", () => {
|
||||
describe("Argument parsing", () => {
|
||||
test("Errors with the code `INVALID_ARGUMENT` for not supplying enough arguments", async () => {
|
||||
const args = "test";
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
|
||||
});
|
||||
|
||||
test("Errors with the code `INVALID_ARGUMENT` for an invalid command", async () => {
|
||||
const args = "invalid-arg";
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Supplied collection export file validations", () => {
|
||||
test("Errors with the code `FILE_NOT_FOUND` if the supplied collection export file doesn't exist", async () => {
|
||||
const args = "test notfound.json";
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("FILE_NOT_FOUND");
|
||||
});
|
||||
|
||||
test("Errors with the code UNKNOWN_ERROR if the supplied collection export file content isn't valid JSON", async () => {
|
||||
const args = `test ${getTestJsonFilePath("malformed-coll.json", "collection")}`;
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("UNKNOWN_ERROR");
|
||||
});
|
||||
|
||||
test("Errors with the code `MALFORMED_COLLECTION` if the supplied collection export file content is malformed", async () => {
|
||||
const args = `test ${getTestJsonFilePath("malformed-coll-2.json", "collection")}`;
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("MALFORMED_COLLECTION");
|
||||
});
|
||||
|
||||
test("Errors with the code `INVALID_FILE_TYPE` if the supplied collection export file doesn't end with the `.json` extension", async () => {
|
||||
const args = `test ${getTestJsonFilePath("notjson-coll.txt", "collection")}`;
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("INVALID_FILE_TYPE");
|
||||
});
|
||||
|
||||
test("Fails if the collection file includes scripts with incorrect API usage and failed assertions", async () => {
|
||||
const args = `test ${getTestJsonFilePath("fails-coll.json", "collection")}`;
|
||||
const { error } = await runCLI(args);
|
||||
|
||||
expect(error).not.toBeNull();
|
||||
expect(error).toMatchObject(<ExecException>{
|
||||
code: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Versioned entities", () => {
|
||||
describe("Collections & Requests", () => {
|
||||
const testFixtures = [
|
||||
{ fileName: "coll-v1-req-v0.json", collVersion: 1, reqVersion: 0 },
|
||||
{ fileName: "coll-v1-req-v1.json", collVersion: 1, reqVersion: 1 },
|
||||
{ fileName: "coll-v2-req-v2.json", collVersion: 2, reqVersion: 2 },
|
||||
{ fileName: "coll-v2-req-v3.json", collVersion: 2, reqVersion: 3 },
|
||||
];
|
||||
|
||||
testFixtures.forEach(({ collVersion, fileName, reqVersion }) => {
|
||||
test(`Successfully processes a supplied collection export file where the collection is based on the "v${collVersion}" schema and the request following the "v${reqVersion}" schema`, async () => {
|
||||
const args = `test ${getTestJsonFilePath(fileName, "collection")}`;
|
||||
const { error } = await runCLI(args);
|
||||
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Environments", () => {
|
||||
const testFixtures = [
|
||||
{ fileName: "env-v0.json", version: 0 },
|
||||
{ fileName: "env-v1.json", version: 1 },
|
||||
];
|
||||
|
||||
testFixtures.forEach(({ fileName, version }) => {
|
||||
test(`Successfully processes the supplied collection and environment export files where the environment is based on the "v${version}" schema`, async () => {
|
||||
const ENV_PATH = getTestJsonFilePath(fileName, "environment");
|
||||
const args = `test ${getTestJsonFilePath("sample-coll.json", "collection")} --env ${ENV_PATH}`;
|
||||
const { error } = await runCLI(args);
|
||||
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("Successfully processes a supplied collection export file of the expected format", async () => {
|
||||
const args = `test ${getTestJsonFilePath("passes-coll.json", "collection")}`;
|
||||
const { error } = await runCLI(args);
|
||||
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
test("Successfully inherits headers and authorization set at the root collection", async () => {
|
||||
const args = `test ${getTestJsonFilePath(
|
||||
"collection-level-headers-auth-coll.json",
|
||||
"collection"
|
||||
)}`;
|
||||
const { error } = await runCLI(args);
|
||||
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
test("Persists environment variables set in the pre-request script for consumption in the test script", async () => {
|
||||
const args = `test ${getTestJsonFilePath(
|
||||
"pre-req-script-env-var-persistence-coll.json",
|
||||
"collection"
|
||||
)}`;
|
||||
const { error } = await runCLI(args);
|
||||
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test `hopp test <file> --env <file>` command:", () => {
|
||||
describe("Supplied environment export file validations", () => {
|
||||
const VALID_TEST_ARGS = `test ${getTestJsonFilePath("passes-coll.json", "collection")}`;
|
||||
|
||||
test("Errors with the code `INVALID_ARGUMENT` if no file is supplied", async () => {
|
||||
const args = `${VALID_TEST_ARGS} --env`;
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
|
||||
});
|
||||
|
||||
test("Errors with the code `INVALID_FILE_TYPE` if the supplied environment export file doesn't end with the `.json` extension", async () => {
|
||||
const args = `${VALID_TEST_ARGS} --env ${getTestJsonFilePath(
|
||||
"notjson-coll.txt",
|
||||
"collection"
|
||||
)}`;
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("INVALID_FILE_TYPE");
|
||||
});
|
||||
|
||||
test("Errors with the code `FILE_NOT_FOUND` if the supplied environment export file doesn't exist", async () => {
|
||||
const args = `${VALID_TEST_ARGS} --env notfound.json`;
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("FILE_NOT_FOUND");
|
||||
});
|
||||
|
||||
test("Errors with the code `MALFORMED_ENV_FILE` on supplying a malformed environment export file", async () => {
|
||||
const ENV_PATH = getTestJsonFilePath(
|
||||
"malformed-envs.json",
|
||||
"environment"
|
||||
);
|
||||
const args = `${VALID_TEST_ARGS} --env ${ENV_PATH}`;
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("MALFORMED_ENV_FILE");
|
||||
});
|
||||
|
||||
test("Errors with the code `BULK_ENV_FILE` on supplying an environment export file based on the bulk environment export format", async () => {
|
||||
const ENV_PATH = getTestJsonFilePath("bulk-envs.json", "environment");
|
||||
const args = `${VALID_TEST_ARGS} --env ${ENV_PATH}`;
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("BULK_ENV_FILE");
|
||||
});
|
||||
});
|
||||
|
||||
test("Successfully resolves values from the supplied environment export file", async () => {
|
||||
const TESTS_PATH = getTestJsonFilePath(
|
||||
"env-flag-tests-coll.json",
|
||||
"collection"
|
||||
);
|
||||
const ENV_PATH = getTestJsonFilePath("env-flag-envs.json", "environment");
|
||||
const args = `test ${TESTS_PATH} --env ${ENV_PATH}`;
|
||||
|
||||
const { error } = await runCLI(args);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
test("Successfully resolves environment variables referenced in the request body", async () => {
|
||||
const COLL_PATH = getTestJsonFilePath(
|
||||
"req-body-env-vars-coll.json",
|
||||
"collection"
|
||||
);
|
||||
const ENVS_PATH = getTestJsonFilePath(
|
||||
"req-body-env-vars-envs.json",
|
||||
"environment"
|
||||
);
|
||||
const args = `test ${COLL_PATH} --env ${ENVS_PATH}`;
|
||||
|
||||
const { error } = await runCLI(args);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
test("Works with shorth `-e` flag", async () => {
|
||||
const TESTS_PATH = getTestJsonFilePath(
|
||||
"env-flag-tests-coll.json",
|
||||
"collection"
|
||||
);
|
||||
const ENV_PATH = getTestJsonFilePath("env-flag-envs.json", "environment");
|
||||
const args = `test ${TESTS_PATH} -e ${ENV_PATH}`;
|
||||
|
||||
const { error } = await runCLI(args);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
describe("Secret environment variables", () => {
|
||||
jest.setTimeout(100000);
|
||||
|
||||
// Reads secret environment values from system environment
|
||||
test("Successfully picks the values for secret environment variables from `process.env` and persists the variables set from the pre-request script", async () => {
|
||||
const env = {
|
||||
...process.env,
|
||||
secretBearerToken: "test-token",
|
||||
secretBasicAuthUsername: "test-user",
|
||||
secretBasicAuthPassword: "test-pass",
|
||||
secretQueryParamValue: "secret-query-param-value",
|
||||
secretBodyValue: "secret-body-value",
|
||||
secretHeaderValue: "secret-header-value",
|
||||
};
|
||||
|
||||
const COLL_PATH = getTestJsonFilePath(
|
||||
"secret-envs-coll.json",
|
||||
"collection"
|
||||
);
|
||||
const ENVS_PATH = getTestJsonFilePath("secret-envs.json", "environment");
|
||||
const args = `test ${COLL_PATH} --env ${ENVS_PATH}`;
|
||||
|
||||
const { error, stdout } = await runCLI(args, { env });
|
||||
|
||||
expect(stdout).toContain(
|
||||
"https://httpbin.org/basic-auth/*********/*********"
|
||||
);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
// Prefers values specified in the environment export file over values set in the system environment
|
||||
test("Successfully picks the values for secret environment variables set directly in the environment export file and persists the environment variables set from the pre-request script", async () => {
|
||||
const COLL_PATH = getTestJsonFilePath(
|
||||
"secret-envs-coll.json",
|
||||
"collection"
|
||||
);
|
||||
const ENVS_PATH = getTestJsonFilePath(
|
||||
"secret-supplied-values-envs.json",
|
||||
"environment"
|
||||
);
|
||||
const args = `test ${COLL_PATH} --env ${ENVS_PATH}`;
|
||||
|
||||
const { error, stdout } = await runCLI(args);
|
||||
|
||||
expect(stdout).toContain(
|
||||
"https://httpbin.org/basic-auth/*********/*********"
|
||||
);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
// Values set from the scripting context takes the highest precedence
|
||||
test("Setting values for secret environment variables from the pre-request script overrides values set at the supplied environment export file", async () => {
|
||||
const COLL_PATH = getTestJsonFilePath(
|
||||
"secret-envs-persistence-coll.json",
|
||||
"collection"
|
||||
);
|
||||
const ENVS_PATH = getTestJsonFilePath(
|
||||
"secret-supplied-values-envs.json",
|
||||
"environment"
|
||||
);
|
||||
const args = `test ${COLL_PATH} --env ${ENVS_PATH}`;
|
||||
|
||||
const { error, stdout } = await runCLI(args);
|
||||
|
||||
expect(stdout).toContain(
|
||||
"https://httpbin.org/basic-auth/*********/*********"
|
||||
);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
test("Persists secret environment variable values set from the pre-request script for consumption in the request and post-request script context", async () => {
|
||||
const COLL_PATH = getTestJsonFilePath(
|
||||
"secret-envs-persistence-scripting-coll.json",
|
||||
"collection"
|
||||
);
|
||||
const ENVS_PATH = getTestJsonFilePath(
|
||||
"secret-envs-persistence-scripting-envs.json",
|
||||
"environment"
|
||||
);
|
||||
const args = `test ${COLL_PATH} --env ${ENVS_PATH}`;
|
||||
|
||||
const { error } = await runCLI(args);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test `hopp test <file> --delay <delay_in_ms>` command:", () => {
|
||||
const VALID_TEST_ARGS = `test ${getTestJsonFilePath("passes-coll.json", "collection")}`;
|
||||
|
||||
test("Errors with the code `INVALID_ARGUMENT` on not supplying a delay value", async () => {
|
||||
const args = `${VALID_TEST_ARGS} --delay`;
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
|
||||
});
|
||||
|
||||
test("Errors with the code `INVALID_ARGUMENT` on supplying an invalid delay value", async () => {
|
||||
const args = `${VALID_TEST_ARGS} --delay 'NaN'`;
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
|
||||
});
|
||||
|
||||
test("Successfully performs delayed request execution for a valid delay value", async () => {
|
||||
const args = `${VALID_TEST_ARGS} --delay 1`;
|
||||
const { error } = await runCLI(args);
|
||||
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
test("Works with the short `-d` flag", async () => {
|
||||
const args = `${VALID_TEST_ARGS} -d 1`;
|
||||
const { error } = await runCLI(args);
|
||||
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,529 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`hopp test [options] <file_path_or_id> > Test\`hopp test <file_path_or_id> --env <file_path_or_id> --reporter-junit [path] > Generates a JUnit report at the default path 1`] = `
|
||||
"<?xml version=\\"1.0\\" encoding=\\"UTF-8\\"?>
|
||||
<testsuites tests=\\"76\\" failures=\\"2\\" errors=\\"66\\" time=\\"time\\">
|
||||
<testsuite name=\\"test-junit-report-export/request-level-errors/invalid-url\\" time=\\"time\\" timestamp=\\"timestamp\\" tests=\\"22\\" failures=\\"0\\" errors=\\"22\\">
|
||||
<system-err><![CDATA[
|
||||
REQUEST_ERROR - TypeError: Invalid URL]]></system-err>
|
||||
<testcase name=\\"\`toBeLevelxxx()\` error scenarios - Expected 200-level status but could not parse value 'foo'\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Expected 200-level status but could not parse value 'foo'\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeLevelxxx()\` error scenarios - Expected 200-level status but could not parse value 'foo'\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Expected 200-level status but could not parse value 'foo'\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toHaveLength()\` error scenarios - Expected toHaveLength to be called for an array or string\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Expected toHaveLength to be called for an array or string\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toHaveLength()\` error scenarios - Expected toHaveLength to be called for an array or string\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Expected toHaveLength to be called for an array or string\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toHaveLength()\` error scenarios - Expected toHaveLength to be called for an array or string\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Expected toHaveLength to be called for an array or string\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toHaveLength()\` error scenarios - Expected toHaveLength to be called for an array or string\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Expected toHaveLength to be called for an array or string\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toHaveLength()\` error scenarios - Argument for toHaveLength should be a number\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Argument for toHaveLength should be a number\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toHaveLength()\` error scenarios - Argument for toHaveLength should be a number\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Argument for toHaveLength should be a number\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toInclude() error scenarios\` - Expected toInclude to be called for an array or string\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Expected toInclude to be called for an array or string\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toInclude() error scenarios\` - Expected toInclude to be called for an array or string\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Expected toInclude to be called for an array or string\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toInclude() error scenarios\` - Argument for toInclude should not be null\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Argument for toInclude should not be null\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toInclude() error scenarios\` - Argument for toInclude should not be undefined\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Argument for toInclude should not be undefined\\"/>
|
||||
</testcase>
|
||||
</testsuite>
|
||||
<testsuite name=\\"test-junit-report-export/request-level-errors/test-script-reference-error\\" time=\\"time\\" timestamp=\\"timestamp\\" tests=\\"0\\" failures=\\"0\\" errors=\\"0\\">
|
||||
<system-err><![CDATA[
|
||||
REQUEST_ERROR - TypeError: Invalid URL
|
||||
TEST_SCRIPT_ERROR - Script execution failed: ReferenceError: status is not defined]]></system-err>
|
||||
</testsuite>
|
||||
<testsuite name=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\" time=\\"time\\" timestamp=\\"timestamp\\" tests=\\"22\\" failures=\\"0\\" errors=\\"22\\">
|
||||
<system-err><![CDATA[
|
||||
PARSING_ERROR - {
|
||||
\\"key\\": \\"<<key>>\\"
|
||||
} (ENV_EXPAND_LOOP)]]></system-err>
|
||||
<testcase name=\\"\`toBeLevelxxx()\` error scenarios - Expected 200-level status but could not parse value 'foo'\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Expected 200-level status but could not parse value 'foo'\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeLevelxxx()\` error scenarios - Expected 200-level status but could not parse value 'foo'\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Expected 200-level status but could not parse value 'foo'\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toHaveLength()\` error scenarios - Expected toHaveLength to be called for an array or string\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Expected toHaveLength to be called for an array or string\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toHaveLength()\` error scenarios - Expected toHaveLength to be called for an array or string\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Expected toHaveLength to be called for an array or string\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toHaveLength()\` error scenarios - Expected toHaveLength to be called for an array or string\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Expected toHaveLength to be called for an array or string\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toHaveLength()\` error scenarios - Expected toHaveLength to be called for an array or string\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Expected toHaveLength to be called for an array or string\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toHaveLength()\` error scenarios - Argument for toHaveLength should be a number\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Argument for toHaveLength should be a number\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toHaveLength()\` error scenarios - Argument for toHaveLength should be a number\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Argument for toHaveLength should be a number\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toInclude() error scenarios\` - Expected toInclude to be called for an array or string\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Expected toInclude to be called for an array or string\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toInclude() error scenarios\` - Expected toInclude to be called for an array or string\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Expected toInclude to be called for an array or string\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toInclude() error scenarios\` - Argument for toInclude should not be null\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Argument for toInclude should not be null\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toInclude() error scenarios\` - Argument for toInclude should not be undefined\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Argument for toInclude should not be undefined\\"/>
|
||||
</testcase>
|
||||
</testsuite>
|
||||
<testsuite name=\\"test-junit-report-export/assertions/error\\" time=\\"time\\" timestamp=\\"timestamp\\" tests=\\"22\\" failures=\\"0\\" errors=\\"22\\">
|
||||
<testcase name=\\"\`toBeLevelxxx()\` error scenarios - Expected 200-level status but could not parse value 'foo'\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Expected 200-level status but could not parse value 'foo'\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeLevelxxx()\` error scenarios - Expected 200-level status but could not parse value 'foo'\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Expected 200-level status but could not parse value 'foo'\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toHaveLength()\` error scenarios - Expected toHaveLength to be called for an array or string\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Expected toHaveLength to be called for an array or string\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toHaveLength()\` error scenarios - Expected toHaveLength to be called for an array or string\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Expected toHaveLength to be called for an array or string\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toHaveLength()\` error scenarios - Expected toHaveLength to be called for an array or string\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Expected toHaveLength to be called for an array or string\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toHaveLength()\` error scenarios - Expected toHaveLength to be called for an array or string\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Expected toHaveLength to be called for an array or string\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toHaveLength()\` error scenarios - Argument for toHaveLength should be a number\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Argument for toHaveLength should be a number\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toHaveLength()\` error scenarios - Argument for toHaveLength should be a number\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Argument for toHaveLength should be a number\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toInclude() error scenarios\` - Expected toInclude to be called for an array or string\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Expected toInclude to be called for an array or string\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toInclude() error scenarios\` - Expected toInclude to be called for an array or string\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Expected toInclude to be called for an array or string\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toInclude() error scenarios\` - Argument for toInclude should not be null\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Argument for toInclude should not be null\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toInclude() error scenarios\` - Argument for toInclude should not be undefined\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Argument for toInclude should not be undefined\\"/>
|
||||
</testcase>
|
||||
</testsuite>
|
||||
<testsuite name=\\"test-junit-report-export/assertions/success\\" time=\\"time\\" timestamp=\\"timestamp\\" tests=\\"5\\" failures=\\"0\\" errors=\\"0\\">
|
||||
<testcase name=\\"Status code is 200 - Expected '200' to be '200'\\" classname=\\"test-junit-report-export/assertions/success\\"/>
|
||||
<testcase name=\\"Check headers - Expected 'application/json, text/plain, */*,image/webp' to be 'application/json, text/plain, */*,image/webp'\\" classname=\\"test-junit-report-export/assertions/success\\"/>
|
||||
<testcase name=\\"Check headers - Expected 'echo.hoppscotch.io' to be 'echo.hoppscotch.io'\\" classname=\\"test-junit-report-export/assertions/success\\"/>
|
||||
<testcase name=\\"Check headers - Expected 'undefined' to be 'undefined'\\" classname=\\"test-junit-report-export/assertions/success\\"/>
|
||||
<testcase name=\\"Status code is 2xx - Expected '200' to be 200-level status\\" classname=\\"test-junit-report-export/assertions/success\\"/>
|
||||
</testsuite>
|
||||
<testsuite name=\\"test-junit-report-export/assertions/failure\\" time=\\"time\\" timestamp=\\"timestamp\\" tests=\\"5\\" failures=\\"2\\" errors=\\"0\\">
|
||||
<testcase name=\\"Simulating failure - Status code is 200 - Expected '200' to not be '200'\\" classname=\\"test-junit-report-export/assertions/failure\\">
|
||||
<failure type=\\"AssertionFailure\\" message=\\"Expected '200' to not be '200'\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"Simulating failure - Check headers - Expected 'application/json, text/plain, */*,image/webp' to not be 'application/json, text/plain, */*'\\" classname=\\"test-junit-report-export/assertions/failure\\"/>
|
||||
<testcase name=\\"Simulating failure - Check headers - Expected 'echo.hoppscotch.io' to not be 'httpbin.org'\\" classname=\\"test-junit-report-export/assertions/failure\\"/>
|
||||
<testcase name=\\"Simulating failure - Check headers - Expected 'undefined' to not be 'value'\\" classname=\\"test-junit-report-export/assertions/failure\\"/>
|
||||
<testcase name=\\"Simulating failure - Status code is 2xx - Expected '200' to not be 200-level status\\" classname=\\"test-junit-report-export/assertions/failure\\">
|
||||
<failure type=\\"AssertionFailure\\" message=\\"Expected '200' to not be 200-level status\\"/>
|
||||
</testcase>
|
||||
</testsuite>
|
||||
</testsuites>"
|
||||
`;
|
||||
|
||||
exports[`hopp test [options] <file_path_or_id> > Test\`hopp test <file_path_or_id> --env <file_path_or_id> --reporter-junit [path] > Generates a JUnit report at the specified path 1`] = `
|
||||
"<?xml version=\\"1.0\\" encoding=\\"UTF-8\\"?>
|
||||
<testsuites tests=\\"76\\" failures=\\"2\\" errors=\\"66\\" time=\\"time\\">
|
||||
<testsuite name=\\"test-junit-report-export/request-level-errors/invalid-url\\" time=\\"time\\" timestamp=\\"timestamp\\" tests=\\"22\\" failures=\\"0\\" errors=\\"22\\">
|
||||
<system-err><![CDATA[
|
||||
REQUEST_ERROR - TypeError: Invalid URL]]></system-err>
|
||||
<testcase name=\\"\`toBeLevelxxx()\` error scenarios - Expected 200-level status but could not parse value 'foo'\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Expected 200-level status but could not parse value 'foo'\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeLevelxxx()\` error scenarios - Expected 200-level status but could not parse value 'foo'\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Expected 200-level status but could not parse value 'foo'\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toHaveLength()\` error scenarios - Expected toHaveLength to be called for an array or string\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Expected toHaveLength to be called for an array or string\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toHaveLength()\` error scenarios - Expected toHaveLength to be called for an array or string\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Expected toHaveLength to be called for an array or string\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toHaveLength()\` error scenarios - Expected toHaveLength to be called for an array or string\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Expected toHaveLength to be called for an array or string\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toHaveLength()\` error scenarios - Expected toHaveLength to be called for an array or string\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Expected toHaveLength to be called for an array or string\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toHaveLength()\` error scenarios - Argument for toHaveLength should be a number\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Argument for toHaveLength should be a number\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toHaveLength()\` error scenarios - Argument for toHaveLength should be a number\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Argument for toHaveLength should be a number\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toInclude() error scenarios\` - Expected toInclude to be called for an array or string\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Expected toInclude to be called for an array or string\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toInclude() error scenarios\` - Expected toInclude to be called for an array or string\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Expected toInclude to be called for an array or string\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toInclude() error scenarios\` - Argument for toInclude should not be null\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Argument for toInclude should not be null\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toInclude() error scenarios\` - Argument for toInclude should not be undefined\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
|
||||
<error message=\\"Argument for toInclude should not be undefined\\"/>
|
||||
</testcase>
|
||||
</testsuite>
|
||||
<testsuite name=\\"test-junit-report-export/request-level-errors/test-script-reference-error\\" time=\\"time\\" timestamp=\\"timestamp\\" tests=\\"0\\" failures=\\"0\\" errors=\\"0\\">
|
||||
<system-err><![CDATA[
|
||||
REQUEST_ERROR - TypeError: Invalid URL
|
||||
TEST_SCRIPT_ERROR - Script execution failed: ReferenceError: status is not defined]]></system-err>
|
||||
</testsuite>
|
||||
<testsuite name=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\" time=\\"time\\" timestamp=\\"timestamp\\" tests=\\"22\\" failures=\\"0\\" errors=\\"22\\">
|
||||
<system-err><![CDATA[
|
||||
PARSING_ERROR - {
|
||||
\\"key\\": \\"<<key>>\\"
|
||||
} (ENV_EXPAND_LOOP)]]></system-err>
|
||||
<testcase name=\\"\`toBeLevelxxx()\` error scenarios - Expected 200-level status but could not parse value 'foo'\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Expected 200-level status but could not parse value 'foo'\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeLevelxxx()\` error scenarios - Expected 200-level status but could not parse value 'foo'\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Expected 200-level status but could not parse value 'foo'\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toHaveLength()\` error scenarios - Expected toHaveLength to be called for an array or string\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Expected toHaveLength to be called for an array or string\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toHaveLength()\` error scenarios - Expected toHaveLength to be called for an array or string\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Expected toHaveLength to be called for an array or string\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toHaveLength()\` error scenarios - Expected toHaveLength to be called for an array or string\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Expected toHaveLength to be called for an array or string\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toHaveLength()\` error scenarios - Expected toHaveLength to be called for an array or string\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Expected toHaveLength to be called for an array or string\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toHaveLength()\` error scenarios - Argument for toHaveLength should be a number\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Argument for toHaveLength should be a number\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toHaveLength()\` error scenarios - Argument for toHaveLength should be a number\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Argument for toHaveLength should be a number\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toInclude() error scenarios\` - Expected toInclude to be called for an array or string\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Expected toInclude to be called for an array or string\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toInclude() error scenarios\` - Expected toInclude to be called for an array or string\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Expected toInclude to be called for an array or string\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toInclude() error scenarios\` - Argument for toInclude should not be null\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Argument for toInclude should not be null\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toInclude() error scenarios\` - Argument for toInclude should not be undefined\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
|
||||
<error message=\\"Argument for toInclude should not be undefined\\"/>
|
||||
</testcase>
|
||||
</testsuite>
|
||||
<testsuite name=\\"test-junit-report-export/assertions/error\\" time=\\"time\\" timestamp=\\"timestamp\\" tests=\\"22\\" failures=\\"0\\" errors=\\"22\\">
|
||||
<testcase name=\\"\`toBeLevelxxx()\` error scenarios - Expected 200-level status but could not parse value 'foo'\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Expected 200-level status but could not parse value 'foo'\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeLevelxxx()\` error scenarios - Expected 200-level status but could not parse value 'foo'\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Expected 200-level status but could not parse value 'foo'\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toHaveLength()\` error scenarios - Expected toHaveLength to be called for an array or string\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Expected toHaveLength to be called for an array or string\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toHaveLength()\` error scenarios - Expected toHaveLength to be called for an array or string\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Expected toHaveLength to be called for an array or string\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toHaveLength()\` error scenarios - Expected toHaveLength to be called for an array or string\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Expected toHaveLength to be called for an array or string\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toHaveLength()\` error scenarios - Expected toHaveLength to be called for an array or string\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Expected toHaveLength to be called for an array or string\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toHaveLength()\` error scenarios - Argument for toHaveLength should be a number\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Argument for toHaveLength should be a number\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toHaveLength()\` error scenarios - Argument for toHaveLength should be a number\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Argument for toHaveLength should be a number\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toInclude() error scenarios\` - Expected toInclude to be called for an array or string\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Expected toInclude to be called for an array or string\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toInclude() error scenarios\` - Expected toInclude to be called for an array or string\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Expected toInclude to be called for an array or string\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toInclude() error scenarios\` - Argument for toInclude should not be null\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Argument for toInclude should not be null\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"\`toInclude() error scenarios\` - Argument for toInclude should not be undefined\\" classname=\\"test-junit-report-export/assertions/error\\">
|
||||
<error message=\\"Argument for toInclude should not be undefined\\"/>
|
||||
</testcase>
|
||||
</testsuite>
|
||||
<testsuite name=\\"test-junit-report-export/assertions/success\\" time=\\"time\\" timestamp=\\"timestamp\\" tests=\\"5\\" failures=\\"0\\" errors=\\"0\\">
|
||||
<testcase name=\\"Status code is 200 - Expected '200' to be '200'\\" classname=\\"test-junit-report-export/assertions/success\\"/>
|
||||
<testcase name=\\"Check headers - Expected 'application/json, text/plain, */*,image/webp' to be 'application/json, text/plain, */*,image/webp'\\" classname=\\"test-junit-report-export/assertions/success\\"/>
|
||||
<testcase name=\\"Check headers - Expected 'echo.hoppscotch.io' to be 'echo.hoppscotch.io'\\" classname=\\"test-junit-report-export/assertions/success\\"/>
|
||||
<testcase name=\\"Check headers - Expected 'undefined' to be 'undefined'\\" classname=\\"test-junit-report-export/assertions/success\\"/>
|
||||
<testcase name=\\"Status code is 2xx - Expected '200' to be 200-level status\\" classname=\\"test-junit-report-export/assertions/success\\"/>
|
||||
</testsuite>
|
||||
<testsuite name=\\"test-junit-report-export/assertions/failure\\" time=\\"time\\" timestamp=\\"timestamp\\" tests=\\"5\\" failures=\\"2\\" errors=\\"0\\">
|
||||
<testcase name=\\"Simulating failure - Status code is 200 - Expected '200' to not be '200'\\" classname=\\"test-junit-report-export/assertions/failure\\">
|
||||
<failure type=\\"AssertionFailure\\" message=\\"Expected '200' to not be '200'\\"/>
|
||||
</testcase>
|
||||
<testcase name=\\"Simulating failure - Check headers - Expected 'application/json, text/plain, */*,image/webp' to not be 'application/json, text/plain, */*'\\" classname=\\"test-junit-report-export/assertions/failure\\"/>
|
||||
<testcase name=\\"Simulating failure - Check headers - Expected 'echo.hoppscotch.io' to not be 'httpbin.org'\\" classname=\\"test-junit-report-export/assertions/failure\\"/>
|
||||
<testcase name=\\"Simulating failure - Check headers - Expected 'undefined' to not be 'value'\\" classname=\\"test-junit-report-export/assertions/failure\\"/>
|
||||
<testcase name=\\"Simulating failure - Status code is 2xx - Expected '200' to not be 200-level status\\" classname=\\"test-junit-report-export/assertions/failure\\">
|
||||
<failure type=\\"AssertionFailure\\" message=\\"Expected '200' to not be 200-level status\\"/>
|
||||
</testcase>
|
||||
</testsuite>
|
||||
</testsuites>"
|
||||
`;
|
||||
|
||||
exports[`hopp test [options] <file_path_or_id> > Test\`hopp test <file_path_or_id> --env <file_path_or_id> --reporter-junit [path] > Generates a JUnit report for a collection referring to environment variables 1`] = `
|
||||
"<?xml version=\\"1.0\\" encoding=\\"UTF-8\\"?>
|
||||
<testsuites tests=\\"12\\" failures=\\"0\\" errors=\\"0\\" time=\\"time\\">
|
||||
<testsuite name=\\"Test environment variables in request body/test-request\\" time=\\"time\\" timestamp=\\"timestamp\\" tests=\\"12\\" failures=\\"0\\" errors=\\"0\\">
|
||||
<testcase name=\\"Status code is 200 - Expected '200' to be '200'\\" classname=\\"Test environment variables in request body/test-request\\"/>
|
||||
<testcase name=\\"Successfully resolves environments recursively - Expected 'Hello' to be 'Hello'\\" classname=\\"Test environment variables in request body/test-request\\"/>
|
||||
<testcase name=\\"Successfully resolves environments recursively - Expected 'Hello' to be 'Hello'\\" classname=\\"Test environment variables in request body/test-request\\"/>
|
||||
<testcase name=\\"Successfully resolves environments recursively - Expected 'Hello' to be 'Hello'\\" classname=\\"Test environment variables in request body/test-request\\"/>
|
||||
<testcase name=\\"Successfully resolves environments referenced in the request body - Expected 'Hello' to be 'Hello'\\" classname=\\"Test environment variables in request body/test-request\\"/>
|
||||
<testcase name=\\"Successfully resolves environments referenced in the request body - Expected 'Hello' to be 'Hello'\\" classname=\\"Test environment variables in request body/test-request\\"/>
|
||||
<testcase name=\\"Successfully resolves environments referenced in the request body - Expected 'Hello' to be 'Hello'\\" classname=\\"Test environment variables in request body/test-request\\"/>
|
||||
<testcase name=\\"Successfully resolves environments referenced in the request body - Expected '7' to be '7'\\" classname=\\"Test environment variables in request body/test-request\\"/>
|
||||
<testcase name=\\"Successfully resolves environments referenced in the request body - Expected 'John' to be 'John'\\" classname=\\"Test environment variables in request body/test-request\\"/>
|
||||
<testcase name=\\"Successfully resolves environments referenced in the request body - Expected 'Doe' to be 'Doe'\\" classname=\\"Test environment variables in request body/test-request\\"/>
|
||||
<testcase name=\\"Successfully resolves environments referenced in the request body - Expected 'John Doe' to be 'John Doe'\\" classname=\\"Test environment variables in request body/test-request\\"/>
|
||||
<testcase name=\\"Successfully resolves environments referenced in the request body - Expected 'Hello, John Doe' to be 'Hello, John Doe'\\" classname=\\"Test environment variables in request body/test-request\\"/>
|
||||
</testsuite>
|
||||
</testsuites>"
|
||||
`;
|
||||
|
||||
exports[`hopp test [options] <file_path_or_id> > Test\`hopp test <file_path_or_id> --env <file_path_or_id> --reporter-junit [path] > Generates a JUnit report for a collection with authorization/headers set at the collection level 1`] = `
|
||||
"<?xml version=\\"1.0\\" encoding=\\"UTF-8\\"?>
|
||||
<testsuites tests=\\"12\\" failures=\\"0\\" errors=\\"0\\" time=\\"time\\">
|
||||
<testsuite name=\\"CollectionB/RequestA\\" time=\\"time\\" timestamp=\\"timestamp\\" tests=\\"2\\" failures=\\"0\\" errors=\\"0\\">
|
||||
<testcase name=\\"Correctly inherits auth and headers from the root collection - Expected 'Set at root collection' to be 'Set at root collection'\\" classname=\\"CollectionB/RequestA\\"/>
|
||||
<testcase name=\\"Correctly inherits auth and headers from the root collection - Expected 'Bearer BearerToken' to be 'Bearer BearerToken'\\" classname=\\"CollectionB/RequestA\\"/>
|
||||
</testsuite>
|
||||
<testsuite name=\\"CollectionB/FolderA/RequestB\\" time=\\"time\\" timestamp=\\"timestamp\\" tests=\\"2\\" failures=\\"0\\" errors=\\"0\\">
|
||||
<testcase name=\\"Correctly inherits auth and headers from the parent folder - Expected 'Set at root collection' to be 'Set at root collection'\\" classname=\\"CollectionB/FolderA/RequestB\\"/>
|
||||
<testcase name=\\"Correctly inherits auth and headers from the parent folder - Expected 'Bearer BearerToken' to be 'Bearer BearerToken'\\" classname=\\"CollectionB/FolderA/RequestB\\"/>
|
||||
</testsuite>
|
||||
<testsuite name=\\"CollectionA/RequestA\\" time=\\"time\\" timestamp=\\"timestamp\\" tests=\\"2\\" failures=\\"0\\" errors=\\"0\\">
|
||||
<testcase name=\\"Correctly inherits auth and headers from the root collection - Expected 'Set at root collection' to be 'Set at root collection'\\" classname=\\"CollectionA/RequestA\\"/>
|
||||
<testcase name=\\"Correctly inherits auth and headers from the root collection - Expected 'Bearer BearerToken' to be 'Bearer BearerToken'\\" classname=\\"CollectionA/RequestA\\"/>
|
||||
</testsuite>
|
||||
<testsuite name=\\"CollectionA/FolderA/RequestB\\" time=\\"time\\" timestamp=\\"timestamp\\" tests=\\"2\\" failures=\\"0\\" errors=\\"0\\">
|
||||
<testcase name=\\"Correctly inherits auth and headers from the parent folder - Expected 'Set at root collection' to be 'Set at root collection'\\" classname=\\"CollectionA/FolderA/RequestB\\"/>
|
||||
<testcase name=\\"Correctly inherits auth and headers from the parent folder - Expected 'Bearer BearerToken' to be 'Bearer BearerToken'\\" classname=\\"CollectionA/FolderA/RequestB\\"/>
|
||||
</testsuite>
|
||||
<testsuite name=\\"CollectionA/FolderA/FolderB/RequestC\\" time=\\"time\\" timestamp=\\"timestamp\\" tests=\\"2\\" failures=\\"0\\" errors=\\"0\\">
|
||||
<testcase name=\\"Correctly inherits auth and headers from the parent folder - Expected 'Overriden at FolderB' to be 'Overriden at FolderB'\\" classname=\\"CollectionA/FolderA/FolderB/RequestC\\"/>
|
||||
<testcase name=\\"Correctly inherits auth and headers from the parent folder - Expected 'test-key' to be 'test-key'\\" classname=\\"CollectionA/FolderA/FolderB/RequestC\\"/>
|
||||
</testsuite>
|
||||
<testsuite name=\\"CollectionA/FolderA/FolderB/FolderC/RequestD\\" time=\\"time\\" timestamp=\\"timestamp\\" tests=\\"2\\" failures=\\"0\\" errors=\\"0\\">
|
||||
<testcase name=\\"Overrides auth and headers set at the parent folder - Expected 'Overriden at RequestD' to be 'Overriden at RequestD'\\" classname=\\"CollectionA/FolderA/FolderB/FolderC/RequestD\\"/>
|
||||
<testcase name=\\"Overrides auth and headers set at the parent folder - Expected 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=' to be 'Basic dXNlcm5hbWU6cGFzc3dvcmQ='\\" classname=\\"CollectionA/FolderA/FolderB/FolderC/RequestD\\"/>
|
||||
</testsuite>
|
||||
</testsuites>"
|
||||
`;
|
||||
670
packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts
Normal file
670
packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts
Normal file
@@ -0,0 +1,670 @@
|
||||
import { ExecException } from "child_process";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
import { HoppErrorCode } from "../../../types/errors";
|
||||
import { getErrorCode, getTestJsonFilePath, runCLI } from "../../utils";
|
||||
|
||||
describe("hopp test [options] <file_path_or_id>", () => {
|
||||
const VALID_TEST_ARGS = `test ${getTestJsonFilePath("passes-coll.json", "collection")}`;
|
||||
|
||||
describe("Test `hopp test <file_path_or_id>` command:", () => {
|
||||
describe("Argument parsing", () => {
|
||||
test("Errors with the code `INVALID_ARGUMENT` for not supplying enough arguments", async () => {
|
||||
const args = "test";
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
|
||||
});
|
||||
|
||||
test("Errors with the code `INVALID_ARGUMENT` for an invalid command", async () => {
|
||||
const args = "invalid-arg";
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Supplied collection export file validations", () => {
|
||||
test("Errors with the code `FILE_NOT_FOUND` if the supplied collection export file doesn't exist", async () => {
|
||||
const args = "test notfound.json";
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("FILE_NOT_FOUND");
|
||||
});
|
||||
|
||||
test("Errors with the code UNKNOWN_ERROR if the supplied collection export file content isn't valid JSON", async () => {
|
||||
const args = `test ${getTestJsonFilePath("malformed-coll.json", "collection")}`;
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("UNKNOWN_ERROR");
|
||||
});
|
||||
|
||||
test("Errors with the code `MALFORMED_COLLECTION` if the supplied collection export file content is malformed", async () => {
|
||||
const args = `test ${getTestJsonFilePath("malformed-coll-2.json", "collection")}`;
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("MALFORMED_COLLECTION");
|
||||
});
|
||||
|
||||
test("Errors with the code `INVALID_FILE_TYPE` if the supplied collection export file doesn't end with the `.json` extension", async () => {
|
||||
const args = `test ${getTestJsonFilePath("notjson-coll.txt", "collection")}`;
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("INVALID_FILE_TYPE");
|
||||
});
|
||||
|
||||
test("Fails if the collection file includes scripts with incorrect API usage and failed assertions", async () => {
|
||||
const args = `test ${getTestJsonFilePath("fails-coll.json", "collection")}`;
|
||||
const { error } = await runCLI(args);
|
||||
|
||||
expect(error).not.toBeNull();
|
||||
expect(error).toMatchObject(<ExecException>{
|
||||
code: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Versioned entities", () => {
|
||||
describe("Collections & Requests", () => {
|
||||
const testFixtures = [
|
||||
{ fileName: "coll-v1-req-v0.json", collVersion: 1, reqVersion: 0 },
|
||||
{ fileName: "coll-v1-req-v1.json", collVersion: 1, reqVersion: 1 },
|
||||
{ fileName: "coll-v2-req-v2.json", collVersion: 2, reqVersion: 2 },
|
||||
{ fileName: "coll-v2-req-v3.json", collVersion: 2, reqVersion: 3 },
|
||||
];
|
||||
|
||||
testFixtures.forEach(({ collVersion, fileName, reqVersion }) => {
|
||||
test(`Successfully processes a supplied collection export file where the collection is based on the "v${collVersion}" schema and the request following the "v${reqVersion}" schema`, async () => {
|
||||
const args = `test ${getTestJsonFilePath(fileName, "collection")}`;
|
||||
const { error } = await runCLI(args);
|
||||
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Environments", () => {
|
||||
const testFixtures = [
|
||||
{ fileName: "env-v0.json", version: 0 },
|
||||
{ fileName: "env-v1.json", version: 1 },
|
||||
];
|
||||
|
||||
testFixtures.forEach(({ fileName, version }) => {
|
||||
test(`Successfully processes the supplied collection and environment export files where the environment is based on the "v${version}" schema`, async () => {
|
||||
const ENV_PATH = getTestJsonFilePath(fileName, "environment");
|
||||
const args = `test ${getTestJsonFilePath("sample-coll.json", "collection")} --env ${ENV_PATH}`;
|
||||
const { error } = await runCLI(args);
|
||||
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("Successfully processes a supplied collection export file of the expected format", async () => {
|
||||
const args = `test ${getTestJsonFilePath("passes-coll.json", "collection")}`;
|
||||
const { error } = await runCLI(args);
|
||||
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
test("Successfully inherits/overrides authorization and headers specified at the root collection at deeply nested collections", async () => {
|
||||
const args = `test ${getTestJsonFilePath(
|
||||
"collection-level-auth-headers-coll.json",
|
||||
"collection"
|
||||
)}`;
|
||||
const { error } = await runCLI(args);
|
||||
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
test(
|
||||
"Successfully inherits/overrides authorization and headers at each level with multiple child collections",
|
||||
async () => {
|
||||
const args = `test ${getTestJsonFilePath(
|
||||
"multiple-child-collections-auth-headers-coll.json",
|
||||
"collection"
|
||||
)}`;
|
||||
const { error } = await runCLI(args);
|
||||
|
||||
expect(error).toBeNull();
|
||||
},
|
||||
{ timeout: 50000 }
|
||||
);
|
||||
|
||||
test("Persists environment variables set in the pre-request script for consumption in the test script", async () => {
|
||||
const args = `test ${getTestJsonFilePath(
|
||||
"pre-req-script-env-var-persistence-coll.json",
|
||||
"collection"
|
||||
)}`;
|
||||
const { error } = await runCLI(args);
|
||||
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test `hopp test <file_path_or_id> --env <file_path_or_id>` command:", () => {
|
||||
describe("Supplied environment export file validations", () => {
|
||||
describe("Argument parsing", () => {
|
||||
test("Errors with the code `INVALID_ARGUMENT` if no file is supplied", async () => {
|
||||
const args = `${VALID_TEST_ARGS} --env`;
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
|
||||
});
|
||||
});
|
||||
|
||||
test("Errors with the code `INVALID_FILE_TYPE` if the supplied environment export file doesn't end with the `.json` extension", async () => {
|
||||
const args = `${VALID_TEST_ARGS} --env ${getTestJsonFilePath(
|
||||
"notjson-coll.txt",
|
||||
"collection"
|
||||
)}`;
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("INVALID_FILE_TYPE");
|
||||
});
|
||||
|
||||
test("Errors with the code `FILE_NOT_FOUND` if the supplied environment export file doesn't exist", async () => {
|
||||
const args = `${VALID_TEST_ARGS} --env notfound.json`;
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("FILE_NOT_FOUND");
|
||||
});
|
||||
|
||||
test("Errors with the code `MALFORMED_ENV_FILE` on supplying a malformed environment export file", async () => {
|
||||
const ENV_PATH = getTestJsonFilePath(
|
||||
"malformed-envs.json",
|
||||
"environment"
|
||||
);
|
||||
const args = `${VALID_TEST_ARGS} --env ${ENV_PATH}`;
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("MALFORMED_ENV_FILE");
|
||||
});
|
||||
|
||||
test("Errors with the code `BULK_ENV_FILE` on supplying an environment export file based on the bulk environment export format", async () => {
|
||||
const ENV_PATH = getTestJsonFilePath("bulk-envs.json", "environment");
|
||||
const args = `${VALID_TEST_ARGS} --env ${ENV_PATH}`;
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("BULK_ENV_FILE");
|
||||
});
|
||||
});
|
||||
|
||||
test("Successfully resolves values from the supplied environment export file", async () => {
|
||||
const TESTS_PATH = getTestJsonFilePath(
|
||||
"env-flag-tests-coll.json",
|
||||
"collection"
|
||||
);
|
||||
const ENV_PATH = getTestJsonFilePath("env-flag-envs.json", "environment");
|
||||
const args = `test ${TESTS_PATH} --env ${ENV_PATH}`;
|
||||
|
||||
const { error } = await runCLI(args);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
test("Successfully resolves environment variables referenced in the request body", async () => {
|
||||
const COLL_PATH = getTestJsonFilePath(
|
||||
"req-body-env-vars-coll.json",
|
||||
"collection"
|
||||
);
|
||||
const ENVS_PATH = getTestJsonFilePath(
|
||||
"req-body-env-vars-envs.json",
|
||||
"environment"
|
||||
);
|
||||
const args = `test ${COLL_PATH} --env ${ENVS_PATH}`;
|
||||
|
||||
const { error } = await runCLI(args);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
test("Works with short `-e` flag", async () => {
|
||||
const TESTS_PATH = getTestJsonFilePath(
|
||||
"env-flag-tests-coll.json",
|
||||
"collection"
|
||||
);
|
||||
const ENV_PATH = getTestJsonFilePath("env-flag-envs.json", "environment");
|
||||
const args = `test ${TESTS_PATH} -e ${ENV_PATH}`;
|
||||
|
||||
const { error } = await runCLI(args);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
describe(
|
||||
"Secret environment variables",
|
||||
() => {
|
||||
// Reads secret environment values from system environment
|
||||
test("Successfully picks the values for secret environment variables from `process.env` and persists the variables set from the pre-request script", async () => {
|
||||
const env = {
|
||||
...process.env,
|
||||
secretBearerToken: "test-token",
|
||||
secretBasicAuthUsername: "test-user",
|
||||
secretBasicAuthPassword: "test-pass",
|
||||
secretQueryParamValue: "secret-query-param-value",
|
||||
secretBodyValue: "secret-body-value",
|
||||
secretHeaderValue: "secret-header-value",
|
||||
};
|
||||
|
||||
const COLL_PATH = getTestJsonFilePath(
|
||||
"secret-envs-coll.json",
|
||||
"collection"
|
||||
);
|
||||
const ENVS_PATH = getTestJsonFilePath(
|
||||
"secret-envs.json",
|
||||
"environment"
|
||||
);
|
||||
const args = `test ${COLL_PATH} --env ${ENVS_PATH}`;
|
||||
|
||||
const { error, stdout } = await runCLI(args, { env });
|
||||
|
||||
expect(stdout).toContain(
|
||||
"https://httpbin.org/basic-auth/*********/*********"
|
||||
);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
// Prefers values specified in the environment export file over values set in the system environment
|
||||
test("Successfully picks the values for secret environment variables set directly in the environment export file and persists the environment variables set from the pre-request script", async () => {
|
||||
const COLL_PATH = getTestJsonFilePath(
|
||||
"secret-envs-coll.json",
|
||||
"collection"
|
||||
);
|
||||
const ENVS_PATH = getTestJsonFilePath(
|
||||
"secret-supplied-values-envs.json",
|
||||
"environment"
|
||||
);
|
||||
const args = `test ${COLL_PATH} --env ${ENVS_PATH}`;
|
||||
|
||||
const { error, stdout } = await runCLI(args);
|
||||
|
||||
expect(stdout).toContain(
|
||||
"https://httpbin.org/basic-auth/*********/*********"
|
||||
);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
// Values set from the scripting context takes the highest precedence
|
||||
test("Setting values for secret environment variables from the pre-request script overrides values set at the supplied environment export file", async () => {
|
||||
const COLL_PATH = getTestJsonFilePath(
|
||||
"secret-envs-persistence-coll.json",
|
||||
"collection"
|
||||
);
|
||||
const ENVS_PATH = getTestJsonFilePath(
|
||||
"secret-supplied-values-envs.json",
|
||||
"environment"
|
||||
);
|
||||
const args = `test ${COLL_PATH} --env ${ENVS_PATH}`;
|
||||
|
||||
const { error, stdout } = await runCLI(args);
|
||||
|
||||
expect(stdout).toContain(
|
||||
"https://httpbin.org/basic-auth/*********/*********"
|
||||
);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
test("Persists secret environment variable values set from the pre-request script for consumption in the request and post-request script context", async () => {
|
||||
const COLL_PATH = getTestJsonFilePath(
|
||||
"secret-envs-persistence-scripting-coll.json",
|
||||
"collection"
|
||||
);
|
||||
const ENVS_PATH = getTestJsonFilePath(
|
||||
"secret-envs-persistence-scripting-envs.json",
|
||||
"environment"
|
||||
);
|
||||
const args = `test ${COLL_PATH} --env ${ENVS_PATH}`;
|
||||
|
||||
const { error } = await runCLI(args);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
},
|
||||
{ timeout: 20000 }
|
||||
);
|
||||
});
|
||||
|
||||
describe("Test `hopp test <file_path_or_id> --delay <delay_in_ms>` command:", () => {
|
||||
describe("Argument parsing", () => {
|
||||
test("Errors with the code `INVALID_ARGUMENT` on not supplying a delay value", async () => {
|
||||
const args = `${VALID_TEST_ARGS} --delay`;
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
|
||||
});
|
||||
|
||||
test("Errors with the code `INVALID_ARGUMENT` on supplying an invalid delay value", async () => {
|
||||
const args = `${VALID_TEST_ARGS} --delay 'NaN'`;
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
|
||||
});
|
||||
});
|
||||
|
||||
test("Successfully performs delayed request execution for a valid delay value", async () => {
|
||||
const args = `${VALID_TEST_ARGS} --delay 1`;
|
||||
const { error } = await runCLI(args);
|
||||
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
test("Works with the short `-d` flag", async () => {
|
||||
const args = `${VALID_TEST_ARGS} -d 1`;
|
||||
const { error } = await runCLI(args);
|
||||
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// Future TODO: Enable once a proper e2e test environment is set up locally
|
||||
describe.skip("Test `hopp test <file_path_or_id> --env <file_path_or_id> --token <access_token> --server <server_url>` command:", () => {
|
||||
const {
|
||||
REQ_BODY_ENV_VARS_COLL_ID,
|
||||
COLLECTION_LEVEL_HEADERS_AUTH_COLL_ID,
|
||||
REQ_BODY_ENV_VARS_ENVS_ID,
|
||||
PERSONAL_ACCESS_TOKEN,
|
||||
} = process.env;
|
||||
|
||||
if (
|
||||
!REQ_BODY_ENV_VARS_COLL_ID ||
|
||||
!COLLECTION_LEVEL_HEADERS_AUTH_COLL_ID ||
|
||||
!REQ_BODY_ENV_VARS_ENVS_ID ||
|
||||
!PERSONAL_ACCESS_TOKEN
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const SERVER_URL = "https://stage-shc.hoppscotch.io/backend";
|
||||
|
||||
describe("Argument parsing", () => {
|
||||
test("Errors with the code `INVALID_ARGUMENT` on not supplying a value for the `--token` flag", async () => {
|
||||
const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --token`;
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
|
||||
});
|
||||
|
||||
test("Errors with the code `INVALID_ARGUMENT` on not supplying a value for the `--server` flag", async () => {
|
||||
const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --server`;
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Workspace access validations", () => {
|
||||
const INVALID_COLLECTION_ID = "invalid-coll-id";
|
||||
const INVALID_ENVIRONMENT_ID = "invalid-env-id";
|
||||
const INVALID_ACCESS_TOKEN = "invalid-token";
|
||||
|
||||
test("Errors with the code `TOKEN_INVALID` if the supplied access token is invalid", async () => {
|
||||
const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --token ${INVALID_ACCESS_TOKEN} --server ${SERVER_URL}`;
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("TOKEN_INVALID");
|
||||
});
|
||||
|
||||
test("Errors with the code `INVALID_ID` if the supplied collection ID is invalid", async () => {
|
||||
const args = `test ${INVALID_COLLECTION_ID} --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`;
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("INVALID_ID");
|
||||
});
|
||||
|
||||
test("Errors with the code `INVALID_ID` if the supplied environment ID is invalid", async () => {
|
||||
const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --env ${INVALID_ENVIRONMENT_ID} --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`;
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("INVALID_ID");
|
||||
});
|
||||
|
||||
test("Errors with the code `INVALID_SERVER_URL` if not supplying a valid SH instance server URL", async () => {
|
||||
// FE URL of the staging SHC instance
|
||||
const INVALID_SERVER_URL = "https://stage-shc.hoppscotch.io";
|
||||
const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --env ${REQ_BODY_ENV_VARS_ENVS_ID} --token ${PERSONAL_ACCESS_TOKEN} --server ${INVALID_SERVER_URL}`;
|
||||
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("INVALID_SERVER_URL");
|
||||
});
|
||||
|
||||
test("Errors with the code `SERVER_CONNECTION_REFUSED` if supplying an SH instance server URL that doesn't follow URL semantics", async () => {
|
||||
const INVALID_URL = "invalid-url";
|
||||
const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --env ${REQ_BODY_ENV_VARS_ENVS_ID} --token ${PERSONAL_ACCESS_TOKEN} --server ${INVALID_URL}`;
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("SERVER_CONNECTION_REFUSED");
|
||||
});
|
||||
});
|
||||
|
||||
test("Successfully retrieves a collection with the ID", async () => {
|
||||
const args = `test ${COLLECTION_LEVEL_HEADERS_AUTH_COLL_ID} --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`;
|
||||
|
||||
const { error } = await runCLI(args);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
test("Successfully retrieves collections and environments from a workspace using their respective IDs", async () => {
|
||||
const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --env ${REQ_BODY_ENV_VARS_ENVS_ID} --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`;
|
||||
|
||||
const { error } = await runCLI(args);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
test("Supports specifying collection file path along with environment ID", async () => {
|
||||
const TESTS_PATH = getTestJsonFilePath(
|
||||
"req-body-env-vars-coll.json",
|
||||
"collection"
|
||||
);
|
||||
const args = `test ${TESTS_PATH} --env ${REQ_BODY_ENV_VARS_ENVS_ID} --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`;
|
||||
|
||||
const { error } = await runCLI(args);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
test("Supports specifying environment file path along with collection ID", async () => {
|
||||
const ENV_PATH = getTestJsonFilePath(
|
||||
"req-body-env-vars-envs.json",
|
||||
"environment"
|
||||
);
|
||||
const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --env ${ENV_PATH} --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`;
|
||||
|
||||
const { error } = await runCLI(args);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
test("Supports specifying both collection and environment file paths", async () => {
|
||||
const TESTS_PATH = getTestJsonFilePath(
|
||||
"req-body-env-vars-coll.json",
|
||||
"collection"
|
||||
);
|
||||
const ENV_PATH = getTestJsonFilePath(
|
||||
"req-body-env-vars-envs.json",
|
||||
"environment"
|
||||
);
|
||||
const args = `test ${TESTS_PATH} --env ${ENV_PATH} --token ${PERSONAL_ACCESS_TOKEN}`;
|
||||
|
||||
const { error } = await runCLI(args);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test`hopp test <file_path_or_id> --env <file_path_or_id> --reporter-junit [path]", () => {
|
||||
const genPath = path.resolve("hopp-cli-test");
|
||||
|
||||
// Helper function to replace dynamic values before generating test snapshots
|
||||
// Currently scoped to JUnit report generation
|
||||
const replaceDynamicValuesInStr = (input: string): string =>
|
||||
input.replace(
|
||||
/(time|timestamp)="[^"]+"/g,
|
||||
(_, attr) => `${attr}="${attr}"`
|
||||
);
|
||||
|
||||
beforeAll(() => {
|
||||
fs.mkdirSync(genPath);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmdirSync(genPath, { recursive: true });
|
||||
});
|
||||
|
||||
test("Report export fails with the code `REPORT_EXPORT_FAILED` while encountering an error during path creation", async () => {
|
||||
const exportPath = "hopp-junit-report.xml";
|
||||
|
||||
const COLL_PATH = getTestJsonFilePath("passes-coll.json", "collection");
|
||||
|
||||
const args = `test ${COLL_PATH} --reporter-junit /non-existent-path/report.xml`;
|
||||
|
||||
const { stdout, stderr } = await runCLI(args, {
|
||||
cwd: path.resolve("hopp-cli-test"),
|
||||
});
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("REPORT_EXPORT_FAILED");
|
||||
|
||||
expect(stdout).not.toContain(
|
||||
`Successfully exported the JUnit report to: ${exportPath}`
|
||||
);
|
||||
});
|
||||
|
||||
test("Generates a JUnit report at the default path", async () => {
|
||||
const exportPath = "hopp-junit-report.xml";
|
||||
|
||||
const COLL_PATH = getTestJsonFilePath(
|
||||
"test-junit-report-export-coll.json",
|
||||
"collection"
|
||||
);
|
||||
|
||||
const args = `test ${COLL_PATH} --reporter-junit`;
|
||||
|
||||
const { stdout } = await runCLI(args, {
|
||||
cwd: path.resolve("hopp-cli-test"),
|
||||
});
|
||||
|
||||
expect(stdout).not.toContain(
|
||||
`Overwriting the pre-existing path: ${exportPath}`
|
||||
);
|
||||
|
||||
expect(stdout).toContain(
|
||||
`Successfully exported the JUnit report to: ${exportPath}`
|
||||
);
|
||||
|
||||
const fileContents = fs
|
||||
.readFileSync(path.resolve(genPath, exportPath))
|
||||
.toString();
|
||||
|
||||
expect(replaceDynamicValuesInStr(fileContents)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("Generates a JUnit report at the specified path", async () => {
|
||||
const exportPath = "outer-dir/inner-dir/report.xml";
|
||||
|
||||
const COLL_PATH = getTestJsonFilePath(
|
||||
"test-junit-report-export-coll.json",
|
||||
"collection"
|
||||
);
|
||||
|
||||
const args = `test ${COLL_PATH} --reporter-junit ${exportPath}`;
|
||||
|
||||
const { stdout } = await runCLI(args, {
|
||||
cwd: path.resolve("hopp-cli-test"),
|
||||
});
|
||||
|
||||
expect(stdout).not.toContain(
|
||||
`Overwriting the pre-existing path: ${exportPath}`
|
||||
);
|
||||
|
||||
expect(stdout).toContain(
|
||||
`Successfully exported the JUnit report to: ${exportPath}`
|
||||
);
|
||||
|
||||
const fileContents = fs
|
||||
.readFileSync(path.resolve(genPath, exportPath))
|
||||
.toString();
|
||||
|
||||
expect(replaceDynamicValuesInStr(fileContents)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("Generates a JUnit report for a collection with authorization/headers set at the collection level", async () => {
|
||||
const exportPath = "hopp-junit-report.xml";
|
||||
|
||||
const COLL_PATH = getTestJsonFilePath(
|
||||
"collection-level-auth-headers-coll.json",
|
||||
"collection"
|
||||
);
|
||||
|
||||
const args = `test ${COLL_PATH} --reporter-junit`;
|
||||
|
||||
const { stdout } = await runCLI(args, {
|
||||
cwd: path.resolve("hopp-cli-test"),
|
||||
});
|
||||
|
||||
expect(stdout).toContain(
|
||||
`Overwriting the pre-existing path: ${exportPath}`
|
||||
);
|
||||
|
||||
expect(stdout).toContain(
|
||||
`Successfully exported the JUnit report to: ${exportPath}`
|
||||
);
|
||||
|
||||
const fileContents = fs
|
||||
.readFileSync(path.resolve(genPath, exportPath))
|
||||
.toString();
|
||||
|
||||
expect(replaceDynamicValuesInStr(fileContents)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("Generates a JUnit report for a collection referring to environment variables", async () => {
|
||||
const exportPath = "hopp-junit-report.xml";
|
||||
|
||||
const COLL_PATH = getTestJsonFilePath(
|
||||
"req-body-env-vars-coll.json",
|
||||
"collection"
|
||||
);
|
||||
const ENV_PATH = getTestJsonFilePath(
|
||||
"req-body-env-vars-envs.json",
|
||||
"environment"
|
||||
);
|
||||
|
||||
const args = `test ${COLL_PATH} --env ${ENV_PATH} --reporter-junit`;
|
||||
|
||||
const { stdout } = await runCLI(args, {
|
||||
cwd: path.resolve("hopp-cli-test"),
|
||||
});
|
||||
|
||||
expect(stdout).toContain(
|
||||
`Overwriting the pre-existing path: ${exportPath}`
|
||||
);
|
||||
|
||||
expect(stdout).toContain(
|
||||
`Successfully exported the JUnit report to: ${exportPath}`
|
||||
);
|
||||
|
||||
const fileContents = fs
|
||||
.readFileSync(path.resolve(genPath, exportPath))
|
||||
.toString();
|
||||
|
||||
expect(replaceDynamicValuesInStr(fileContents)).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,655 @@
|
||||
{
|
||||
"v": 2,
|
||||
"id": "clx1f86hv000010f8szcfya0t",
|
||||
"name": "Multiple child collections with authorization & headers set at each level",
|
||||
"folders": [
|
||||
{
|
||||
"v": 2,
|
||||
"id": "clx1fjgah000110f8a5bs68gd",
|
||||
"name": "folder-1",
|
||||
"folders": [
|
||||
{
|
||||
"v": 2,
|
||||
"id": "clx1fjwmm000410f8l1gkkr1a",
|
||||
"name": "folder-11",
|
||||
"folders": [],
|
||||
"requests": [
|
||||
{
|
||||
"v": "4",
|
||||
"auth": {
|
||||
"authType": "inherit",
|
||||
"password": "testpass",
|
||||
"username": "testuser",
|
||||
"authActive": true
|
||||
},
|
||||
"body": {
|
||||
"body": null,
|
||||
"contentType": null
|
||||
},
|
||||
"name": "folder-11-request",
|
||||
"method": "GET",
|
||||
"params": [],
|
||||
"headers": [],
|
||||
"endpoint": "https://httpbin.org/get",
|
||||
"testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully inherits authorization/header set at the parent collection level\", () => {\n pw.expect(pw.response.body.headers[\"Authorization\"]).toBe(\"Basic dGVzdHVzZXI6dGVzdHBhc3M=\")\n \n pw.expect(pw.response.body.headers[\"Custom-Header\"]).toBe(\"Custom header value overriden at folder-1\")\n pw.expect(pw.response.body.headers[\"Inherited-Header\"]).toBe(\"Inherited header at all levels\")\n})",
|
||||
"preRequestScript": "",
|
||||
"requestVariables": []
|
||||
}
|
||||
],
|
||||
"auth": {
|
||||
"authType": "inherit",
|
||||
"authActive": true
|
||||
},
|
||||
"headers": [
|
||||
{
|
||||
"key": "key",
|
||||
"value": "Set at folder-11",
|
||||
"active": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"v": 2,
|
||||
"id": "clx1fjyxm000510f8pv90dt43",
|
||||
"name": "folder-12",
|
||||
"folders": [],
|
||||
"requests": [
|
||||
{
|
||||
"v": "4",
|
||||
"auth": {
|
||||
"authType": "none",
|
||||
"authActive": true
|
||||
},
|
||||
"body": {
|
||||
"body": null,
|
||||
"contentType": null
|
||||
},
|
||||
"name": "folder-12-request",
|
||||
"method": "GET",
|
||||
"params": [],
|
||||
"headers": [
|
||||
{
|
||||
"key": "Custom-Header",
|
||||
"value": "Custom header value overriden at folder-12-request",
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"key": "key",
|
||||
"value": "Overriden at folder-12-request",
|
||||
"active": true
|
||||
}
|
||||
],
|
||||
"endpoint": "https://httpbin.org/get",
|
||||
"testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully inherits/overrides authorization/header set at the parent collection level\", () => {\n pw.expect(pw.response.body.headers[\"Authorization\"]).toBe(undefined)\n \n pw.expect(pw.response.body.headers[\"Custom-Header\"]).toBe(\"Custom header value overriden at folder-12-request\")\n pw.expect(pw.response.body.headers[\"Inherited-Header\"]).toBe(\"Inherited header at all levels\")\n pw.expect(pw.response.body.headers[\"Key\"]).toBe(\"Overriden at folder-12-request\")\n})",
|
||||
"preRequestScript": "",
|
||||
"requestVariables": []
|
||||
}
|
||||
],
|
||||
"auth": {
|
||||
"authType": "none",
|
||||
"authActive": true
|
||||
},
|
||||
"headers": [
|
||||
{
|
||||
"key": "Custom-Header",
|
||||
"value": "Custom header value overriden at folder-12",
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"key": "key",
|
||||
"value": "Set at folder-12",
|
||||
"active": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"v": 2,
|
||||
"id": "clx1fk1cv000610f88kc3aupy",
|
||||
"name": "folder-13",
|
||||
"folders": [],
|
||||
"requests": [
|
||||
{
|
||||
"v": "4",
|
||||
"auth": {
|
||||
"key": "api-key",
|
||||
"addTo": "HEADERS",
|
||||
"value": "api-key-value",
|
||||
"authType": "basic",
|
||||
"password": "testpass",
|
||||
"username": "testuser",
|
||||
"authActive": true,
|
||||
"grantTypeInfo": {
|
||||
"token": "",
|
||||
"isPKCE": true,
|
||||
"clientID": "sfasfa",
|
||||
"password": "",
|
||||
"username": "",
|
||||
"grantType": "AUTHORIZATION_CODE",
|
||||
"authEndpoint": "asfafs",
|
||||
"clientSecret": "sfasfasf",
|
||||
"tokenEndpoint": "asfa",
|
||||
"codeVerifierMethod": "S256"
|
||||
}
|
||||
},
|
||||
"body": {
|
||||
"body": null,
|
||||
"contentType": null
|
||||
},
|
||||
"name": "folder-13-request",
|
||||
"method": "GET",
|
||||
"params": [],
|
||||
"headers": [
|
||||
{
|
||||
"key": "Custom-Header-Request-Level",
|
||||
"value": "New custom header added at the folder-13-request level",
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"key": "key",
|
||||
"value": "Overriden at folder-13-request",
|
||||
"active": true
|
||||
}
|
||||
],
|
||||
"endpoint": "https://httpbin.org/get",
|
||||
"testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully inherits/overrides authorization/header set at the parent collection level with new header addition\", () => {\n pw.expect(pw.response.body.headers[\"Authorization\"]).toBe(\"Basic dGVzdHVzZXI6dGVzdHBhc3M=\")\n \n pw.expect(pw.response.body.headers[\"Custom-Header\"]).toBe(\"Custom header value overriden at folder-13\")\n pw.expect(pw.response.body.headers[\"Inherited-Header\"]).toBe(\"Inherited header at all levels\")\n pw.expect(pw.response.body.headers[\"Key\"]).toBe(\"Overriden at folder-13-request\")\n pw.expect(pw.response.body.headers[\"Custom-Header-Request-Level\"]).toBe(\"New custom header added at the folder-13-request level\")\n})",
|
||||
"preRequestScript": "",
|
||||
"requestVariables": []
|
||||
}
|
||||
],
|
||||
"auth": {
|
||||
"token": "test-token",
|
||||
"authType": "bearer",
|
||||
"authActive": true
|
||||
},
|
||||
"headers": [
|
||||
{
|
||||
"key": "Custom-Header",
|
||||
"value": "Custom header value overriden at folder-13",
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"key": "key",
|
||||
"value": "Set at folder-13",
|
||||
"active": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"requests": [
|
||||
{
|
||||
"v": "4",
|
||||
"auth": {
|
||||
"authType": "inherit",
|
||||
"authActive": true
|
||||
},
|
||||
"body": {
|
||||
"body": null,
|
||||
"contentType": null
|
||||
},
|
||||
"name": "folder-1-request",
|
||||
"method": "GET",
|
||||
"params": [],
|
||||
"headers": [],
|
||||
"endpoint": "https://httpbin.org/get",
|
||||
"testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully inherits authorization/header set at the parent collection level\", () => {\n pw.expect(pw.response.body.headers[\"Authorization\"]).toBe(\"Basic dGVzdHVzZXI6dGVzdHBhc3M=\")\n \n pw.expect(pw.response.body.headers[\"Custom-Header\"]).toBe(\"Custom header value overriden at folder-1\")\n pw.expect(pw.response.body.headers[\"Inherited-Header\"]).toBe(\"Inherited header at all levels\")\n})",
|
||||
"preRequestScript": "",
|
||||
"requestVariables": []
|
||||
}
|
||||
],
|
||||
"auth": {
|
||||
"authType": "inherit",
|
||||
"authActive": true
|
||||
},
|
||||
"headers": [
|
||||
{
|
||||
"key": "Custom-Header",
|
||||
"value": "Custom header value overriden at folder-1",
|
||||
"active": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"v": 2,
|
||||
"id": "clx1fjk9o000210f8j0573pls",
|
||||
"name": "folder-2",
|
||||
"folders": [
|
||||
{
|
||||
"v": 2,
|
||||
"id": "clx1fk516000710f87sfpw6bo",
|
||||
"name": "folder-21",
|
||||
"folders": [],
|
||||
"requests": [
|
||||
{
|
||||
"v": "4",
|
||||
"auth": {
|
||||
"authType": "inherit",
|
||||
"authActive": true
|
||||
},
|
||||
"body": {
|
||||
"body": null,
|
||||
"contentType": null
|
||||
},
|
||||
"name": "folder-21-request",
|
||||
"method": "GET",
|
||||
"params": [],
|
||||
"headers": [],
|
||||
"endpoint": "https://httpbin.org/get",
|
||||
"testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully inherits authorization/header set at the parent collection level\", () => {\n pw.expect(pw.response.body.headers[\"Authorization\"]).toBe(undefined)\n \n pw.expect(pw.response.body.headers[\"Custom-Header\"]).toBe(\"Custom header value overriden at folder-2\")\n pw.expect(pw.response.body.headers[\"Inherited-Header\"]).toBe(\"Inherited header at all levels\")\n})",
|
||||
"preRequestScript": "",
|
||||
"requestVariables": []
|
||||
}
|
||||
],
|
||||
"auth": {
|
||||
"authType": "inherit",
|
||||
"authActive": true
|
||||
},
|
||||
"headers": [
|
||||
{
|
||||
"key": "key",
|
||||
"value": "Set at folder-21",
|
||||
"active": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"v": 2,
|
||||
"id": "clx1fk72t000810f8gfwkpi5y",
|
||||
"name": "folder-22",
|
||||
"folders": [],
|
||||
"requests": [
|
||||
{
|
||||
"v": "4",
|
||||
"auth": {
|
||||
"authType": "none",
|
||||
"authActive": true
|
||||
},
|
||||
"body": {
|
||||
"body": null,
|
||||
"contentType": null
|
||||
},
|
||||
"name": "folder-22-request",
|
||||
"method": "GET",
|
||||
"params": [],
|
||||
"headers": [
|
||||
{
|
||||
"key": "Custom-Header",
|
||||
"value": "Custom header value overriden at folder-22-request",
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"key": "key",
|
||||
"value": "Overriden at folder-22-request",
|
||||
"active": true
|
||||
}
|
||||
],
|
||||
"endpoint": "https://httpbin.org/get",
|
||||
"testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully inherits/overrides authorization/header set at the parent collection level\", () => {\n pw.expect(pw.response.body.headers[\"Authorization\"]).toBe(undefined)\n \n pw.expect(pw.response.body.headers[\"Custom-Header\"]).toBe(\"Custom header value overriden at folder-22-request\")\n pw.expect(pw.response.body.headers[\"Inherited-Header\"]).toBe(\"Inherited header at all levels\")\n pw.expect(pw.response.body.headers[\"Key\"]).toBe(\"Overriden at folder-22-request\")\n})",
|
||||
"preRequestScript": "",
|
||||
"requestVariables": []
|
||||
}
|
||||
],
|
||||
"auth": {
|
||||
"authType": "none",
|
||||
"authActive": true
|
||||
},
|
||||
"headers": [
|
||||
{
|
||||
"key": "Custom-Header",
|
||||
"value": "Custom header value overriden at folder-22",
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"key": "key",
|
||||
"value": "Set at folder-22",
|
||||
"active": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"v": 2,
|
||||
"id": "clx1fk95g000910f8bunhaoo8",
|
||||
"name": "folder-23",
|
||||
"folders": [],
|
||||
"requests": [
|
||||
{
|
||||
"v": "4",
|
||||
"auth": {
|
||||
"authType": "basic",
|
||||
"password": "testpass",
|
||||
"username": "testuser",
|
||||
"authActive": true
|
||||
},
|
||||
"body": {
|
||||
"body": null,
|
||||
"contentType": null
|
||||
},
|
||||
"name": "folder-23-request",
|
||||
"method": "GET",
|
||||
"params": [],
|
||||
"headers": [
|
||||
{
|
||||
"key": "Custom-Header-Request-Level",
|
||||
"value": "New custom header added at the folder-23-request level",
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"key": "key",
|
||||
"value": "Overriden at folder-23-request",
|
||||
"active": true
|
||||
}
|
||||
],
|
||||
"endpoint": "https://httpbin.org/get",
|
||||
"testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully inherits/overrides authorization/header set at the parent collection level with new header addition\", () => {\n pw.expect(pw.response.body.headers[\"Authorization\"]).toBe(\"Basic dGVzdHVzZXI6dGVzdHBhc3M=\")\n \n pw.expect(pw.response.body.headers[\"Custom-Header\"]).toBe(\"Custom header value overriden at folder-23\")\n pw.expect(pw.response.body.headers[\"Inherited-Header\"]).toBe(\"Inherited header at all levels\")\n pw.expect(pw.response.body.headers[\"Key\"]).toBe(\"Overriden at folder-23-request\")\n pw.expect(pw.response.body.headers[\"Custom-Header-Request-Level\"]).toBe(\"New custom header added at the folder-23-request level\")\n})",
|
||||
"preRequestScript": "",
|
||||
"requestVariables": []
|
||||
}
|
||||
],
|
||||
"auth": {
|
||||
"token": "test-token",
|
||||
"authType": "bearer",
|
||||
"password": "testpass",
|
||||
"username": "testuser",
|
||||
"authActive": true
|
||||
},
|
||||
"headers": [
|
||||
{
|
||||
"key": "Custom-Header",
|
||||
"value": "Custom header value overriden at folder-23",
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"key": "key",
|
||||
"value": "Set at folder-23",
|
||||
"active": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"requests": [
|
||||
{
|
||||
"v": "4",
|
||||
"auth": {
|
||||
"authType": "none",
|
||||
"authActive": true
|
||||
},
|
||||
"body": {
|
||||
"body": null,
|
||||
"contentType": null
|
||||
},
|
||||
"name": "folder-2-request",
|
||||
"method": "GET",
|
||||
"params": [],
|
||||
"headers": [
|
||||
{
|
||||
"key": "Custom-Header",
|
||||
"value": "Custom header value overriden at folder-2-request",
|
||||
"active": true
|
||||
}
|
||||
],
|
||||
"endpoint": "https://httpbin.org/get",
|
||||
"testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully inherits/overrides authorization/header set at the parent collection level\", () => {\n pw.expect(pw.response.body.headers[\"Authorization\"]).toBe(undefined)\n \n pw.expect(pw.response.body.headers[\"Custom-Header\"]).toBe(\"Custom header value overriden at folder-2-request\")\n pw.expect(pw.response.body.headers[\"Inherited-Header\"]).toBe(\"Inherited header at all levels\")\n})",
|
||||
"preRequestScript": "",
|
||||
"requestVariables": []
|
||||
}
|
||||
],
|
||||
"auth": {
|
||||
"authType": "none",
|
||||
"authActive": true
|
||||
},
|
||||
"headers": [
|
||||
{
|
||||
"key": "Custom-Header",
|
||||
"value": "Custom header value overriden at folder-2",
|
||||
"active": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"v": 2,
|
||||
"id": "clx1fjmlq000310f86o4d3w2o",
|
||||
"name": "folder-3",
|
||||
"folders": [
|
||||
{
|
||||
"v": 2,
|
||||
"id": "clx1iwq0p003e10f8u8zg0p85",
|
||||
"name": "folder-31",
|
||||
"folders": [],
|
||||
"requests": [
|
||||
{
|
||||
"v": "4",
|
||||
"auth": {
|
||||
"authType": "inherit",
|
||||
"authActive": true
|
||||
},
|
||||
"body": {
|
||||
"body": null,
|
||||
"contentType": null
|
||||
},
|
||||
"name": "folder-31-request",
|
||||
"method": "GET",
|
||||
"params": [],
|
||||
"headers": [],
|
||||
"endpoint": "https://httpbin.org/get",
|
||||
"testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully inherits authorization/header set at the parent collection level\", () => {\n pw.expect(pw.response.body.headers[\"Authorization\"]).toBe(\"Basic dGVzdHVzZXI6dGVzdHBhc3M=\")\n \n pw.expect(pw.response.body.headers[\"Custom-Header\"]).toBe(\"Custom header value overriden at folder-3\")\n pw.expect(pw.response.body.headers[\"Inherited-Header\"]).toBe(\"Inherited header at all levels\")\n})",
|
||||
"preRequestScript": "",
|
||||
"requestVariables": []
|
||||
}
|
||||
],
|
||||
"auth": {
|
||||
"authType": "inherit",
|
||||
"authActive": true
|
||||
},
|
||||
"headers": [
|
||||
{
|
||||
"key": "key",
|
||||
"value": "Set at folder-31",
|
||||
"active": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"v": 2,
|
||||
"id": "clx1izut7003m10f894ip59zg",
|
||||
"name": "folder-32",
|
||||
"folders": [],
|
||||
"requests": [
|
||||
{
|
||||
"v": "4",
|
||||
"auth": {
|
||||
"authType": "none",
|
||||
"authActive": true
|
||||
},
|
||||
"body": {
|
||||
"body": null,
|
||||
"contentType": null
|
||||
},
|
||||
"name": "folder-32-request",
|
||||
"method": "GET",
|
||||
"params": [],
|
||||
"headers": [
|
||||
{
|
||||
"key": "Custom-Header",
|
||||
"value": "Custom header value overriden at folder-32-request",
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"key": "key",
|
||||
"value": "Overriden at folder-32-request",
|
||||
"active": true
|
||||
}
|
||||
],
|
||||
"endpoint": "https://httpbin.org/get",
|
||||
"testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully inherits/overrides authorization/header set at the parent collection level\", () => {\n pw.expect(pw.response.body.headers[\"Authorization\"]).toBe(undefined)\n \n pw.expect(pw.response.body.headers[\"Custom-Header\"]).toBe(\"Custom header value overriden at folder-32-request\")\n pw.expect(pw.response.body.headers[\"Inherited-Header\"]).toBe(\"Inherited header at all levels\")\n pw.expect(pw.response.body.headers[\"Key\"]).toBe(\"Overriden at folder-32-request\")\n})",
|
||||
"preRequestScript": "",
|
||||
"requestVariables": []
|
||||
}
|
||||
],
|
||||
"auth": {
|
||||
"authType": "none",
|
||||
"authActive": true
|
||||
},
|
||||
"headers": [
|
||||
{
|
||||
"key": "Custom-Header",
|
||||
"value": "Custom header value overriden at folder-32",
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"key": "key",
|
||||
"value": "Set at folder-32",
|
||||
"active": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"v": 2,
|
||||
"id": "clx1j2ka9003q10f8cdbzpgpg",
|
||||
"name": "folder-33",
|
||||
"folders": [],
|
||||
"requests": [
|
||||
{
|
||||
"v": "4",
|
||||
"auth": {
|
||||
"authType": "basic",
|
||||
"password": "testpass",
|
||||
"username": "testuser",
|
||||
"authActive": true
|
||||
},
|
||||
"body": {
|
||||
"body": null,
|
||||
"contentType": null
|
||||
},
|
||||
"name": "folder-33-request",
|
||||
"method": "GET",
|
||||
"params": [],
|
||||
"headers": [
|
||||
{
|
||||
"key": "Custom-Header-Request-Level",
|
||||
"value": "New custom header added at the folder-33-request level",
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"key": "key",
|
||||
"value": "Overriden at folder-33-request",
|
||||
"active": true
|
||||
}
|
||||
],
|
||||
"endpoint": "https://httpbin.org/get",
|
||||
"testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully inherits/overrides authorization/header set at the parent collection level with new header addition\", () => {\n pw.expect(pw.response.body.headers[\"Authorization\"]).toBe(\"Basic dGVzdHVzZXI6dGVzdHBhc3M=\")\n \n pw.expect(pw.response.body.headers[\"Custom-Header\"]).toBe(\"Custom header value overriden at folder-33\")\n pw.expect(pw.response.body.headers[\"Inherited-Header\"]).toBe(\"Inherited header at all levels\")\n pw.expect(pw.response.body.headers[\"Key\"]).toBe(\"Overriden at folder-33-request\")\n pw.expect(pw.response.body.headers[\"Custom-Header-Request-Level\"]).toBe(\"New custom header added at the folder-33-request level\")\n})",
|
||||
"preRequestScript": "",
|
||||
"requestVariables": []
|
||||
}
|
||||
],
|
||||
"auth": {
|
||||
"token": "test-token",
|
||||
"authType": "bearer",
|
||||
"password": "testpass",
|
||||
"username": "testuser",
|
||||
"authActive": true
|
||||
},
|
||||
"headers": [
|
||||
{
|
||||
"key": "Custom-Header",
|
||||
"value": "Custom header value overriden at folder-33",
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"key": "key",
|
||||
"value": "Set at folder-33",
|
||||
"active": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"requests": [
|
||||
{
|
||||
"v": "4",
|
||||
"auth": {
|
||||
"authType": "basic",
|
||||
"password": "testpass",
|
||||
"username": "testuser",
|
||||
"authActive": true
|
||||
},
|
||||
"body": {
|
||||
"body": null,
|
||||
"contentType": null
|
||||
},
|
||||
"name": "folder-3-request",
|
||||
"method": "GET",
|
||||
"params": [],
|
||||
"headers": [
|
||||
{
|
||||
"key": "Custom-Header-Request-Level",
|
||||
"value": "New custom header added at the folder-3-request level",
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"key": "key",
|
||||
"value": "Set at folder-3-request",
|
||||
"active": true
|
||||
}
|
||||
],
|
||||
"endpoint": "https://httpbin.org/get",
|
||||
"testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully inherits/overrides authorization/header set at the parent collection level with new header addition\", () => {\n pw.expect(pw.response.body.headers[\"Authorization\"]).toBe(\"Basic dGVzdHVzZXI6dGVzdHBhc3M=\")\n \n pw.expect(pw.response.body.headers[\"Custom-Header\"]).toBe(\"Custom header value overriden at folder-3\")\n pw.expect(pw.response.body.headers[\"Inherited-Header\"]).toBe(\"Inherited header at all levels\")\n pw.expect(pw.response.body.headers[\"Key\"]).toBe(\"Set at folder-3-request\")\n pw.expect(pw.response.body.headers[\"Custom-Header-Request-Level\"]).toBe(\"New custom header added at the folder-3-request level\")\n})",
|
||||
"preRequestScript": "",
|
||||
"requestVariables": []
|
||||
}
|
||||
],
|
||||
"auth": {
|
||||
"key": "testuser",
|
||||
"addTo": "HEADERS",
|
||||
"value": "testpass",
|
||||
"authType": "basic",
|
||||
"password": "testpass",
|
||||
"username": "testuser",
|
||||
"authActive": true
|
||||
},
|
||||
"headers": [
|
||||
{
|
||||
"key": "Custom-Header",
|
||||
"value": "Custom header value overriden at folder-3",
|
||||
"active": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"requests": [
|
||||
{
|
||||
"v": "4",
|
||||
"auth": {
|
||||
"authType": "inherit",
|
||||
"authActive": true
|
||||
},
|
||||
"body": {
|
||||
"body": null,
|
||||
"contentType": null
|
||||
},
|
||||
"name": "root-collection-request",
|
||||
"method": "GET",
|
||||
"params": [],
|
||||
"headers": [],
|
||||
"endpoint": "https://httpbin.org/get",
|
||||
"testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully inherits authorization/header set at the parent collection level\", () => {\n pw.expect(pw.response.body.headers[\"Authorization\"]).toBe(\"Basic dGVzdHVzZXI6dGVzdHBhc3M=\")\n \n pw.expect(pw.response.body.headers[\"Custom-Header\"]).toBe(\"Custom header value set at the root collection\")\n pw.expect(pw.response.body.headers[\"Inherited-Header\"]).toBe(\"Inherited header at all levels\")\n})",
|
||||
"preRequestScript": "",
|
||||
"requestVariables": []
|
||||
}
|
||||
],
|
||||
"auth": {
|
||||
"authType": "basic",
|
||||
"password": "testpass",
|
||||
"username": "testuser",
|
||||
"authActive": true
|
||||
},
|
||||
"headers": [
|
||||
{
|
||||
"key": "Custom-Header",
|
||||
"value": "Custom header value set at the root collection",
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"key": "Inherited-Header",
|
||||
"value": "Inherited header at all levels",
|
||||
"active": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
{
|
||||
"v": 2,
|
||||
"name": "test-junit-report-export",
|
||||
"folders": [
|
||||
{
|
||||
"v": 2,
|
||||
"name": "assertions",
|
||||
"folders": [],
|
||||
"requests": [
|
||||
{
|
||||
"v": "5",
|
||||
"auth": {
|
||||
"authType": "inherit",
|
||||
"authActive": true
|
||||
},
|
||||
"body": {
|
||||
"body": null,
|
||||
"contentType": null
|
||||
},
|
||||
"name": "error",
|
||||
"method": "GET",
|
||||
"params": [],
|
||||
"headers": [],
|
||||
"endpoint": "https://echo.hoppscotch.io",
|
||||
"testScript": "pw.test(\"`toBeLevelxxx()` error scenarios\", ()=> {\n pw.expect(\"foo\").toBeLevel2xx();\n pw.expect(\"foo\").not.toBeLevel2xx();\n});\n\npw.test(\"`toBeType()` error scenarios\", () => {\n pw.expect(2).toBeType(\"foo\")\n pw.expect(\"2\").toBeType(\"bar\")\n pw.expect(true).toBeType(\"baz\")\n pw.expect({}).toBeType(\"qux\")\n pw.expect(undefined).toBeType(\"quux\")\n \n pw.expect(2).not.toBeType(\"foo\")\n pw.expect(\"2\").not.toBeType(\"bar\")\n pw.expect(true).not.toBeType(\"baz\")\n pw.expect({}).not.toBeType(\"qux\")\n pw.expect(undefined).not.toBeType(\"quux\")\n})\n\npw.test(\"`toHaveLength()` error scenarios\", () => {\n pw.expect(5).toHaveLength(0)\n pw.expect(true).toHaveLength(0)\n\n pw.expect(5).not.toHaveLength(0)\n pw.expect(true).not.toHaveLength(0)\n\n pw.expect([1, 2, 3, 4]).toHaveLength(\"a\")\n\n pw.expect([1, 2, 3, 4]).not.toHaveLength(\"a\")\n})\n\npw.test(\"`toInclude() error scenarios`\", () => {\n pw.expect(5).not.toInclude(0)\n pw.expect(true).not.toInclude(0)\n\n pw.expect([1, 2, 3, 4]).not.toInclude(null)\n\n pw.expect([1, 2, 3, 4]).not.toInclude(undefined)\n})",
|
||||
"preRequestScript": "",
|
||||
"requestVariables": []
|
||||
},
|
||||
{
|
||||
"v": "5",
|
||||
"auth": {
|
||||
"authType": "inherit",
|
||||
"authActive": true
|
||||
},
|
||||
"body": {
|
||||
"body": null,
|
||||
"contentType": null
|
||||
},
|
||||
"name": "success",
|
||||
"method": "GET",
|
||||
"params": [],
|
||||
"headers": [],
|
||||
"endpoint": "https://echo.hoppscotch.io",
|
||||
"testScript": "\n\n// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check headers\npw.test(\"Check headers\", ()=> {\n pw.expect(pw.response.body.headers[\"accept\"]).toBe(\"application/json, text/plain, */*,image/webp\");\n pw.expect(pw.response.body.headers[\"host\"]).toBe(\"echo.hoppscotch.io\")\n pw.expect(pw.response.body.headers[\"custom-header\"]).toBe(undefined)\n});\n\n// Check status code is 2xx\npw.test(\"Status code is 2xx\", ()=> {\n pw.expect(pw.response.status).toBeLevel2xx();\n});",
|
||||
"preRequestScript": "",
|
||||
"requestVariables": []
|
||||
},
|
||||
{
|
||||
"v": "5",
|
||||
"auth": {
|
||||
"authType": "inherit",
|
||||
"authActive": true
|
||||
},
|
||||
"body": {
|
||||
"body": null,
|
||||
"contentType": null
|
||||
},
|
||||
"name": "failure",
|
||||
"method": "GET",
|
||||
"params": [],
|
||||
"headers": [],
|
||||
"endpoint": "https://echo.hoppscotch.io",
|
||||
"testScript": "\n\n// Check status code is 200\npw.test(\"Simulating failure - Status code is 200\", ()=> {\n pw.expect(pw.response.status).not.toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Simulating failure - Check headers\", ()=> {\n pw.expect(pw.response.body.headers[\"accept\"]).not.toBe(\"application/json, text/plain, */*\");\n pw.expect(pw.response.body.headers[\"host\"]).not.toBe(\"httpbin.org\")\n pw.expect(pw.response.body.headers[\"custom-header\"]).not.toBe(\"value\")\n});\n\n// Check status code is 2xx\npw.test(\"Simulating failure - Status code is 2xx\", ()=> {\n pw.expect(pw.response.status).not.toBeLevel2xx();\n});",
|
||||
"preRequestScript": "",
|
||||
"requestVariables": []
|
||||
}
|
||||
],
|
||||
"auth": {
|
||||
"authType": "inherit",
|
||||
"authActive": true
|
||||
},
|
||||
"headers": []
|
||||
},
|
||||
{
|
||||
"v": 2,
|
||||
"name": "request-level-errors",
|
||||
"folders": [],
|
||||
"requests": [
|
||||
{
|
||||
"v": "5",
|
||||
"auth": {
|
||||
"authType": "inherit",
|
||||
"authActive": true
|
||||
},
|
||||
"body": {
|
||||
"body": null,
|
||||
"contentType": null
|
||||
},
|
||||
"name": "invalid-url",
|
||||
"method": "GET",
|
||||
"params": [],
|
||||
"headers": [],
|
||||
"endpoint": "invalid-url",
|
||||
"testScript": "pw.test(\"`toBeLevelxxx()` error scenarios\", ()=> {\n pw.expect(\"foo\").toBeLevel2xx();\n pw.expect(\"foo\").not.toBeLevel2xx();\n});\n\npw.test(\"`toBeType()` error scenarios\", () => {\n pw.expect(2).toBeType(\"foo\")\n pw.expect(\"2\").toBeType(\"bar\")\n pw.expect(true).toBeType(\"baz\")\n pw.expect({}).toBeType(\"qux\")\n pw.expect(undefined).toBeType(\"quux\")\n \n pw.expect(2).not.toBeType(\"foo\")\n pw.expect(\"2\").not.toBeType(\"bar\")\n pw.expect(true).not.toBeType(\"baz\")\n pw.expect({}).not.toBeType(\"qux\")\n pw.expect(undefined).not.toBeType(\"quux\")\n})\n\npw.test(\"`toHaveLength()` error scenarios\", () => {\n pw.expect(5).toHaveLength(0)\n pw.expect(true).toHaveLength(0)\n\n pw.expect(5).not.toHaveLength(0)\n pw.expect(true).not.toHaveLength(0)\n\n pw.expect([1, 2, 3, 4]).toHaveLength(\"a\")\n\n pw.expect([1, 2, 3, 4]).not.toHaveLength(\"a\")\n})\n\npw.test(\"`toInclude() error scenarios`\", () => {\n pw.expect(5).not.toInclude(0)\n pw.expect(true).not.toInclude(0)\n\n pw.expect([1, 2, 3, 4]).not.toInclude(null)\n\n pw.expect([1, 2, 3, 4]).not.toInclude(undefined)\n})",
|
||||
"preRequestScript": "",
|
||||
"requestVariables": []
|
||||
},
|
||||
{
|
||||
"v": "5",
|
||||
"auth": {
|
||||
"authType": "inherit",
|
||||
"authActive": true
|
||||
},
|
||||
"body": {
|
||||
"body": null,
|
||||
"contentType": null
|
||||
},
|
||||
"name": "test-script-reference-error",
|
||||
"method": "GET",
|
||||
"params": [],
|
||||
"headers": [],
|
||||
"endpoint": "invalid-url",
|
||||
"testScript": "pw.test(\"Reference error\", () => {\n pw.expect(status).toBe(200);\n})",
|
||||
"preRequestScript": "",
|
||||
"requestVariables": []
|
||||
},
|
||||
{
|
||||
"v": "5",
|
||||
"auth": {
|
||||
"authType": "inherit",
|
||||
"authActive": true
|
||||
},
|
||||
"body": {
|
||||
"body": "{\n \"key\": \"<<key>>\"\n}",
|
||||
"contentType": "application/json"
|
||||
},
|
||||
"name": "non-existent-env-var",
|
||||
"method": "POST",
|
||||
"params": [],
|
||||
"headers": [],
|
||||
"endpoint": "https://echo.hoppscotch.io",
|
||||
"testScript": "pw.test(\"`toBeLevelxxx()` error scenarios\", ()=> {\n pw.expect(\"foo\").toBeLevel2xx();\n pw.expect(\"foo\").not.toBeLevel2xx();\n});\n\npw.test(\"`toBeType()` error scenarios\", () => {\n pw.expect(2).toBeType(\"foo\")\n pw.expect(\"2\").toBeType(\"bar\")\n pw.expect(true).toBeType(\"baz\")\n pw.expect({}).toBeType(\"qux\")\n pw.expect(undefined).toBeType(\"quux\")\n \n pw.expect(2).not.toBeType(\"foo\")\n pw.expect(\"2\").not.toBeType(\"bar\")\n pw.expect(true).not.toBeType(\"baz\")\n pw.expect({}).not.toBeType(\"qux\")\n pw.expect(undefined).not.toBeType(\"quux\")\n})\n\npw.test(\"`toHaveLength()` error scenarios\", () => {\n pw.expect(5).toHaveLength(0)\n pw.expect(true).toHaveLength(0)\n\n pw.expect(5).not.toHaveLength(0)\n pw.expect(true).not.toHaveLength(0)\n\n pw.expect([1, 2, 3, 4]).toHaveLength(\"a\")\n\n pw.expect([1, 2, 3, 4]).not.toHaveLength(\"a\")\n})\n\npw.test(\"`toInclude() error scenarios`\", () => {\n pw.expect(5).not.toInclude(0)\n pw.expect(true).not.toInclude(0)\n\n pw.expect([1, 2, 3, 4]).not.toInclude(null)\n\n pw.expect([1, 2, 3, 4]).not.toInclude(undefined)\n})",
|
||||
"preRequestScript": "",
|
||||
"requestVariables": []
|
||||
}
|
||||
],
|
||||
"auth": {
|
||||
"authType": "inherit",
|
||||
"authActive": true
|
||||
},
|
||||
"headers": []
|
||||
}
|
||||
],
|
||||
"requests": [],
|
||||
"auth": {
|
||||
"authType": "inherit",
|
||||
"authActive": true
|
||||
},
|
||||
"headers": []
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user