Compare commits
9 Commits
feat/cli-a
...
feat/invit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ec4714afa | ||
|
|
da2ec5a46d | ||
|
|
ab87a7a16b | ||
|
|
8b14f77d78 | ||
|
|
bcaed7ec69 | ||
|
|
c371b56a23 | ||
|
|
5f52acacc0 | ||
|
|
742eca6d10 | ||
|
|
1e860c3535 |
13
.env.example
13
.env.example
@@ -35,9 +35,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_ADVANCE_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
|
||||
|
||||
@@ -118,11 +118,11 @@ services:
|
||||
restart: always
|
||||
environment:
|
||||
# Edit the below line to match your PostgresDB URL if you have an outside DB (make sure to update the .env file as well)
|
||||
# - DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300
|
||||
- DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300
|
||||
- PORT=3000
|
||||
volumes:
|
||||
# Uncomment the line below when modifying code. Only applicable when using the "dev" target.
|
||||
- ./packages/hoppscotch-backend/:/usr/src/app
|
||||
# - ./packages/hoppscotch-backend/:/usr/src/app
|
||||
- /usr/src/app/node_modules/
|
||||
depends_on:
|
||||
hoppscotch-db:
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"@nestjs/common": "10.2.7",
|
||||
"@nestjs/config": "3.1.1",
|
||||
"@nestjs/core": "10.2.7",
|
||||
"@nestjs/event-emitter": "2.0.4",
|
||||
"@nestjs/graphql": "12.0.9",
|
||||
"@nestjs/jwt": "10.1.1",
|
||||
"@nestjs/passport": "10.0.2",
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "lastLoggedOn" TIMESTAMP(3);
|
||||
@@ -1,19 +0,0 @@
|
||||
|
||||
-- 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;
|
||||
@@ -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,26 +89,24 @@ 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?
|
||||
lastLoggedOn DateTime?
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
invitedUsers InvitedUsers[]
|
||||
shortcodes Shortcode[]
|
||||
personalAccessTokens PersonalAccessToken[]
|
||||
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[]
|
||||
}
|
||||
|
||||
model Account {
|
||||
@@ -220,14 +218,3 @@ 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)
|
||||
}
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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 {}
|
||||
@@ -1,195 +0,0 @@
|
||||
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,
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,203 +0,0 @@
|
||||
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 { 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-';
|
||||
|
||||
/**
|
||||
* Calculate the expiration date of the token
|
||||
*
|
||||
* @param expiresOn Number of days the token is valid for
|
||||
* @returns Date object of the expiration date
|
||||
*/
|
||||
private calculateExpirationDate(expiresOn: null | number) {
|
||||
if (expiresOn === null) return null;
|
||||
return new Date(Date.now() + expiresOn * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: this.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
// Inputs to create a new PAT
|
||||
export class CreateAccessTokenDto {
|
||||
label: string;
|
||||
expiryInDays: number | null;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { ShortcodeService } from 'src/shortcode/shortcode.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { OffsetPaginationArgs } from 'src/types/input-types.args';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
|
||||
const mockPrisma = mockDeep<PrismaService>();
|
||||
const mockPubSub = mockDeep<PubSubService>();
|
||||
@@ -34,6 +35,7 @@ const mockTeamCollectionService = mockDeep<TeamCollectionService>();
|
||||
const mockMailerService = mockDeep<MailerService>();
|
||||
const mockShortcodeService = mockDeep<ShortcodeService>();
|
||||
const mockConfigService = mockDeep<ConfigService>();
|
||||
const mockEventEmitter = mockDeep<EventEmitter2>();
|
||||
|
||||
const adminService = new AdminService(
|
||||
mockUserService,
|
||||
@@ -44,9 +46,9 @@ const adminService = new AdminService(
|
||||
mockTeamInvitationService,
|
||||
mockPubSub as any,
|
||||
mockPrisma as any,
|
||||
mockMailerService,
|
||||
mockShortcodeService,
|
||||
mockConfigService,
|
||||
mockEventEmitter,
|
||||
);
|
||||
|
||||
const invitedUsers: InvitedUsers[] = [
|
||||
@@ -74,7 +76,6 @@ const dbAdminUsers: DbUser[] = [
|
||||
refreshToken: 'refreshToken',
|
||||
currentRESTSession: '',
|
||||
currentGQLSession: '',
|
||||
lastLoggedOn: new Date(),
|
||||
createdOn: new Date(),
|
||||
},
|
||||
{
|
||||
@@ -86,10 +87,20 @@ const dbAdminUsers: DbUser[] = [
|
||||
refreshToken: 'refreshToken',
|
||||
currentRESTSession: '',
|
||||
currentGQLSession: '',
|
||||
lastLoggedOn: 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', () => {
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
USER_IS_ADMIN,
|
||||
USER_NOT_FOUND,
|
||||
} from '../errors';
|
||||
import { MailerService } from '../mailer/mailer.service';
|
||||
import { InvitedUser } from './invited-user.model';
|
||||
import { TeamService } from '../team/team.service';
|
||||
import { TeamCollectionService } from '../team-collection/team-collection.service';
|
||||
@@ -31,6 +30,8 @@ import { ShortcodeService } from 'src/shortcode/shortcode.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { OffsetPaginationArgs } from 'src/types/input-types.args';
|
||||
import { UserDeletionResult } from 'src/user/user.model';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { Events } from 'src/types/EventEmitter';
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
@@ -43,9 +44,9 @@ export class AdminService {
|
||||
private readonly teamInvitationService: TeamInvitationService,
|
||||
private readonly pubsub: PubSubService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mailerService: MailerService,
|
||||
private readonly shortcodeService: ShortcodeService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -99,17 +100,16 @@ export class AdminService {
|
||||
});
|
||||
if (alreadyInvitedUser != null) return E.left(USER_ALREADY_INVITED);
|
||||
|
||||
try {
|
||||
await this.mailerService.sendUserInvitationEmail(inviteeEmail, {
|
||||
this.eventEmitter.emit(Events.MAILER_SEND_USER_INVITATION_EMAIL, {
|
||||
to: inviteeEmail,
|
||||
mailDesc: {
|
||||
template: 'user-invitation',
|
||||
variables: {
|
||||
inviteeEmail: inviteeEmail,
|
||||
magicLink: `${this.configService.get('VITE_BASE_URL')}`,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
return E.left(EMAIL_FAILED);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Add invitee email to the list of invited users by admin
|
||||
const dbInvitedUser = await this.prisma.invitedUsers.create({
|
||||
|
||||
@@ -292,6 +292,14 @@ export class InfraResolver {
|
||||
return this.infraConfigService.getAllowedAuthProviders();
|
||||
}
|
||||
|
||||
@Query(() => Boolean, {
|
||||
description: 'Check if the SMTP is enabled or not',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard)
|
||||
isSMTPEnabled() {
|
||||
return this.infraConfigService.isSMTPEnabled();
|
||||
}
|
||||
|
||||
/* Mutations */
|
||||
|
||||
@Mutation(() => [InfraConfig], {
|
||||
@@ -359,4 +367,23 @@ export class InfraResolver {
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Enable or Disable SMTP for sending emails',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async toggleSMTP(
|
||||
@Args({
|
||||
name: 'status',
|
||||
type: () => ServiceStatus,
|
||||
description: 'Toggle SMTP',
|
||||
})
|
||||
status: ServiceStatus,
|
||||
) {
|
||||
const isUpdated = await this.infraConfigService.enableAndDisableSMTP(
|
||||
status,
|
||||
);
|
||||
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,11 @@ 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 { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
EventEmitterModule.forRoot(),
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [async () => loadInfraConfiguration()],
|
||||
@@ -103,7 +104,6 @@ import { AccessTokenModule } from './access-token/access-token.module';
|
||||
PosthogModule,
|
||||
ScheduleModule.forRoot(),
|
||||
HealthModule,
|
||||
AccessTokenModule,
|
||||
],
|
||||
providers: [GQLComplexityPlugin],
|
||||
controllers: [AppController],
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Request,
|
||||
Res,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { SignInMagicDto } from './dto/signin-magic.dto';
|
||||
@@ -28,7 +27,6 @@ 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' })
|
||||
@@ -112,7 +110,6 @@ 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);
|
||||
@@ -138,7 +135,6 @@ 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);
|
||||
@@ -164,7 +160,6 @@ 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);
|
||||
|
||||
@@ -23,6 +23,7 @@ import * as argon2 from 'argon2';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InfraConfigService } from 'src/infra-config/infra-config.service';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
|
||||
const mockPrisma = mockDeep<PrismaService>();
|
||||
const mockUser = mockDeep<UserService>();
|
||||
@@ -30,6 +31,7 @@ const mockJWT = mockDeep<JwtService>();
|
||||
const mockMailer = mockDeep<MailerService>();
|
||||
const mockConfigService = mockDeep<ConfigService>();
|
||||
const mockInfraConfigService = mockDeep<InfraConfigService>();
|
||||
const mockEventEmitter = mockDeep<EventEmitter2>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
@@ -37,9 +39,9 @@ const authService = new AuthService(
|
||||
mockUser,
|
||||
mockPrisma,
|
||||
mockJWT,
|
||||
mockMailer,
|
||||
mockConfigService,
|
||||
mockInfraConfigService,
|
||||
mockEventEmitter,
|
||||
);
|
||||
|
||||
const currentTime = new Date();
|
||||
@@ -51,7 +53,6 @@ const user: AuthUser = {
|
||||
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
|
||||
isAdmin: false,
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
currentGQLSession: {},
|
||||
currentRESTSession: {},
|
||||
@@ -173,11 +174,9 @@ 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({
|
||||
@@ -200,11 +199,9 @@ 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({
|
||||
@@ -244,7 +241,7 @@ describe('verifyMagicLinkTokens', () => {
|
||||
// generateAuthTokens
|
||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.updateUserRefreshToken.mockResolvedValueOnce(
|
||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
|
||||
E.left(USER_NOT_FOUND),
|
||||
);
|
||||
|
||||
@@ -269,7 +266,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');
|
||||
|
||||
@@ -285,7 +282,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({
|
||||
@@ -297,7 +294,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),
|
||||
);
|
||||
|
||||
@@ -324,7 +321,7 @@ describe('refreshAuthTokens', () => {
|
||||
// generateAuthTokens
|
||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.updateUserRefreshToken.mockResolvedValueOnce(
|
||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
|
||||
E.left(USER_NOT_FOUND),
|
||||
);
|
||||
|
||||
@@ -353,7 +350,7 @@ describe('refreshAuthTokens', () => {
|
||||
// generateAuthTokens
|
||||
mockJWT.sign.mockReturnValue('sdhjcbjsdhcbshjdcb');
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.updateUserRefreshToken.mockResolvedValueOnce(
|
||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
|
||||
E.right({
|
||||
...user,
|
||||
refreshToken: 'sdhjcbjsdhcbshjdcb',
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { MailerService } from 'src/mailer/mailer.service';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { UserService } from 'src/user/user.service';
|
||||
import { VerifyMagicDto } from './dto/verify-magic.dto';
|
||||
@@ -30,6 +29,8 @@ import { VerificationToken } from '@prisma/client';
|
||||
import { Origin } from './helper';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InfraConfigService } from 'src/infra-config/infra-config.service';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { Events } from 'src/types/EventEmitter';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@@ -37,9 +38,9 @@ export class AuthService {
|
||||
private usersService: UserService,
|
||||
private prismaService: PrismaService,
|
||||
private jwtService: JwtService,
|
||||
private readonly mailerService: MailerService,
|
||||
private readonly configService: ConfigService,
|
||||
private infraConfigService: InfraConfigService,
|
||||
private eventEmitter: EventEmitter2,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -112,7 +113,7 @@ export class AuthService {
|
||||
|
||||
const refreshTokenHash = await argon2.hash(refreshToken);
|
||||
|
||||
const updatedUser = await this.usersService.updateUserRefreshToken(
|
||||
const updatedUser = await this.usersService.UpdateUserRefreshToken(
|
||||
refreshTokenHash,
|
||||
userUid,
|
||||
);
|
||||
@@ -234,11 +235,14 @@ export class AuthService {
|
||||
url = this.configService.get('VITE_BASE_URL');
|
||||
}
|
||||
|
||||
await this.mailerService.sendEmail(email, {
|
||||
template: 'user-invitation',
|
||||
variables: {
|
||||
inviteeEmail: email,
|
||||
magicLink: `${url}/enter?token=${generatedTokens.token}`,
|
||||
this.eventEmitter.emit(Events.MAILER_SEND_EMAIL, {
|
||||
to: email,
|
||||
mailDesc: {
|
||||
template: 'user-invitation',
|
||||
variables: {
|
||||
inviteeEmail: email,
|
||||
magicLink: `${url}/enter?token=${generatedTokens.token}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -320,8 +324,6 @@ export class AuthService {
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
|
||||
this.usersService.updateUserLastLoggedOn(passwordlessTokens.value.userUid);
|
||||
|
||||
return E.right(tokens.right);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,39 +774,3 @@ 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_TOKENS_EXPIRED = 'TOKEN_EXPIRED';
|
||||
|
||||
/**
|
||||
* AccessTokens is invalid
|
||||
* (AccessTokenService)
|
||||
*/
|
||||
export const ACCESS_TOKENS_INVALID = 'TOKEN_INVALID';
|
||||
|
||||
/**
|
||||
* AccessTokens is invalid
|
||||
* (AccessTokenService)
|
||||
*/
|
||||
export const ACCESS_TOKENS_INVALID_DATA_ID = 'INVALID_ID';
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
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_TOKENS_EXPIRED, ACCESS_TOKENS_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_TOKENS_INVALID),
|
||||
);
|
||||
}
|
||||
|
||||
const userAccessToken = await this.accessTokenService.getUserPAT(token);
|
||||
if (E.isLeft(userAccessToken))
|
||||
throw new BadRequestException(
|
||||
createCLIErrorResponse(ACCESS_TOKENS_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_TOKENS_EXPIRED),
|
||||
);
|
||||
}
|
||||
|
||||
private extractTokenFromHeader(request: Request): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
}
|
||||
@@ -33,10 +33,17 @@ const AuthProviderConfigurations = {
|
||||
InfraConfigEnum.MICROSOFT_SCOPE,
|
||||
InfraConfigEnum.MICROSOFT_TENANT,
|
||||
],
|
||||
[AuthProvider.EMAIL]: [
|
||||
InfraConfigEnum.MAILER_SMTP_URL,
|
||||
InfraConfigEnum.MAILER_ADDRESS_FROM,
|
||||
],
|
||||
[AuthProvider.EMAIL]: !!process.env.MAILER_USE_CUSTOM_CONFIGS
|
||||
? [
|
||||
InfraConfigEnum.MAILER_SMTP_HOST,
|
||||
InfraConfigEnum.MAILER_SMTP_PORT,
|
||||
InfraConfigEnum.MAILER_SMTP_SECURE,
|
||||
InfraConfigEnum.MAILER_SMTP_USER,
|
||||
InfraConfigEnum.MAILER_SMTP_PASSWORD,
|
||||
InfraConfigEnum.MAILER_TLS_REJECT_UNAUTHORIZED,
|
||||
InfraConfigEnum.MAILER_ADDRESS_FROM,
|
||||
]
|
||||
: [InfraConfigEnum.MAILER_SMTP_URL, InfraConfigEnum.MAILER_ADDRESS_FROM],
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -75,6 +82,14 @@ export async function getDefaultInfraConfigs(): Promise<
|
||||
|
||||
// Prepare rows for 'infra_config' table with default values (from .env) for each 'name'
|
||||
const infraConfigDefaultObjs: { name: InfraConfigEnum; value: string }[] = [
|
||||
{
|
||||
name: InfraConfigEnum.MAILER_SMTP_ENABLE,
|
||||
value: process.env.MAILER_SMTP_ENABLE ?? 'true',
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MAILER_USE_CUSTOM_CONFIGS,
|
||||
value: process.env.MAILER_USE_CUSTOM_CONFIGS ?? 'false',
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MAILER_SMTP_URL,
|
||||
value: process.env.MAILER_SMTP_URL,
|
||||
@@ -83,6 +98,30 @@ export async function getDefaultInfraConfigs(): Promise<
|
||||
name: InfraConfigEnum.MAILER_ADDRESS_FROM,
|
||||
value: process.env.MAILER_ADDRESS_FROM,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MAILER_SMTP_HOST,
|
||||
value: process.env.MAILER_SMTP_HOST,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MAILER_SMTP_PORT,
|
||||
value: process.env.MAILER_SMTP_PORT,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MAILER_SMTP_SECURE,
|
||||
value: process.env.MAILER_SMTP_SECURE,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MAILER_SMTP_USER,
|
||||
value: process.env.MAILER_SMTP_USER,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MAILER_SMTP_PASSWORD,
|
||||
value: process.env.MAILER_SMTP_PASSWORD,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MAILER_TLS_REJECT_UNAUTHORIZED,
|
||||
value: process.env.MAILER_TLS_REJECT_UNAUTHORIZED,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.GOOGLE_CLIENT_ID,
|
||||
value: process.env.GOOGLE_CLIENT_ID,
|
||||
|
||||
@@ -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 = [
|
||||
@@ -218,6 +219,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 +358,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 +415,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 +437,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;
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Observable, map } from 'rxjs';
|
||||
import { AccessTokenService } from 'src/access-token/access-token.service';
|
||||
import * as E from 'fp-ts/Either';
|
||||
|
||||
@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 UnauthorizedException();
|
||||
}
|
||||
|
||||
return handler.handle().pipe(
|
||||
map(async (data) => {
|
||||
const userAccessToken =
|
||||
await this.accessTokenService.updateLastUsedForPAT(token);
|
||||
if (E.isLeft(userAccessToken)) throw new UnauthorizedException();
|
||||
|
||||
return data;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
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;
|
||||
|
||||
const now = Date.now();
|
||||
return next.handle().pipe(
|
||||
tap(() => {
|
||||
this.userService.updateUserLastLoggedOn(user.uid);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
30
packages/hoppscotch-backend/src/mailer/mailer.listener.ts
Normal file
30
packages/hoppscotch-backend/src/mailer/mailer.listener.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { Events } from 'src/types/EventEmitter';
|
||||
import {
|
||||
AdminUserInvitationMailDescription,
|
||||
MailDescription,
|
||||
UserMagicLinkMailDescription,
|
||||
} from './MailDescriptions';
|
||||
import { MailerService } from './mailer.service';
|
||||
|
||||
@Injectable()
|
||||
export class MailerEventListener {
|
||||
constructor(private mailerService: MailerService) {}
|
||||
|
||||
@OnEvent(Events.MAILER_SEND_EMAIL, { async: true })
|
||||
async handleSendEmailEvent(data: {
|
||||
to: string;
|
||||
mailDesc: MailDescription | UserMagicLinkMailDescription;
|
||||
}) {
|
||||
await this.mailerService.sendEmail(data.to, data.mailDesc);
|
||||
}
|
||||
|
||||
@OnEvent(Events.MAILER_SEND_USER_INVITATION_EMAIL, { async: true })
|
||||
async handleSendUserInvitationEmailEvent(data: {
|
||||
to: string;
|
||||
mailDesc: AdminUserInvitationMailDescription;
|
||||
}) {
|
||||
await this.mailerService.sendUserInvitationEmail(data.to, data.mailDesc);
|
||||
}
|
||||
}
|
||||
@@ -4,38 +4,43 @@ import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handleba
|
||||
import { MailerService } from './mailer.service';
|
||||
import { throwErr } from 'src/utils';
|
||||
import {
|
||||
MAILER_FROM_ADDRESS_UNDEFINED,
|
||||
MAILER_SMTP_PASSWORD_UNDEFINED,
|
||||
MAILER_SMTP_URL_UNDEFINED,
|
||||
MAILER_SMTP_USER_UNDEFINED,
|
||||
} from 'src/errors';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { loadInfraConfiguration } from 'src/infra-config/helper';
|
||||
import { MailerEventListener } from './mailer.listener';
|
||||
import { TransportType } from '@nestjs-modules/mailer/dist/interfaces/mailer-options.interface';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [],
|
||||
providers: [MailerService],
|
||||
exports: [MailerService],
|
||||
})
|
||||
@Module({})
|
||||
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 SMTP is disabled');
|
||||
return { module: MailerModule };
|
||||
}
|
||||
|
||||
// If mailer is ENABLED, return the module with configuration (service, listener, 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,
|
||||
providers: [MailerService, MailerEventListener],
|
||||
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',
|
||||
@@ -46,3 +51,52 @@ export class MailerModule {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function isEnabled(value) {
|
||||
return value === 'true';
|
||||
}
|
||||
function getMailerAddressFrom(env, config): string {
|
||||
return (
|
||||
env.INFRA.MAILER_ADDRESS_FROM ??
|
||||
config.get('MAILER_ADDRESS_FROM') ??
|
||||
throwErr(MAILER_SMTP_URL_UNDEFINED)
|
||||
);
|
||||
}
|
||||
function getTransportOption(env, config): TransportType {
|
||||
const useCustomConfigs = isEnabled(
|
||||
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'),
|
||||
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'),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
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,
|
||||
@@ -64,6 +70,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,
|
||||
|
||||
@@ -48,7 +48,6 @@ const user: AuthUser = {
|
||||
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
|
||||
isAdmin: false,
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: createdOn,
|
||||
createdOn: createdOn,
|
||||
currentGQLSession: {},
|
||||
currentRESTSession: {},
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { TeamRequest } from '@prisma/client';
|
||||
|
||||
// Type of data returned from the query to obtain all search results
|
||||
export type SearchQueryReturnType = {
|
||||
id: string;
|
||||
@@ -14,12 +12,3 @@ 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[];
|
||||
};
|
||||
|
||||
@@ -12,7 +12,6 @@ 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';
|
||||
@@ -20,18 +19,15 @@ 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();
|
||||
@@ -43,7 +39,6 @@ const user: AuthUser = {
|
||||
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
|
||||
isAdmin: false,
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
currentGQLSession: {},
|
||||
currentRESTSession: {},
|
||||
@@ -1743,63 +1738,3 @@ describe('updateTeamCollection', () => {
|
||||
});
|
||||
|
||||
//ToDo: write test cases for exportCollectionsToJSON
|
||||
|
||||
describe('getCollectionForCLI', () => {
|
||||
test('should throw TEAM_COLL_NOT_FOUND if collectionID is invalid', async () => {
|
||||
mockPrisma.teamCollection.findUniqueOrThrow.mockRejectedValueOnce(
|
||||
'NotFoundError',
|
||||
);
|
||||
|
||||
const result = await teamCollectionService.getCollectionForCLI(
|
||||
'invalidID',
|
||||
user.uid,
|
||||
);
|
||||
expect(result).toEqualLeft(TEAM_COLL_NOT_FOUND);
|
||||
});
|
||||
|
||||
test('should throw TEAM_MEMBER_NOT_FOUND if user not in same team', async () => {
|
||||
mockPrisma.teamCollection.findUniqueOrThrow.mockResolvedValueOnce(
|
||||
rootTeamCollection,
|
||||
);
|
||||
mockTeamService.getTeamMember.mockResolvedValue(null);
|
||||
|
||||
const result = await teamCollectionService.getCollectionForCLI(
|
||||
rootTeamCollection.id,
|
||||
user.uid,
|
||||
);
|
||||
expect(result).toEqualLeft(TEAM_MEMBER_NOT_FOUND);
|
||||
});
|
||||
|
||||
// test('should return the TeamCollection data for CLI', async () => {
|
||||
// mockPrisma.teamCollection.findUniqueOrThrow.mockResolvedValueOnce(
|
||||
// rootTeamCollection,
|
||||
// );
|
||||
// mockTeamService.getTeamMember.mockResolvedValue({
|
||||
// membershipID: 'sdc3sfdv',
|
||||
// userUid: user.uid,
|
||||
// role: TeamMemberRole.OWNER,
|
||||
// });
|
||||
|
||||
// const result = await teamCollectionService.getCollectionForCLI(
|
||||
// rootTeamCollection.id,
|
||||
// user.uid,
|
||||
// );
|
||||
// expect(result).toEqualRight({
|
||||
// id: rootTeamCollection.id,
|
||||
// data: JSON.stringify(rootTeamCollection.data),
|
||||
// title: rootTeamCollection.title,
|
||||
// parentID: rootTeamCollection.parentID,
|
||||
// folders: [
|
||||
// {
|
||||
// id: childTeamCollection.id,
|
||||
// data: JSON.stringify(childTeamCollection.data),
|
||||
// title: childTeamCollection.title,
|
||||
// parentID: childTeamCollection.parentID,
|
||||
// folders: [],
|
||||
// requests: [],
|
||||
// },
|
||||
// ],
|
||||
// requests: [],
|
||||
// });
|
||||
// });
|
||||
});
|
||||
|
||||
@@ -18,34 +18,23 @@ 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 * as E from 'fp-ts/Either';
|
||||
import * as O from 'fp-ts/Option';
|
||||
import {
|
||||
Prisma,
|
||||
TeamCollection as DBTeamCollection,
|
||||
TeamRequest,
|
||||
} from '@prisma/client';
|
||||
import { Prisma, TeamCollection as DBTeamCollection } from '@prisma/client';
|
||||
import { CollectionFolder } from 'src/types/CollectionFolder';
|
||||
import { stringToJson } from 'src/utils';
|
||||
import { CollectionSearchNode } from 'src/types/CollectionSearchNode';
|
||||
import {
|
||||
GetCollectionResponse,
|
||||
ParentTreeQueryReturnType,
|
||||
SearchQueryReturnType,
|
||||
} from './helper';
|
||||
import { 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;
|
||||
@@ -1355,95 +1344,4 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,24 +6,19 @@ 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 = {
|
||||
@@ -385,47 +380,4 @@ describe('TeamEnvironmentsService', () => {
|
||||
expect(result).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTeamEnvironmentForCLI', () => {
|
||||
test('should successfully return a TeamEnvironment with valid ID', async () => {
|
||||
mockPrisma.teamEnvironment.findFirstOrThrow.mockResolvedValueOnce(
|
||||
teamEnvironment,
|
||||
);
|
||||
mockTeamService.getTeamMember.mockResolvedValue({
|
||||
membershipID: 'sdc3sfdv',
|
||||
userUid: '123454',
|
||||
role: TeamMemberRole.OWNER,
|
||||
});
|
||||
|
||||
const result = await teamEnvironmentsService.getTeamEnvironmentForCLI(
|
||||
teamEnvironment.id,
|
||||
'123454',
|
||||
);
|
||||
expect(result).toEqualRight(teamEnvironment);
|
||||
});
|
||||
|
||||
test('should throw TEAM_ENVIRONMENT_NOT_FOUND with invalid ID', async () => {
|
||||
mockPrisma.teamEnvironment.findFirstOrThrow.mockRejectedValueOnce(
|
||||
'RejectOnNotFound',
|
||||
);
|
||||
|
||||
const result = await teamEnvironmentsService.getTeamEnvironment(
|
||||
teamEnvironment.id,
|
||||
);
|
||||
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
|
||||
});
|
||||
|
||||
test('should throw TEAM_MEMBER_NOT_FOUND if user not in same team', async () => {
|
||||
mockPrisma.teamEnvironment.findFirstOrThrow.mockResolvedValueOnce(
|
||||
teamEnvironment,
|
||||
);
|
||||
mockTeamService.getTeamMember.mockResolvedValue(null);
|
||||
|
||||
const result = await teamEnvironmentsService.getTeamEnvironmentForCLI(
|
||||
teamEnvironment.id,
|
||||
'333',
|
||||
);
|
||||
expect(result).toEqualLeft(TEAM_MEMBER_NOT_FOUND);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,17 +6,14 @@ 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;
|
||||
@@ -245,30 +242,4 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,12 +15,13 @@ import {
|
||||
TEAM_MEMBER_NOT_FOUND,
|
||||
} from 'src/errors';
|
||||
import { TeamInvitation } from './team-invitation.model';
|
||||
import { MailerService } from 'src/mailer/mailer.service';
|
||||
import { UserService } from 'src/user/user.service';
|
||||
import { PubSubService } from 'src/pubsub/pubsub.service';
|
||||
import { validateEmail } from '../utils';
|
||||
import { AuthUser } from 'src/types/AuthUser';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { Events } from 'src/types/EventEmitter';
|
||||
|
||||
@Injectable()
|
||||
export class TeamInvitationService {
|
||||
@@ -28,9 +29,9 @@ export class TeamInvitationService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly userService: UserService,
|
||||
private readonly teamService: TeamService,
|
||||
private readonly mailerService: MailerService,
|
||||
private readonly pubsub: PubSubService,
|
||||
private readonly configService: ConfigService,
|
||||
private eventEmitter: EventEmitter2,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -147,14 +148,17 @@ export class TeamInvitationService {
|
||||
},
|
||||
});
|
||||
|
||||
await this.mailerService.sendEmail(inviteeEmail, {
|
||||
template: 'team-invitation',
|
||||
variables: {
|
||||
invitee: creator.displayName ?? 'A Hoppscotch User',
|
||||
action_url: `${this.configService.get('VITE_BASE_URL')}/join-team?id=${
|
||||
dbInvitation.id
|
||||
}`,
|
||||
invite_team_name: team.name,
|
||||
this.eventEmitter.emit(Events.MAILER_SEND_EMAIL, {
|
||||
to: inviteeEmail,
|
||||
mailDesc: {
|
||||
template: 'team-invitation',
|
||||
variables: {
|
||||
invitee: creator.displayName ?? 'A Hoppscotch User',
|
||||
action_url: `${this.configService.get(
|
||||
'VITE_BASE_URL',
|
||||
)}/join-team?id=${dbInvitation.id}`,
|
||||
invite_team_name: team.name,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export type AccessToken = {
|
||||
id: string;
|
||||
label: string;
|
||||
createdOn: Date;
|
||||
lastUsedOn: Date;
|
||||
expiresOn: null | Date;
|
||||
};
|
||||
4
packages/hoppscotch-backend/src/types/EventEmitter.ts
Normal file
4
packages/hoppscotch-backend/src/types/EventEmitter.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export enum Events {
|
||||
MAILER_SEND_EMAIL = 'mailer.sendEmail',
|
||||
MAILER_SEND_USER_INVITATION_EMAIL = 'mailer.sendUserInvitationEmail',
|
||||
}
|
||||
@@ -1,7 +1,16 @@
|
||||
export enum InfraConfigEnum {
|
||||
MAILER_SMTP_ENABLE = 'MAILER_SMTP_ENABLE',
|
||||
MAILER_USE_CUSTOM_CONFIGS = 'MAILER_USE_CUSTOM_CONFIGS',
|
||||
MAILER_SMTP_URL = 'MAILER_SMTP_URL',
|
||||
MAILER_ADDRESS_FROM = 'MAILER_ADDRESS_FROM',
|
||||
|
||||
MAILER_SMTP_HOST = 'MAILER_SMTP_HOST',
|
||||
MAILER_SMTP_PORT = 'MAILER_SMTP_PORT',
|
||||
MAILER_SMTP_SECURE = 'MAILER_SMTP_SECURE',
|
||||
MAILER_SMTP_USER = 'MAILER_SMTP_USER',
|
||||
MAILER_SMTP_PASSWORD = 'MAILER_SMTP_PASSWORD',
|
||||
MAILER_TLS_REJECT_UNAUTHORIZED = 'MAILER_TLS_REJECT_UNAUTHORIZED',
|
||||
|
||||
GOOGLE_CLIENT_ID = 'GOOGLE_CLIENT_ID',
|
||||
GOOGLE_CLIENT_SECRET = 'GOOGLE_CLIENT_SECRET',
|
||||
GOOGLE_CALLBACK_URL = 'GOOGLE_CALLBACK_URL',
|
||||
|
||||
@@ -5,6 +5,6 @@ import { HttpStatus } from '@nestjs/common';
|
||||
** Since its REST we need to return the HTTP status code along with the error message
|
||||
*/
|
||||
export type RESTError = {
|
||||
message: string | Record<string, string>;
|
||||
message: string;
|
||||
statusCode: HttpStatus;
|
||||
};
|
||||
|
||||
@@ -38,7 +38,6 @@ const user: AuthUser = {
|
||||
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
|
||||
isAdmin: false,
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
currentGQLSession: {},
|
||||
currentRESTSession: {},
|
||||
|
||||
@@ -41,7 +41,6 @@ const user: AuthUser = {
|
||||
photoURL: 'https://example.com/photo.png',
|
||||
isAdmin: false,
|
||||
refreshToken: null,
|
||||
lastLoggedOn: new Date(),
|
||||
createdOn: new Date(),
|
||||
currentGQLSession: null,
|
||||
currentRESTSession: null,
|
||||
|
||||
@@ -27,7 +27,6 @@ const user: AuthUser = {
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
currentGQLSession: {},
|
||||
currentRESTSession: {},
|
||||
lastLoggedOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
};
|
||||
|
||||
|
||||
@@ -30,12 +30,6 @@ export class User {
|
||||
})
|
||||
isAdmin: boolean;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description: 'Date when the user last logged in',
|
||||
})
|
||||
lastLoggedOn: Date;
|
||||
|
||||
@Field({
|
||||
description: 'Date when the user account was created',
|
||||
})
|
||||
|
||||
@@ -42,7 +42,6 @@ const user: AuthUser = {
|
||||
currentRESTSession: {},
|
||||
currentGQLSession: {},
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
};
|
||||
|
||||
@@ -55,7 +54,6 @@ const adminUser: AuthUser = {
|
||||
currentRESTSession: {},
|
||||
currentGQLSession: {},
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
};
|
||||
|
||||
@@ -69,7 +67,6 @@ const users: AuthUser[] = [
|
||||
currentRESTSession: {},
|
||||
currentGQLSession: {},
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
},
|
||||
{
|
||||
@@ -81,7 +78,6 @@ const users: AuthUser[] = [
|
||||
currentRESTSession: {},
|
||||
currentGQLSession: {},
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
},
|
||||
{
|
||||
@@ -93,7 +89,6 @@ const users: AuthUser[] = [
|
||||
currentRESTSession: {},
|
||||
currentGQLSession: {},
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
},
|
||||
];
|
||||
@@ -108,7 +103,6 @@ const adminUsers: AuthUser[] = [
|
||||
currentRESTSession: {},
|
||||
currentGQLSession: {},
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
},
|
||||
{
|
||||
@@ -120,7 +114,6 @@ const adminUsers: AuthUser[] = [
|
||||
currentRESTSession: {},
|
||||
currentGQLSession: {},
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
},
|
||||
{
|
||||
@@ -132,7 +125,6 @@ const adminUsers: AuthUser[] = [
|
||||
currentRESTSession: {},
|
||||
currentGQLSession: {},
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
},
|
||||
];
|
||||
@@ -503,26 +495,6 @@ 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);
|
||||
|
||||
@@ -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,7 +174,6 @@ export class UserService {
|
||||
displayName: userDisplayName,
|
||||
email: profile.emails[0].value,
|
||||
photoURL: userPhotoURL,
|
||||
lastLoggedOn: new Date(),
|
||||
providerAccounts: {
|
||||
create: {
|
||||
provider: profile.provider,
|
||||
@@ -222,7 +221,7 @@ export class UserService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update User displayName and photoURL when logged in via a SSO provider
|
||||
* Update User displayName and photoURL
|
||||
*
|
||||
* @param user User object
|
||||
* @param profile Data received from SSO provider on the users account
|
||||
@@ -237,7 +236,6 @@ 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);
|
||||
@@ -291,7 +289,7 @@ export class UserService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a user's displayName
|
||||
* Update a user's data
|
||||
* @param userUID User UID
|
||||
* @param displayName User's displayName
|
||||
* @returns a Either of User or error
|
||||
@@ -318,22 +316,6 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and parse currentRESTSession and currentGQLSession
|
||||
* @param sessionData string of the session
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ExecException } from "child_process";
|
||||
import { HoppErrorCode } from "../../types/errors";
|
||||
import { runCLI, getErrorCode, getTestJsonFilePath } from "../utils";
|
||||
|
||||
describe("Test `hopp test <file_path_or_id>` command:", () => {
|
||||
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";
|
||||
@@ -131,7 +131,7 @@ describe("Test `hopp test <file_path_or_id>` command:", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test `hopp test <file_path_or_id> --env <file_path_or_id>` command:", () => {
|
||||
describe("Test `hopp test <file> --env <file>` command:", () => {
|
||||
describe("Supplied environment export file validations", () => {
|
||||
const VALID_TEST_ARGS = `test ${getTestJsonFilePath("passes-coll.json", "collection")}`;
|
||||
|
||||
@@ -310,7 +310,7 @@ describe("Test `hopp test <file_path_or_id> --env <file_path_or_id>` command:",
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test `hopp test <file_path_or_id> --delay <delay_in_ms>` command:", () => {
|
||||
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 () => {
|
||||
@@ -343,116 +343,3 @@ describe("Test `hopp test <file_path_or_id> --delay <delay_in_ms>` command:", ()
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
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("Validations", () => {
|
||||
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");
|
||||
});
|
||||
|
||||
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-token --server ${SERVER_URL}`;
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("TOKEN_INVALID");
|
||||
});
|
||||
|
||||
test("Errors with the code `TOKEN_INVALID` if the supplied collection ID is invalid", async () => {
|
||||
const args = `test invalid-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 `TOKEN_INVALID` if the supplied environment ID is invalid", async () => {
|
||||
const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --env invalid-id --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`;
|
||||
const { stderr } = await runCLI(args);
|
||||
|
||||
const out = getErrorCode(stderr);
|
||||
expect(out).toBe<HoppErrorCode>("INVALID_ID");
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,38 +1,30 @@
|
||||
import { handleError } from "../handlers/error";
|
||||
import { parseDelayOption } from "../options/test/delay";
|
||||
import { parseEnvsData } from "../options/test/env";
|
||||
import { TestCmdOptions } from "../types/commands";
|
||||
import { HoppEnvs } from "../types/request";
|
||||
import { isHoppCLIError } from "../utils/checks";
|
||||
import {
|
||||
collectionsRunner,
|
||||
collectionsRunnerExit,
|
||||
collectionsRunnerResult,
|
||||
} from "../utils/collections";
|
||||
import { handleError } from "../handlers/error";
|
||||
import { parseCollectionData } from "../utils/mutators";
|
||||
import { parseEnvsData } from "../options/test/env";
|
||||
import { TestCmdOptions } from "../types/commands";
|
||||
import { parseDelayOption } from "../options/test/delay";
|
||||
import { HoppEnvs } from "../types/request";
|
||||
import { isHoppCLIError } from "../utils/checks";
|
||||
|
||||
export const test = (pathOrId: string, options: TestCmdOptions) => async () => {
|
||||
export const test = (path: string, options: TestCmdOptions) => async () => {
|
||||
try {
|
||||
const delay = options.delay ? parseDelayOption(options.delay) : 0;
|
||||
const delay = options.delay ? parseDelayOption(options.delay) : 0
|
||||
const envs = options.env ? await parseEnvsData(options.env) : <HoppEnvs>{ global: [], selected: [] }
|
||||
const collections = await parseCollectionData(path)
|
||||
|
||||
const envs = options.env
|
||||
? await parseEnvsData(options.env, options.token, options.server)
|
||||
: <HoppEnvs>{ global: [], selected: [] };
|
||||
|
||||
const collections = await parseCollectionData(
|
||||
pathOrId,
|
||||
options.token,
|
||||
options.server
|
||||
);
|
||||
|
||||
const report = await collectionsRunner({ collections, envs, delay });
|
||||
const hasSucceeded = collectionsRunnerResult(report);
|
||||
|
||||
collectionsRunnerExit(hasSucceeded);
|
||||
} catch (e) {
|
||||
if (isHoppCLIError(e)) {
|
||||
handleError(e);
|
||||
const report = await collectionsRunner({collections, envs, delay})
|
||||
const hasSucceeded = collectionsRunnerResult(report)
|
||||
collectionsRunnerExit(hasSucceeded)
|
||||
} catch(e) {
|
||||
if(isHoppCLIError(e)) {
|
||||
handleError(e)
|
||||
process.exit(1);
|
||||
} else throw e;
|
||||
}
|
||||
else throw e
|
||||
}
|
||||
};
|
||||
|
||||
@@ -82,15 +82,6 @@ export const handleError = <T extends HoppErrorCode>(error: HoppError<T>) => {
|
||||
case "TESTS_FAILING":
|
||||
ERROR_MSG = error.data;
|
||||
break;
|
||||
case "TOKEN_EXPIRED":
|
||||
ERROR_MSG = `Token is expired: ${error.data}`;
|
||||
break;
|
||||
case "TOKEN_INVALID":
|
||||
ERROR_MSG = `Token is invalid/removed: ${error.data}`;
|
||||
break;
|
||||
case "INVALID_ID":
|
||||
ERROR_MSG = `The collection/environment is not valid/not accessible to the user: ${error.data}`;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!S.isEmpty(ERROR_MSG)) {
|
||||
|
||||
@@ -49,22 +49,14 @@ program.exitOverride().configureOutput({
|
||||
program
|
||||
.command("test")
|
||||
.argument(
|
||||
"<file_path_or_id>",
|
||||
"path to a hoppscotch collection.json file or collection ID from a workspace for CI testing"
|
||||
)
|
||||
.option(
|
||||
"-e, --env <file_path_or_id>",
|
||||
"path to an environment variables json file or environment ID from a workspace"
|
||||
"<file_path>",
|
||||
"path to a hoppscotch collection.json file for CI testing"
|
||||
)
|
||||
.option("-e, --env <file_path>", "path to an environment variables json file")
|
||||
.option(
|
||||
"-d, --delay <delay_in_ms>",
|
||||
"delay in milliseconds(ms) between consecutive requests within a collection"
|
||||
)
|
||||
.option(
|
||||
"--token <access_token>",
|
||||
"personal access token to access collections/environments from a workspace"
|
||||
)
|
||||
.option("--server <server_url>", "server URL for SH instance")
|
||||
.allowExcessArguments(false)
|
||||
.allowUnknownOption(false)
|
||||
.description("running hoppscotch collection.json file")
|
||||
@@ -74,7 +66,7 @@ program
|
||||
"https://docs.hoppscotch.io/documentation/clients/cli#commands"
|
||||
)}`
|
||||
)
|
||||
.action(async (pathOrId, options) => await test(pathOrId, options)());
|
||||
.action(async (path, options) => await test(path, options)());
|
||||
|
||||
export const cli = async (args: string[]) => {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { Environment, EnvironmentSchemaVersion } from "@hoppscotch/data";
|
||||
import axios, { AxiosError } from "axios";
|
||||
import fs from "fs/promises";
|
||||
import { Environment } from "@hoppscotch/data";
|
||||
import { entityReference } from "verzod";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -12,94 +10,13 @@ import {
|
||||
} from "../../types/request";
|
||||
import { readJsonFile } from "../../utils/mutators";
|
||||
|
||||
interface WorkspaceEnvironment {
|
||||
id: string;
|
||||
teamID: string;
|
||||
name: string;
|
||||
variables: HoppEnvPair[];
|
||||
}
|
||||
|
||||
// Helper functions to transform workspace environment data to the `HoppEnvironment` format
|
||||
const transformWorkspaceEnvironment = (
|
||||
workspaceEnvironment: WorkspaceEnvironment
|
||||
): Environment => {
|
||||
const { teamID, variables, ...rest } = workspaceEnvironment;
|
||||
|
||||
// Add `secret` field if the data conforms to an older schema
|
||||
const transformedEnvVars = variables.map((variable) => {
|
||||
if (!("secret" in variable)) {
|
||||
return {
|
||||
...(variable as HoppEnvPair),
|
||||
secret: false,
|
||||
} as HoppEnvPair;
|
||||
}
|
||||
|
||||
return variable;
|
||||
});
|
||||
|
||||
return {
|
||||
v: EnvironmentSchemaVersion,
|
||||
variables: transformedEnvVars,
|
||||
...rest,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses env json file for given path and validates the parsed env json object
|
||||
* @param pathOrId Path of env.json file to be parsed
|
||||
* @param [accessToken] Personal access token to fetch workspace environments
|
||||
* @param [serverUrl] server URL for SH instance
|
||||
* @param path Path of env.json file to be parsed
|
||||
* @returns For successful parsing we get HoppEnvs object
|
||||
*/
|
||||
export async function parseEnvsData(
|
||||
pathOrId: string,
|
||||
accessToken?: string,
|
||||
serverUrl?: string
|
||||
) {
|
||||
let contents = null;
|
||||
let fileExistsInPath = null;
|
||||
|
||||
try {
|
||||
await fs.access(pathOrId);
|
||||
fileExistsInPath = true;
|
||||
} catch (e) {
|
||||
fileExistsInPath = false;
|
||||
}
|
||||
|
||||
if (accessToken && !fileExistsInPath) {
|
||||
try {
|
||||
const hostname = serverUrl || "https://api.hoppscotch.io";
|
||||
const url = `${hostname.endsWith("/") ? hostname.slice(0, -1) : hostname}/v1/access-tokens/environment/${pathOrId}`;
|
||||
|
||||
const { data }: { data: WorkspaceEnvironment } = await axios.get(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
contents = transformWorkspaceEnvironment(data);
|
||||
} catch (err) {
|
||||
const errReason = (
|
||||
err as AxiosError<{
|
||||
reason?: any;
|
||||
message: string;
|
||||
error: string;
|
||||
statusCode: number;
|
||||
}>
|
||||
).response?.data?.reason;
|
||||
|
||||
if (errReason) {
|
||||
throw error({
|
||||
code: errReason,
|
||||
data: pathOrId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (contents === null) {
|
||||
contents = await readJsonFile(pathOrId);
|
||||
}
|
||||
export async function parseEnvsData(path: string) {
|
||||
const contents = await readJsonFile(path);
|
||||
const envPairs: Array<HoppEnvPair | Record<string, string>> = [];
|
||||
|
||||
// The legacy key-value pair format that is still supported
|
||||
@@ -116,7 +33,7 @@ export async function parseEnvsData(
|
||||
// CLI doesnt support bulk environments export
|
||||
// Hence we check for this case and throw an error if it matches the format
|
||||
if (HoppBulkEnvExportObjectResult.success) {
|
||||
throw error({ code: "BULK_ENV_FILE", path: pathOrId, data: error });
|
||||
throw error({ code: "BULK_ENV_FILE", path, data: error });
|
||||
}
|
||||
|
||||
// Checks if the environment file is of the correct format
|
||||
@@ -125,7 +42,7 @@ export async function parseEnvsData(
|
||||
!HoppEnvKeyPairResult.success &&
|
||||
HoppEnvExportObjectResult.type === "err"
|
||||
) {
|
||||
throw error({ code: "MALFORMED_ENV_FILE", path: pathOrId, data: error });
|
||||
throw error({ code: "MALFORMED_ENV_FILE", path, data: error });
|
||||
}
|
||||
|
||||
if (HoppEnvKeyPairResult.success) {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
export type TestCmdOptions = {
|
||||
env: string | undefined;
|
||||
delay: string | undefined;
|
||||
token: string | undefined;
|
||||
server: string | undefined;
|
||||
};
|
||||
|
||||
export type HOPP_ENV_FILE_EXT = "json";
|
||||
|
||||
@@ -26,9 +26,6 @@ type HoppErrors = {
|
||||
MALFORMED_ENV_FILE: HoppErrorPath & HoppErrorData;
|
||||
BULK_ENV_FILE: HoppErrorPath & HoppErrorData;
|
||||
INVALID_FILE_TYPE: HoppErrorData;
|
||||
TOKEN_EXPIRED: HoppErrorData;
|
||||
TOKEN_INVALID: HoppErrorData;
|
||||
INVALID_ID: HoppErrorData;
|
||||
};
|
||||
|
||||
export type HoppErrorCode = keyof HoppErrors;
|
||||
|
||||
@@ -1,34 +1,12 @@
|
||||
import {
|
||||
CollectionSchemaVersion,
|
||||
HoppCollection,
|
||||
HoppRESTRequest,
|
||||
} from "@hoppscotch/data";
|
||||
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data";
|
||||
import fs from "fs/promises";
|
||||
import { entityReference } from "verzod";
|
||||
import { z } from "zod";
|
||||
|
||||
import axios, { AxiosError } from "axios";
|
||||
import { error } from "../types/errors";
|
||||
import { FormDataEntry } from "../types/request";
|
||||
import { isHoppErrnoException } from "./checks";
|
||||
|
||||
interface WorkspaceCollection {
|
||||
id: string;
|
||||
data: string | null;
|
||||
title: string;
|
||||
parentID: string | null;
|
||||
folders: WorkspaceCollection[];
|
||||
requests: WorkspaceRequest[];
|
||||
}
|
||||
|
||||
interface WorkspaceRequest {
|
||||
id: string;
|
||||
collectionID: string;
|
||||
teamID: string;
|
||||
title: string;
|
||||
request: string;
|
||||
}
|
||||
|
||||
const getValidRequests = (
|
||||
collections: HoppCollection[],
|
||||
collectionFilePath: string
|
||||
@@ -64,50 +42,6 @@ const getValidRequests = (
|
||||
});
|
||||
};
|
||||
|
||||
// Helper functions to transform workspace collection data to the `HoppCollection` format
|
||||
const transformWorkspaceRequests = (requests: WorkspaceRequest[]) =>
|
||||
requests.map(({ request }) => JSON.parse(request));
|
||||
|
||||
const transformChildCollections = (
|
||||
childCollections: WorkspaceCollection[]
|
||||
): HoppCollection[] => {
|
||||
return childCollections.map(({ id, title, data, folders, requests }) => {
|
||||
const parsedData = data ? JSON.parse(data) : {};
|
||||
const { auth = { authType: "inherit", authActive: false }, headers = [] } =
|
||||
parsedData;
|
||||
|
||||
return {
|
||||
v: CollectionSchemaVersion,
|
||||
id,
|
||||
name: title,
|
||||
folders: transformChildCollections(folders),
|
||||
requests: transformWorkspaceRequests(requests),
|
||||
auth,
|
||||
headers,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const transformWorkspaceCollection = (
|
||||
collection: WorkspaceCollection
|
||||
): HoppCollection => {
|
||||
const { id, title, data, requests, folders } = collection;
|
||||
|
||||
const parsedData = data ? JSON.parse(data) : {};
|
||||
const { auth = { authType: "inherit", authActive: false }, headers = [] } =
|
||||
parsedData;
|
||||
|
||||
return {
|
||||
v: CollectionSchemaVersion,
|
||||
id,
|
||||
name: title,
|
||||
folders: transformChildCollections(folders),
|
||||
requests: transformWorkspaceRequests(requests),
|
||||
auth,
|
||||
headers,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses array of FormDataEntry to FormData.
|
||||
* @param values Array of FormDataEntry.
|
||||
@@ -158,64 +92,16 @@ export async function readJsonFile(path: string): Promise<unknown> {
|
||||
|
||||
/**
|
||||
* Parses collection json file for given path:context.path, and validates
|
||||
* the parsed collectiona array
|
||||
* @param pathOrId Collection json file path
|
||||
* @param [accessToken] Personal access token to fetch workspace environments
|
||||
* @param [serverUrl] server URL for SH instance
|
||||
* @returns For successful parsing we get array of HoppCollection
|
||||
* the parsed collectiona array.
|
||||
* @param path Collection json file path.
|
||||
* @returns For successful parsing we get array of HoppCollection,
|
||||
*/
|
||||
export async function parseCollectionData(
|
||||
pathOrId: string,
|
||||
accessToken?: string,
|
||||
serverUrl?: string
|
||||
path: string
|
||||
): Promise<HoppCollection[]> {
|
||||
let contents = null;
|
||||
let fileExistsInPath = null;
|
||||
let contents = await readJsonFile(path);
|
||||
|
||||
try {
|
||||
await fs.access(pathOrId);
|
||||
fileExistsInPath = true;
|
||||
} catch (e) {
|
||||
fileExistsInPath = false;
|
||||
}
|
||||
|
||||
if (accessToken && !fileExistsInPath) {
|
||||
try {
|
||||
const hostname = serverUrl || "https://api.hoppscotch.io";
|
||||
const url = `${hostname.endsWith("/") ? hostname.slice(0, -1) : hostname}/v1/access-tokens/collection/${pathOrId}`;
|
||||
|
||||
const { data }: { data: WorkspaceCollection } = await axios.get(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
contents = transformWorkspaceCollection(data);
|
||||
} catch (err) {
|
||||
const errReason = (
|
||||
err as AxiosError<{
|
||||
reason?: any;
|
||||
message: string;
|
||||
error: string;
|
||||
statusCode: number;
|
||||
}>
|
||||
).response?.data?.reason;
|
||||
|
||||
if (errReason) {
|
||||
throw error({
|
||||
code: errReason,
|
||||
data: pathOrId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to reading from file if contents are not available
|
||||
if (contents === null) {
|
||||
contents = await readJsonFile(pathOrId);
|
||||
}
|
||||
|
||||
const maybeArrayOfCollections: HoppCollection[] = Array.isArray(contents)
|
||||
const maybeArrayOfCollections: unknown[] = Array.isArray(contents)
|
||||
? contents
|
||||
: [contents];
|
||||
|
||||
@@ -224,14 +110,12 @@ export async function parseCollectionData(
|
||||
.safeParse(maybeArrayOfCollections);
|
||||
|
||||
if (!collectionSchemaParsedResult.success) {
|
||||
console.error(`Error is `, collectionSchemaParsedResult.error);
|
||||
|
||||
throw error({
|
||||
code: "MALFORMED_COLLECTION",
|
||||
path: pathOrId,
|
||||
path,
|
||||
data: "Please check the collection data.",
|
||||
});
|
||||
}
|
||||
|
||||
return getValidRequests(collectionSchemaParsedResult.data, pathOrId);
|
||||
return getValidRequests(collectionSchemaParsedResult.data, path);
|
||||
}
|
||||
|
||||
34235
pnpm-lock.yaml
generated
34235
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user