Compare commits

...

86 Commits

Author SHA1 Message Date
Gusram
f7efffcd79 Merge branch 'main' of github.com:hoppscotch/hoppscotch 2024-08-01 12:25:21 +08:00
James George
4fd6d6ddb4 fix(cli): remove postinstall script (#4229) 2024-07-31 19:10:11 +05:30
Akash K
40f93fc7f4 chore: add analytics event for ai experiments (#4224) 2024-07-30 18:55:09 +05:30
Akash K
adb7782b58 chore: enable ai experiments by default in settings (#4223) 2024-07-30 18:32:32 +05:30
Andrew Bastin
a9fc7e6a62 chore: bump version to 2024.7.0 2024-07-30 14:41:12 +05:30
Akash K
d68cfb313e feat: hoppscotch-common & platform additions for ai experiments (#4222)
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
2024-07-30 12:16:44 +05:30
Joel Jacob Stephen
2a37235229 fix(sh-admin): resolved issue while rendering images (#4221)
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
2024-07-29 22:59:02 +05:30
Mir Arif Hasan
e26528cedf fix: infra token expriry check on guard (#4219)
fix: null check added in infraToken expiry check on guard
2024-07-29 19:38:17 +05:30
James George
c9f92282bf feat: duplicate REST/GraphQL collections (#4211)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
Co-authored-by: nivedin <nivedinp@gmail.com>
2024-07-29 18:37:34 +05:30
Joel Jacob Stephen
c24d5c5302 feat(sh-admin): introducing infra-tokens to admin dashboard (#4202)
Co-authored-by: nivedin <nivedinp@gmail.com>
2024-07-29 17:50:06 +05:30
Mir Arif Hasan
783d911f8d HSB-462 feat: infra token module and sh apis (#4191)
* feat: infra token module added

* feat: infra token guard added

* feat: token prefix removed

* feat: get pending invites api added

* docs: swagger doc added for get user invites api

* feat: delete user invitation api added

* feat: get users api added

* feat: update user api added

* feat: update admin status api added

* feat: create invitation api added

* chore: swagger doc update for create user invite

* feat: interceptor added to track last used on

* feat: change db schema

* chore: readonly tag added

* feat: get user by id api added

* fix: return type of a function

* feat: controller name change

* chore: improve token extractino

* chore: added email validation logic

---------

Co-authored-by: Balu Babu <balub997@gmail.com>
2024-07-29 12:36:18 +05:30
Nivedin
c88ea5c8b2 fix: auth bug when value is a secret environment (#4210)
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
2024-07-27 20:54:16 +05:30
James George
5f96cda5e2 feat(cli): add support for JUnit reporter (#4189) 2024-07-26 22:56:58 +05:30
jamesgeorge007
ecf0901491 chore: merge hoppscotch/main into hoppscotch/next 2024-07-26 22:39:48 +05:30
Joel Jacob Stephen
9e445cda84 refactor(sh-admin): updated invite flow for the admin dashboard (#4176)
* feat: new success invite link modal introduced

* refactor: replaced button with lucide icon

* style: vertical padding to lucide icon

* feat: new success invite modal component

* refactor: hide copy link button when there are no pending invites

* refactor: changed copy link button to copy invite link

* chore: minor UI update

---------

Co-authored-by: nivedin <nivedinp@gmail.com>
2024-07-26 20:14:41 +05:30
Balu Babu
9cde6c597b feat: duplicate team and user collections (#4207)
* chore: created new mutation for team-collection duplication

* feat: completed duplication function in TeamCollection module

* feat: user-collection duplication complete

* chore: changed duplicated title suffix for collections

* chore: added return type to argument for duplication mutation in user-collections

* chore: capitalized duplicate in duplicate collection service4 methods

* chore: changed target of hopp-old-backend service to prod
2024-07-24 21:55:08 +05:30
Balu Babu
df730e4d21 feat: Ability to toggle cookies to work in HTTP (#4194)
* feat: added new env variable

* feat: made http secure cookie conditional

* chore: added comments to env files

* chore: changed target of hopp-old-backend service to prod
2024-07-24 21:44:39 +05:30
Andrew Bastin
4457eeb74c chore: bump versions 2024-07-22 22:12:32 +05:30
jamesgeorge007
bf5d62364d chore: bump CLI tests timeout 2024-07-22 19:21:16 +05:30
Dmitry
f913899ad5 fix: fix issue for multipart/form-data and cookie header (#4172)
* fix(multipart-formdata): fix type for data variable

* fix(multipart-formdata): fix parameter filter for unsetted file

* fix(multipart-formdata): add mime type for sending file

* fix(multipart-formdata): add application/octet-stream by default mimetype

* fix(multipart-formdata): remove content-type: multipart/form-data header

* fix(multipart-formdata): remove cookie cookie header if not present

* fix(multipart-formdata): fix content-type filter

---------

Co-authored-by: Dmitry Mukovkin <d.mukovkin@cft.ru>
2024-07-22 18:33:38 +05:30
Nivedin
7652dab9c4 fix: codemirror search bar misaligned (#4203)
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
2024-07-22 17:04:53 +05:30
Anwarul Islam
801df8ea97 feat: persist HTML response preview preference (#4177)
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
Co-authored-by: nivedin <nivedinp@gmail.com>
2024-07-19 16:18:55 +05:30
Nivedin
25a519a2e9 fix: wrap line button showing inconsistency bug (#4188) 2024-07-18 15:20:57 +05:30
Nivedin
8c6b80dc42 fix: embeds response and request option section getting hidden bug (#4181)
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
2024-07-18 15:13:43 +05:30
Nivedin
7ebc9a6fd6 fix: secret variables being populated as undefined on code snippets (#4180) 2024-07-18 14:44:37 +05:30
Paul Vogel
7ad3f660ae i18n: add german translations (#4196)
Add german translations
2024-07-18 14:26:09 +05:30
Paul Vogel
199a7234fa i18n: fix existing german translations (#4195)
Fix existing german translations
2024-07-18 14:19:46 +05:30
Anwarul Islam
90bb470b3a fix: <img> is flex's immediate child therefore it stretch to its parent width (#4173)
* fix: img is flex immediate child and renders in full width

* chore: remove unnecesary use of flex
2024-07-17 21:34:08 +05:30
Nivedin
1dee1e1b1a chore: cleanup i18n translations (#4166) 2024-07-05 17:52:50 +03:00
Muhammed Ajmal M
71ac013a38 fix: ctrl+enter inserting newline in code (#4108)
* fix: ctrl+enter inserting newline in code

* chore: add 'cmd+enter' to the key list

---------

Co-authored-by: nivedin <nivedinp@gmail.com>
2024-07-02 15:07:27 +05:30
Nivedin
fe79c47d1f fix: codemirror crashing due to cursor not updating (#4151)
fix: cursor not updating while filtering
2024-07-02 13:41:20 +05:30
Akash K
2917d50c6a feat: copyable invite links (#4153)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
2024-06-28 18:48:10 +03:00
Joel Jacob Stephen
0c06f26893 refactor(sh-admin): improvements to invite links in dashboard (#4152) 2024-06-28 16:15:40 +03:00
Andrew Bastin
fba22ea687 refactor: move pwa update toast to the bottom center 2024-06-28 15:10:04 +03:00
Andrew Bastin
fd60c630fd refactor: remove auto dismissal of whats new toast and move it to the bottom left 2024-06-28 15:05:16 +03:00
Mir Arif Hasan
7deb49b244 HSB-453 hotfix: remove !! operator (#4150)
fix: remove !! operator
2024-06-28 14:32:24 +05:30
jamesgeorge007
ac84166b8b chore: update documentation link references 2024-06-28 11:49:09 +05:30
jamesgeorge007
07e7180dc9 chore: update CLI documentation link references
Bump CLI version.
2024-06-28 11:16:47 +05:30
Joel Jacob Stephen
1d1462df69 feat(sh-admin): introducing advanced SMTP configurations and invite links to dashboard (#4087)
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
2024-06-27 22:15:50 +03:00
Anwarul Islam
b851d3003c feat: added change log prompt for PWA updates (#4098)
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
Co-authored-by: nivedin <nivedinp@gmail.com>
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2024-06-27 19:03:33 +03:00
James George
3b70668162 feat: CLI collection runner command generation UI flow (#4141)
Co-authored-by: nivedin <nivedinp@gmail.com>
2024-06-27 19:17:56 +05:30
James George
a9afb17dc0 feat(cli): access team workspace collections and environments (#4095)
Co-authored-by: nivedin <nivedinp@gmail.com>
2024-06-27 18:11:29 +05:30
Mir Arif Hasan
fa2f73ee40 HSB-439 feature: invite link with SMTP optional (#4078)
* feat: env variable added in infra-config for smtp enable status

* feat: event emitter added

* feat: added advance mailer configurations from infra config

* test: fix test cases

* feat: added query to see is smtp enabled or not

* feat: email auth provider disabled on smtp disable

* chore: restrict on update directly instead of dedicated mutation

* fix: feedback resolved

* chore: modify mailer module

* chore: error handle in mailer functions

* chore: removed unused imports

* chore: remove event-emit

* chore: update env example

* test: fix broken test cases

* chore: feedback resolved

* chore: isSMTPEnabled moved to infra config resolver

* fix: email can not reenable if smtp not enabled
2024-06-27 17:53:40 +05:30
Joel Jacob Stephen
b3e42bf7c3 feat(sh-admin): introducing last active status to users table and individual user's page in dashboard (#4077)
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
2024-06-26 12:30:40 +05:30
jamesgeorge007
25fd35770a fix: regression with missing lucide icon pack
Add `@iconify-json/lucide` as a dev dependency to all packages that consume `unplugin-icons`.
2024-06-26 11:59:21 +05:30
Andrew Bastin
762cd2207b chore: bump version to 2024.6.0 2024-06-25 21:29:30 +05:30
Andrew Bastin
1da5dde0ba refactor: bump dioc to 3.0.2 2024-06-25 20:54:25 +05:30
Mir Arif Hasan
190a3b8eaf fix: crashes in user last login interceptor (#4146)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2024-06-25 17:15:04 +05:30
Andrew Bastin
aead9e6c98 feat: client certificates and ability to skip ssl cert verification in desktop app (#4111)
Co-authored-by: Nivedin <53208152+nivedin@users.noreply.github.com>
2024-06-25 16:57:07 +05:30
Joel Jacob Stephen
5e3bc01922 refactor(common): improvements to banner handling logic (#4123) 2024-06-25 15:14:52 +05:30
Andrew Bastin
9479258acb chore: merge hoppscotch/patch into hoppscotch/next 2024-06-24 16:09:12 +05:30
Andrew Bastin
7baabebcee chore: merge hoppscotch/main into hoppscotch/patch
there was a hotfix to update the CLI version with a patch release
2024-06-24 16:07:43 +05:30
Andrew Bastin
6e63c723b7 chore: bump cli to 0.8.1 due to new hopp-data schema change 2024-06-24 16:04:39 +05:30
Nivedin
fac90ac342 fix: styling issue on selection in codemirror (#4122) 2024-06-24 12:15:04 +05:30
Anwarul Islam
9d02c8e6c9 feat: added codegen tab in the sidebar (#4099) 2024-06-24 12:03:19 +05:30
Anwarul Islam
d45f15e9cb fix: prevent unwanted variable wrapping during Insomnia import (#4076)
feat: conditionally check path variable
2024-06-24 11:46:50 +05:30
James George
021ecf17ce feat: introduce personal access tokens for authorization (#4094)
Co-authored-by: Nivedin <53208152+nivedin@users.noreply.github.com>
2024-06-24 11:45:31 +05:30
Nivedin
257974325e fix: large content scroll bug in codemirror editor (#4138) 2024-06-21 23:27:22 +05:30
Mir Arif Hasan
c2085b8b6f HSB-450 feature: user last active on (#4121)
* feat: userLastActiveOnInterceptor added and update func added in userService

* chore: changed user model parameter description

* chore: commented out docker volume for hopp-old-service

* chore: changed backend to work with secure cookies

---------

Co-authored-by: Balu Babu <balub997@gmail.com>
2024-06-21 12:40:21 +05:30
Andrew Bastin
cfb77f2bfe chore: merge hoppscotch/patch into hoppscotch/next 2024-06-20 22:26:42 +05:30
Andrew Bastin
292d752f32 chore: update CONTRIBUTING.md 2024-06-17 21:50:10 +05:30
Andrew Bastin
73090c7a2b chore: update PR template 2024-06-17 21:47:59 +05:30
Andrew Bastin
e6cd03c283 chore: update SECURITY.md 2024-06-17 21:41:47 +05:30
Andrew Bastin
cc1be91446 chore: update CODEOWNERS 2024-06-17 21:14:37 +05:30
Andrew Bastin
c2cb295bcb chore: bump version to 2024.3.4 2024-06-14 04:14:16 +05:30
Andrew Bastin
31f1e1b21a fix: headers having different values with the same key are not shown (#4112)
* feat: allow ability for multiple headers with the same key to be shown

* chore: remove extension inspector in selfhost-desktop

* chore: cleanup

---------

Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
2024-06-12 14:11:43 +05:30
Akash K
93807bfe8f fix: make client credential optional in authcode + pkce flow (#4114)
* fix: make client credential optional

* test: update test fixtures

---------

Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
2024-06-12 14:10:54 +05:30
Andrew Bastin
e3ad0c9e2e feat: add ability for platforms to define custom login selector ui 2024-06-11 06:20:02 +05:30
Balu Babu
465ea2b4e0 chore: changed the returned status type for errors in the access-tokens (#4107)
* chore: changed the returned status type for errors in the access-token interceptor

* chore: removed unused pagination args
2024-06-07 12:08:35 +05:30
Alexandre Rodrigues Batista
f13478da86 fix(sh-desktop): add missing @hoppscotch/data to selfhost-desktop (#4097) 2024-06-06 20:37:45 +05:30
Joel Jacob Stephen
5805826994 refactor(sh-admin): improved error handling and dynamic user actions in admin dashboard (#4044)
* feat: new helper functions for better error management

* refactor: new i18n strings

* refactor: better error handling in invite modal and members component

* refactor: better user management

* refactor: better error handling in config handler

* refactor: updated logic of dynamic action row

* refactor: better naming for computed properties

* feat: new error message when an admin tries to invite himself

* refactor: updated error message when user is already invited

* refactor: reverted i18n string for user already invited back to the old string

* refactor: removed show prop from invite modal

* refactor: improved implementation for getting the compiled error messages

* feat: new error message when email inputted is of an invalid format

* refactor: minor optimization

---------

Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-06-03 17:17:46 +05:30
islamzeki
5fd7c28894 locale: update tr i18n translations (#4057)
* Update tr.json

i18n

* Update tr.json

i18n

* Update tr.json

l10n

* Update tr.json

l10n based on en.json

* Update tr.json

* Update tr.json
2024-06-03 14:35:32 +05:30
Joel Jacob Stephen
b601a2f55f fix(sh-admin): saving changes to server configurations post a failed attempt will require a page reload in dashboard (#4081)
* fix: resolved an issue with server restart component

* refactor: early return if any mutation fails when initiating server restart

* fix: ensure further attempts go through after a failed reset configs action

---------

Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-06-03 14:31:44 +05:30
Nivedin
bece13e6b0 fix: precision lost when json is beautified (#4086) 2024-06-03 14:30:31 +05:30
Nivedin
d0350ec789 fix: add previous value as optional in test schema env diff (#4071)
* fix: add previous value as optional

* refactor: remove method chaining for consistency

---------

Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-06-03 14:29:48 +05:30
Nivedin
5c214a8da0 fix: persist interceptor state for logged out user (#4060)
* fix: persist interceptor state for loged out user

* refactor: update interceptor section in settings page

* chore: update settings page interceptor section UI

* chore: minoir ui and code refactor
2024-06-03 14:28:58 +05:30
Gusram
2c0805fafe docs(desktop): update building instruction 2024-05-31 01:41:06 +08:00
Gusram
26b4f64824 chore(desktop): update dependencies version 2024-05-31 01:39:36 +08:00
Gusram
4156551b24 feat(desktop): implement backend wrapper auth 2024-05-31 00:39:19 +08:00
Balu Babu
4bd23a8f4c feat: adding support for hopp-cli in self-host Hoppscotch (#4079)
* feat: created a new table to store user PATs

* chore: renamed UserTokens table name to PersonalAccessToken

* chore: added unique property to token column

* chore: made expiresOn field optional

* chore: created access-token module

* feat: created access-token rest routes

* chore: created a new auth guard for PATs

* chore: scaffolded routes in team collection and environments modules for CLI

* chore: created method to update lastUsedOn property for accessTokens

* chore: created interceptor to update lastUsedOn property of PAT

* feat: interceptor to update lastUpdatedOn property complete

* chore: removed unused imports in access-token interceptor

* chore: moved routes to fetch collection and environment into access-token module

* feat: added routes to fetch collections and environments for CLI

* chore: modified access-token interceptor

* chore: removed log statement from interceptor

* chore: added team member checking logic to ForCLI methods in team collection and environments module

* chore: changed return error messages to those defined in spec

* chore: added comments to all service methods

* chore: removed unused imports

* chore: updated testcases for team-environments module service file

* chore: added and updated testcases

* chore: removed unneseccary SQL from auto-generated migration sql for PAT

* chore: remobed JWTAuthGuard from relevant routes in PAT controllers file

* chore: modified token for auth in PATAuthGuard

* chore: changed error codes in some certain service methods in access-token module

* feat: worked on feedback for PR review

* chore: renamed service method in access-token module

* chore: removed console log statements

* chore: modified cli error type

* test: fix broken test case

* chore: changed target of hopp-old-backend to prod

---------

Co-authored-by: mirarifhasan <arif.ishan05@gmail.com>
2024-05-28 16:39:50 +05:30
Mir Arif Hasan
f4f3fdf2d5 HSB-445 feature: storing user last login timestamp (#4074)
* feat: lastLoggedOn added in schema and service function

* feat: add lastLoggedOn logic for magic link

* test: update test cases

* feat: add lastLoggedOn in gql model

* fix: nullable allowed in model attribute

* fix: resolve feedback

* feat: user last login interceptor added
2024-05-27 21:49:42 +05:30
Mir Arif Hasan
b7a3ae231b HSB-431 fix: email case sensitive in email provider (#4042)
* feat: code level email insensitivity added

* test: fix broken test case

* chore: updated testcase for findUserByEmail in user module

---------

Co-authored-by: Balu Babu <balub997@gmail.com>
2024-05-15 12:37:35 +05:30
Nivedin
f8ac6dfeb1 chore: add workspace switcher login A/B testing flow (#4053)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2024-05-10 16:35:42 +05:30
Andrew Bastin
7d2d335b37 chore: revert back default interceptor for sh app to browser 2024-05-10 16:13:51 +05:30
Andrew Bastin
76875db865 chore: bump selfhost-desktop lockfile version 2024-05-10 15:04:16 +05:30
Balu Babu
96e2d87b57 feat: update node version to node20-apline3.19 (#4040) 2024-05-10 14:24:34 +05:30
319 changed files with 22937 additions and 8494 deletions

View File

@@ -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

View File

@@ -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 -->

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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"
}

View File

@@ -1,4 +1,4 @@
FROM node:18.8.0 AS builder
FROM node:20.12.2 AS builder
WORKDIR /usr/src/app

View File

@@ -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",

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "lastLoggedOn" TIMESTAMP(3);

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "lastActiveOn" TIMESTAMP(3);

View File

@@ -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");

View File

@@ -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)
}

View File

@@ -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;
}
}

View File

@@ -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 {}

View File

@@ -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);
});
});
});

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,5 @@
// Inputs to create a new PAT
export class CreateAccessTokenDto {
label: string;
expiryInDays: number | null;
}

View 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 };
}

View File

@@ -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);

View File

@@ -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',
},
},
},

View File

@@ -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;
}
}

View File

@@ -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 {}

View File

@@ -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);

View File

@@ -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',

View File

@@ -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);
}

View File

@@ -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,
});

View File

@@ -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];
},
);

View File

@@ -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';

View File

@@ -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,
];
/**

View 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;
}
}

View File

@@ -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;
}
}

View File

@@ -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,

View File

@@ -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],
})

View File

@@ -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();
}
}

View File

@@ -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;

View File

@@ -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,
},
);
}
}

View File

@@ -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;
}

View File

@@ -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 {}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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;
}),
);
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}),
);
}
}

View File

@@ -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);
}),
);
}
}

View 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',
},
};
}
}

View File

@@ -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',

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -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: {},

View File

@@ -299,7 +299,10 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
where: userEmail
? {
User: {
email: userEmail,
email: {
equals: userEmail,
mode: 'insensitive',
},
},
}
: undefined,

View File

@@ -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[];
};

View File

@@ -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, {

View File

@@ -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: [],
// });
// });
});

View File

@@ -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);
}
}

View File

@@ -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);
});
});
});

View File

@@ -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);
}
}
}

View File

@@ -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,
},
});

View File

@@ -0,0 +1,7 @@
export type AccessToken = {
id: string;
label: string;
createdOn: Date;
lastUsedOn: Date;
expiresOn: null | Date;
};

View File

@@ -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',

View File

@@ -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;
};

View File

@@ -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,

View File

@@ -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',

View File

@@ -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: {},

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -27,6 +27,8 @@ const user: AuthUser = {
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
currentGQLSession: {},
currentRESTSession: {},
lastLoggedOn: currentTime,
lastActiveOn: currentTime,
createdOn: currentTime,
};

View File

@@ -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',
})

View File

@@ -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);

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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,
};

View File

@@ -1 +0,0 @@
import "@relmify/jest-fp-ts";

View File

@@ -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"
}
}

View 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);

View File

@@ -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();
});
});

View File

@@ -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 &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</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 &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</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 &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/assertions/error\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/assertions/error\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/assertions/error\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/assertions/error\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/assertions/error\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/assertions/error\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/assertions/error\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/assertions/error\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/assertions/error\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/assertions/error\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</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 &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/invalid-url\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</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 &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/request-level-errors/non-existent-env-var\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</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 &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/assertions/error\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/assertions/error\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/assertions/error\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/assertions/error\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/assertions/error\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/assertions/error\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/assertions/error\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/assertions/error\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/assertions/error\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</testcase>
<testcase name=\\"\`toBeType()\` error scenarios - Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\" classname=\\"test-junit-report-export/assertions/error\\">
<error message=\\"Argument for toBeType should be &quot;string&quot;, &quot;boolean&quot;, &quot;number&quot;, &quot;object&quot;, &quot;undefined&quot;, &quot;bigint&quot;, &quot;symbol&quot; or &quot;function&quot;\\"/>
</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>"
`;

View 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();
});
});
});

View File

@@ -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
}
]
}

View File

@@ -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