Compare commits
86 Commits
feat/node-
...
2024.7.0-l
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7efffcd79 | ||
|
|
4fd6d6ddb4 | ||
|
|
40f93fc7f4 | ||
|
|
adb7782b58 | ||
|
|
a9fc7e6a62 | ||
|
|
d68cfb313e | ||
|
|
2a37235229 | ||
|
|
e26528cedf | ||
|
|
c9f92282bf | ||
|
|
c24d5c5302 | ||
|
|
783d911f8d | ||
|
|
c88ea5c8b2 | ||
|
|
5f96cda5e2 | ||
|
|
ecf0901491 | ||
|
|
9e445cda84 | ||
|
|
9cde6c597b | ||
|
|
df730e4d21 | ||
|
|
4457eeb74c | ||
|
|
bf5d62364d | ||
|
|
f913899ad5 | ||
|
|
7652dab9c4 | ||
|
|
801df8ea97 | ||
|
|
25a519a2e9 | ||
|
|
8c6b80dc42 | ||
|
|
7ebc9a6fd6 | ||
|
|
7ad3f660ae | ||
|
|
199a7234fa | ||
|
|
90bb470b3a | ||
|
|
1dee1e1b1a | ||
|
|
71ac013a38 | ||
|
|
fe79c47d1f | ||
|
|
2917d50c6a | ||
|
|
0c06f26893 | ||
|
|
fba22ea687 | ||
|
|
fd60c630fd | ||
|
|
7deb49b244 | ||
|
|
ac84166b8b | ||
|
|
07e7180dc9 | ||
|
|
1d1462df69 | ||
|
|
b851d3003c | ||
|
|
3b70668162 | ||
|
|
a9afb17dc0 | ||
|
|
fa2f73ee40 | ||
|
|
b3e42bf7c3 | ||
|
|
25fd35770a | ||
|
|
762cd2207b | ||
|
|
1da5dde0ba | ||
|
|
190a3b8eaf | ||
|
|
aead9e6c98 | ||
|
|
5e3bc01922 | ||
|
|
9479258acb | ||
|
|
7baabebcee | ||
|
|
6e63c723b7 | ||
|
|
fac90ac342 | ||
|
|
9d02c8e6c9 | ||
|
|
d45f15e9cb | ||
|
|
021ecf17ce | ||
|
|
257974325e | ||
|
|
c2085b8b6f | ||
|
|
cfb77f2bfe | ||
|
|
292d752f32 | ||
|
|
73090c7a2b | ||
|
|
e6cd03c283 | ||
|
|
cc1be91446 | ||
|
|
c2cb295bcb | ||
|
|
31f1e1b21a | ||
|
|
93807bfe8f | ||
|
|
e3ad0c9e2e | ||
|
|
465ea2b4e0 | ||
|
|
f13478da86 | ||
|
|
5805826994 | ||
|
|
5fd7c28894 | ||
|
|
b601a2f55f | ||
|
|
bece13e6b0 | ||
|
|
d0350ec789 | ||
|
|
5c214a8da0 | ||
|
|
2c0805fafe | ||
|
|
26b4f64824 | ||
|
|
4156551b24 | ||
|
|
4bd23a8f4c | ||
|
|
f4f3fdf2d5 | ||
|
|
b7a3ae231b | ||
|
|
f8ac6dfeb1 | ||
|
|
7d2d335b37 | ||
|
|
76875db865 | ||
|
|
96e2d87b57 |
17
.env.example
17
.env.example
@@ -9,6 +9,9 @@ MAGIC_LINK_TOKEN_VALIDITY= 3
|
||||
REFRESH_TOKEN_VALIDITY="604800000" # Default validity is 7 days (604800000 ms) in ms
|
||||
ACCESS_TOKEN_VALIDITY="86400000" # Default validity is 1 day (86400000 ms) in ms
|
||||
SESSION_SECRET='add some secret here'
|
||||
# Reccomended to be true, set to false if you are using http
|
||||
# Note: Some auth providers may not support http requests
|
||||
ALLOW_SECURE_COOKIES=true
|
||||
|
||||
# Hoppscotch App Domain Config
|
||||
REDIRECT_URL="http://localhost:3000"
|
||||
@@ -35,9 +38,20 @@ MICROSOFT_SCOPE="user.read"
|
||||
MICROSOFT_TENANT="common"
|
||||
|
||||
# Mailer config
|
||||
MAILER_SMTP_URL="smtps://user@domain.com:pass@smtp.domain.com"
|
||||
MAILER_SMTP_ENABLE="true"
|
||||
MAILER_USE_CUSTOM_CONFIGS="false"
|
||||
MAILER_ADDRESS_FROM='"From Name Here" <from@example.com>'
|
||||
|
||||
MAILER_SMTP_URL="smtps://user@domain.com:pass@smtp.domain.com" # used if custom mailer configs is false
|
||||
|
||||
# The following are used if custom mailer configs is true
|
||||
MAILER_SMTP_HOST="smtp.domain.com"
|
||||
MAILER_SMTP_PORT="587"
|
||||
MAILER_SMTP_SECURE="true"
|
||||
MAILER_SMTP_USER="user@domain.com"
|
||||
MAILER_SMTP_PASSWORD="pass"
|
||||
MAILER_TLS_REJECT_UNAUTHORIZED="true"
|
||||
|
||||
# Rate Limit Config
|
||||
RATE_LIMIT_TTL=60 # In seconds
|
||||
RATE_LIMIT_MAX=100 # Max requests per IP
|
||||
@@ -47,6 +61,7 @@ RATE_LIMIT_MAX=100 # Max requests per IP
|
||||
|
||||
|
||||
# Base URLs
|
||||
VITE_BACKEND_LOGIN_API_URL=http://localhost:5444
|
||||
VITE_BASE_URL=http://localhost:3000
|
||||
VITE_SHORTCODE_BASE_URL=http://localhost:3000
|
||||
VITE_ADMIN_URL=http://localhost:3100
|
||||
|
||||
17
.github/pull_request_template.md
vendored
17
.github/pull_request_template.md
vendored
@@ -7,20 +7,15 @@ Please make sure that the pull request is limited to one type (docs, feature, et
|
||||
<!-- If this pull request closes an issue, please mention the issue number below -->
|
||||
Closes # <!-- Issue # here -->
|
||||
|
||||
### Description
|
||||
<!-- Add a brief description of the pull request -->
|
||||
<!-- Add an introduction into what this PR tries to solve in a couple of sentences -->
|
||||
|
||||
### What's changed
|
||||
<!-- Describe point by point the different things you have changed in this PR -->
|
||||
|
||||
<!-- You can also choose to add a list of changes and if they have been completed or not by using the markdown to-do list syntax
|
||||
- [ ] Not Completed
|
||||
- [x] Completed
|
||||
-->
|
||||
|
||||
### Checks
|
||||
<!-- Make sure your pull request passes the CI checks and do check the following fields as needed - -->
|
||||
- [ ] My pull request adheres to the code style of this project
|
||||
- [ ] My code requires changes to the documentation
|
||||
- [ ] I have updated the documentation as required
|
||||
- [ ] All the tests have passed
|
||||
|
||||
### Additional Information
|
||||
<!-- Any additional information like breaking changes, dependencies added, screenshots, comparisons between new and old behaviour, etc. -->
|
||||
### Notes to reviewers
|
||||
<!-- Any information you feel the reviewer should know about when reviewing your PR -->
|
||||
|
||||
35
CODEOWNERS
35
CODEOWNERS
@@ -1,30 +1,21 @@
|
||||
# CODEOWNERS is prioritized from bottom to top
|
||||
|
||||
# If none of the below matched
|
||||
* @AndrewBastin @liyasthomas
|
||||
|
||||
# Packages
|
||||
/packages/codemirror-lang-graphql/ @AndrewBastin
|
||||
/packages/hoppscotch-cli/ @AndrewBastin
|
||||
/packages/hoppscotch-common/ @amk-dev @AndrewBastin
|
||||
/packages/hoppscotch-cli/ @jamesgeorge007
|
||||
/packages/hoppscotch-data/ @AndrewBastin
|
||||
/packages/hoppscotch-js-sandbox/ @AndrewBastin
|
||||
/packages/hoppscotch-ui/ @anwarulislam
|
||||
/packages/hoppscotch-web/ @amk-dev
|
||||
/packages/hoppscotch-selfhost-web/ @amk-dev
|
||||
/packages/hoppscotch-js-sandbox/ @jamesgeorge007
|
||||
/packages/hoppscotch-selfhost-web/ @jamesgeorge007
|
||||
/packages/hoppscotch-selfhost-desktop/ @AndrewBastin
|
||||
/packages/hoppscotch-sh-admin/ @JoelJacobStephen
|
||||
/packages/hoppscotch-backend/ @ankitsridhar16 @balub
|
||||
/packages/hoppscotch-backend/ @balub
|
||||
|
||||
# Sections within Hoppscotch Common
|
||||
/packages/hoppscotch-common/src/components @anwarulislam
|
||||
/packages/hoppscotch-common/src/components/collections @nivedin @amk-dev
|
||||
/packages/hoppscotch-common/src/components/environments @nivedin @amk-dev
|
||||
/packages/hoppscotch-common/src/composables @amk-dev
|
||||
/packages/hoppscotch-common/src/modules @AndrewBastin @amk-dev
|
||||
/packages/hoppscotch-common/src/pages @AndrewBastin @amk-dev
|
||||
/packages/hoppscotch-common/src/newstore @AndrewBastin @amk-dev
|
||||
# READMEs and other documentation files
|
||||
*.md @liyasthomas
|
||||
|
||||
README.md @liyasthomas
|
||||
|
||||
# The lockfile has no owner
|
||||
pnpm-lock.yaml
|
||||
# Self Host deployment related files
|
||||
*.Dockerfile @balub
|
||||
docker-compose.yml @balub
|
||||
docker-compose.deploy.yml @balub
|
||||
*.Caddyfile @balub
|
||||
.dockerignore @balub
|
||||
|
||||
@@ -11,7 +11,4 @@ Please note we have a code of conduct, please follow it in all your interactions
|
||||
build.
|
||||
2. Update the README.md with details of changes to the interface, this includes new environment
|
||||
variables, exposed ports, useful file locations and container parameters.
|
||||
3. Increase the version numbers in any examples files and the README.md to the new version that this
|
||||
Pull Request would represent. The versioning scheme we use is [SemVer](https://semver.org).
|
||||
4. You may merge the Pull Request once you have the sign-off of two other developers, or if you
|
||||
do not have permission to do that, you may request the second reviewer merge it for you.
|
||||
3. Make sure you do not expose environment variables or other sensitive information in your PR.
|
||||
|
||||
27
SECURITY.md
27
SECURITY.md
@@ -4,19 +4,36 @@ This document outlines security procedures and general policies for the Hoppscot
|
||||
|
||||
- [Security Policy](#security-policy)
|
||||
- [Reporting a security vulnerability](#reporting-a-security-vulnerability)
|
||||
- [What is not a valid vulnerability](#what-is-not-a-valid-vulnerability)
|
||||
- [Incident response process](#incident-response-process)
|
||||
|
||||
## Reporting a security vulnerability
|
||||
|
||||
Report security vulnerabilities by emailing the Hoppscotch Support team at support@hoppscotch.io.
|
||||
We use [Github Security Advisories](https://github.com/hoppscotch/hoppscotch/security/advisories) to manage vulnerability reports and collaboration.
|
||||
Someone from the Hoppscotch team shall report to you within 48 hours of the disclosure of the vulnerability in GHSA. If no response was received, please reach out to
|
||||
Hoppscotch Support at support@hoppscotch.io along with the GHSA advisory link.
|
||||
|
||||
The primary security point of contact from Hoppscotch Support team will acknowledge your email within 48 hours, and will send a more detailed response within 48 hours indicating the next steps in handling your report. After the initial reply to your report, the security team will endeavor to keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance.
|
||||
> NOTE: Since we have multiple open source components, Advisories may move into the relevant repo (for example, an XSS in a UI component might be part of [`@hoppscotch/ui`](https://github.com/hoppscotch/ui)).
|
||||
> If in doubt, open your report in `hoppscotch/hoppscotch` GHSA.
|
||||
|
||||
**Do not create a GitHub issue ticket to report a security vulnerability.**
|
||||
**Do not create a GitHub issue ticket to report a security vulnerability!**
|
||||
|
||||
The Hoppscotch team and community take all security vulnerability reports in Hoppscotch seriously. Thank you for improving the security of Hoppscotch. We appreciate your efforts and responsible disclosure and will make every effort to acknowledge your contributions.
|
||||
The Hoppscotch team takes all security vulnerability reports in Hoppscotch seriously. We appreciate your efforts and responsible disclosure and will make every effort to acknowledge your contributions.
|
||||
|
||||
Report security bugs in third-party modules to the person or team maintaining the module.
|
||||
## What is not a valid vulnerability
|
||||
We receive many reports about different sections of the Hoppscotch platform. Hence, we have a fine line we have drawn defining what is considered valid vulnerability.
|
||||
Please refrain from opening an advisory if it describes the following:
|
||||
|
||||
- A vulnerability in a dependency of Hoppscotch (unless you have practical attack with it on the Hoppscotch codebase)
|
||||
- Reports of vulnerabilities related to old runtimes (like NodeJS) or container images used by the codebase
|
||||
- Vulnerabilities present when using Hoppscotch in anything other than the defined minimum requirements that Hoppscotch supports.
|
||||
|
||||
Hoppscotch Team ensures security support for:
|
||||
- Modern Browsers (Chrome/Firefox/Safari/Edge) with versions up to 1 year old.
|
||||
- Windows versions on or above Windows 10 on Intel and ARM.
|
||||
- macOS versions dating back up to 2 years on Intel and Apple Silicon.
|
||||
- Popular Linux distributions with up-to-date packages with preference to x86/64 CPUs.
|
||||
- Docker/OCI Runtimes (preference to Docker and Podman) dating back up to 1 year.
|
||||
|
||||
## Incident response process
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ services:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"sh -c 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}'"
|
||||
"sh -c 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}'",
|
||||
]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "16.3.0",
|
||||
"@commitlint/config-conventional": "16.2.4",
|
||||
"@hoppscotch/ui": "0.1.0",
|
||||
"@hoppscotch/ui": "0.2.0",
|
||||
"@types/node": "17.0.27",
|
||||
"cross-env": "7.0.3",
|
||||
"http-server": "14.1.1",
|
||||
@@ -37,7 +37,7 @@
|
||||
"vue": "3.3.9"
|
||||
},
|
||||
"packageExtensions": {
|
||||
"httpsnippet@3.0.1": {
|
||||
"@hoppscotch/httpsnippet": {
|
||||
"dependencies": {
|
||||
"ajv": "6.12.3"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:18.8.0 AS builder
|
||||
FROM node:20.12.2 AS builder
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
|
||||
@@ -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