Compare commits

...

2 Commits

Author SHA1 Message Date
Balu Babu
4bd23a8f4c feat: adding support for hopp-cli in self-host Hoppscotch (#4079)
* feat: created a new table to store user PATs

* chore: renamed UserTokens table name to PersonalAccessToken

* chore: added unique property to token column

* chore: made expiresOn field optional

* chore: created access-token module

* feat: created access-token rest routes

* chore: created a new auth guard for PATs

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

* chore: created method to update lastUsedOn property for accessTokens

* chore: created interceptor to update lastUsedOn property of PAT

* feat: interceptor to update lastUpdatedOn property complete

* chore: removed unused imports in access-token interceptor

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

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

* chore: modified access-token interceptor

* chore: removed log statement from interceptor

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

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

* chore: added comments to all service methods

* chore: removed unused imports

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

* chore: added and updated testcases

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

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

* chore: modified token for auth in PATAuthGuard

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

* feat: worked on feedback for PR review

* chore: renamed service method in access-token module

* chore: removed console log statements

* chore: modified cli error type

* test: fix broken test case

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

---------

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

* feat: add lastLoggedOn logic for magic link

* test: update test cases

* feat: add lastLoggedOn in gql model

* fix: nullable allowed in model attribute

* fix: resolve feedback

* feat: user last login interceptor added
2024-05-27 21:49:42 +05:30
33 changed files with 1105 additions and 57 deletions

View File

@@ -118,11 +118,11 @@ services:
restart: always restart: always
environment: 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) # 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 - PORT=3000
volumes: volumes:
# Uncomment the line below when modifying code. Only applicable when using the "dev" target. # 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/ - /usr/src/app/node_modules/
depends_on: depends_on:
hoppscotch-db: hoppscotch-db:

View File

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

View File

@@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "PersonalAccessToken" (
"id" TEXT NOT NULL,
"userUid" TEXT NOT NULL,
"label" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expiresOn" TIMESTAMP(3),
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedOn" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PersonalAccessToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "PersonalAccessToken_token_key" ON "PersonalAccessToken"("token");
-- AddForeignKey
ALTER TABLE "PersonalAccessToken" ADD CONSTRAINT "PersonalAccessToken_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -41,31 +41,31 @@ model TeamInvitation {
} }
model TeamCollection { model TeamCollection {
id String @id @default(cuid()) id String @id @default(cuid())
parentID String? parentID String?
data Json? data Json?
parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id]) parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id])
children TeamCollection[] @relation("TeamCollectionChildParent") children TeamCollection[] @relation("TeamCollectionChildParent")
requests TeamRequest[] requests TeamRequest[]
teamID String teamID String
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade) team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
title String title String
orderIndex Int orderIndex Int
createdOn DateTime @default(now()) @db.Timestamp(3) createdOn DateTime @default(now()) @db.Timestamp(3)
updatedOn DateTime @updatedAt @db.Timestamp(3) updatedOn DateTime @updatedAt @db.Timestamp(3)
} }
model TeamRequest { model TeamRequest {
id String @id @default(cuid()) id String @id @default(cuid())
collectionID String collectionID String
collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade) collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade)
teamID String teamID String
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade) team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
title String title String
request Json request Json
orderIndex Int orderIndex Int
createdOn DateTime @default(now()) @db.Timestamp(3) createdOn DateTime @default(now()) @db.Timestamp(3)
updatedOn DateTime @updatedAt @db.Timestamp(3) updatedOn DateTime @updatedAt @db.Timestamp(3)
} }
model Shortcode { model Shortcode {
@@ -89,24 +89,26 @@ model TeamEnvironment {
} }
model User { model User {
uid String @id @default(cuid()) uid String @id @default(cuid())
displayName String? displayName String?
email String? @unique email String? @unique
photoURL String? photoURL String?
isAdmin Boolean @default(false) isAdmin Boolean @default(false)
refreshToken String? refreshToken String?
providerAccounts Account[] providerAccounts Account[]
VerificationToken VerificationToken[] VerificationToken VerificationToken[]
settings UserSettings? settings UserSettings?
UserHistory UserHistory[] UserHistory UserHistory[]
UserEnvironments UserEnvironment[] UserEnvironments UserEnvironment[]
userCollections UserCollection[] userCollections UserCollection[]
userRequests UserRequest[] userRequests UserRequest[]
currentRESTSession Json? currentRESTSession Json?
currentGQLSession Json? currentGQLSession Json?
createdOn DateTime @default(now()) @db.Timestamp(3) lastLoggedOn DateTime?
invitedUsers InvitedUsers[] createdOn DateTime @default(now()) @db.Timestamp(3)
shortcodes Shortcode[] invitedUsers InvitedUsers[]
shortcodes Shortcode[]
personalAccessTokens PersonalAccessToken[]
} }
model Account { model Account {
@@ -218,3 +220,14 @@ model InfraConfig {
createdOn DateTime @default(now()) @db.Timestamp(3) createdOn DateTime @default(now()) @db.Timestamp(3)
updatedOn DateTime @updatedAt @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)
}

View File

@@ -0,0 +1,107 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
HttpStatus,
Param,
ParseIntPipe,
Post,
Query,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { AccessTokenService } from './access-token.service';
import { CreateAccessTokenDto } from './dto/create-access-token.dto';
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
import * as E from 'fp-ts/Either';
import { throwHTTPErr } from 'src/utils';
import { GqlUser } from 'src/decorators/gql-user.decorator';
import { AuthUser } from 'src/types/AuthUser';
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
import { PATAuthGuard } from 'src/guards/rest-pat-auth.guard';
import { AccessTokenInterceptor } from 'src/interceptors/access-token.interceptor';
import { TeamEnvironmentsService } from 'src/team-environments/team-environments.service';
import { TeamCollectionService } from 'src/team-collection/team-collection.service';
import { ACCESS_TOKENS_INVALID_DATA_ID } from 'src/errors';
import { createCLIErrorResponse } from './helper';
@UseGuards(ThrottlerBehindProxyGuard)
@Controller({ path: 'access-tokens', version: '1' })
export class AccessTokenController {
constructor(
private readonly accessTokenService: AccessTokenService,
private readonly teamCollectionService: TeamCollectionService,
private readonly teamEnvironmentsService: TeamEnvironmentsService,
) {}
@Post('create')
@UseGuards(JwtAuthGuard)
async createPAT(
@GqlUser() user: AuthUser,
@Body() createAccessTokenDto: CreateAccessTokenDto,
) {
const result = await this.accessTokenService.createPAT(
createAccessTokenDto,
user,
);
if (E.isLeft(result)) throwHTTPErr(result.left);
return result.right;
}
@Delete('revoke')
@UseGuards(JwtAuthGuard)
async deletePAT(@Query('id') id: string) {
const result = await this.accessTokenService.deletePAT(id);
if (E.isLeft(result)) throwHTTPErr(result.left);
return result.right;
}
@Get('list')
@UseGuards(JwtAuthGuard)
async listAllUserPAT(
@GqlUser() user: AuthUser,
@Query('offset', ParseIntPipe) offset: number,
@Query('limit', ParseIntPipe) limit: number,
) {
return await this.accessTokenService.listAllUserPAT(
user.uid,
offset,
limit,
);
}
@Get('collection/:id')
@UseGuards(PATAuthGuard)
@UseInterceptors(AccessTokenInterceptor)
async fetchCollection(@GqlUser() user: AuthUser, @Param('id') id: string) {
const res = await this.teamCollectionService.getCollectionForCLI(
id,
user.uid,
);
if (E.isLeft(res))
throw new BadRequestException(
createCLIErrorResponse(ACCESS_TOKENS_INVALID_DATA_ID),
);
return res.right;
}
@Get('environment/:id')
@UseGuards(PATAuthGuard)
@UseInterceptors(AccessTokenInterceptor)
async fetchEnvironment(@GqlUser() user: AuthUser, @Param('id') id: string) {
const res = await this.teamEnvironmentsService.getTeamEnvironmentForCLI(
id,
user.uid,
);
if (E.isLeft(res))
throw new BadRequestException(
createCLIErrorResponse(ACCESS_TOKENS_INVALID_DATA_ID),
);
return res.right;
}
}

View File

@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { AccessTokenController } from './access-token.controller';
import { PrismaModule } from 'src/prisma/prisma.module';
import { AccessTokenService } from './access-token.service';
import { TeamCollectionModule } from 'src/team-collection/team-collection.module';
import { TeamEnvironmentsModule } from 'src/team-environments/team-environments.module';
import { TeamModule } from 'src/team/team.module';
@Module({
imports: [
PrismaModule,
TeamCollectionModule,
TeamEnvironmentsModule,
TeamModule,
],
controllers: [AccessTokenController],
providers: [AccessTokenService],
exports: [AccessTokenService],
})
export class AccessTokenModule {}

View File

@@ -0,0 +1,195 @@
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);
});
});
});

View File

@@ -0,0 +1,203 @@
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);
}
}
}

View File

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

View File

@@ -0,0 +1,17 @@
import { AccessToken } from 'src/types/AccessToken';
// Response type of PAT creation method
export type CreateAccessTokenResponse = {
token: string;
info: AccessToken;
};
// Response type of any error in PAT module
export type CLIErrorResponse = {
reason: string;
};
// Return a CLIErrorResponse object
export function createCLIErrorResponse(reason: string): CLIErrorResponse {
return { reason };
}

View File

@@ -74,6 +74,7 @@ const dbAdminUsers: DbUser[] = [
refreshToken: 'refreshToken', refreshToken: 'refreshToken',
currentRESTSession: '', currentRESTSession: '',
currentGQLSession: '', currentGQLSession: '',
lastLoggedOn: new Date(),
createdOn: new Date(), createdOn: new Date(),
}, },
{ {
@@ -85,20 +86,10 @@ const dbAdminUsers: DbUser[] = [
refreshToken: 'refreshToken', refreshToken: 'refreshToken',
currentRESTSession: '', currentRESTSession: '',
currentGQLSession: '', currentGQLSession: '',
lastLoggedOn: new Date(),
createdOn: 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('AdminService', () => {
describe('fetchInvitedUsers', () => { describe('fetchInvitedUsers', () => {

View File

@@ -27,6 +27,7 @@ import { MailerModule } from './mailer/mailer.module';
import { PosthogModule } from './posthog/posthog.module'; import { PosthogModule } from './posthog/posthog.module';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { AccessTokenModule } from './access-token/access-token.module';
@Module({ @Module({
imports: [ imports: [
@@ -102,6 +103,7 @@ import { HealthModule } from './health/health.module';
PosthogModule, PosthogModule,
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
HealthModule, HealthModule,
AccessTokenModule,
], ],
providers: [GQLComplexityPlugin], providers: [GQLComplexityPlugin],
controllers: [AppController], controllers: [AppController],

View File

@@ -7,6 +7,7 @@ import {
Request, Request,
Res, Res,
UseGuards, UseGuards,
UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { SignInMagicDto } from './dto/signin-magic.dto'; import { SignInMagicDto } from './dto/signin-magic.dto';
@@ -27,6 +28,7 @@ import { SkipThrottle } from '@nestjs/throttler';
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors'; import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { throwHTTPErr } from 'src/utils'; import { throwHTTPErr } from 'src/utils';
import { UserLastLoginInterceptor } from 'src/interceptors/user-last-login.interceptor';
@UseGuards(ThrottlerBehindProxyGuard) @UseGuards(ThrottlerBehindProxyGuard)
@Controller({ path: 'auth', version: '1' }) @Controller({ path: 'auth', version: '1' })
@@ -110,6 +112,7 @@ export class AuthController {
@Get('google/callback') @Get('google/callback')
@SkipThrottle() @SkipThrottle()
@UseGuards(GoogleSSOGuard) @UseGuards(GoogleSSOGuard)
@UseInterceptors(UserLastLoginInterceptor)
async googleAuthRedirect(@Request() req, @Res() res) { async googleAuthRedirect(@Request() req, @Res() res) {
const authTokens = await this.authService.generateAuthTokens(req.user.uid); const authTokens = await this.authService.generateAuthTokens(req.user.uid);
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left); if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
@@ -135,6 +138,7 @@ export class AuthController {
@Get('github/callback') @Get('github/callback')
@SkipThrottle() @SkipThrottle()
@UseGuards(GithubSSOGuard) @UseGuards(GithubSSOGuard)
@UseInterceptors(UserLastLoginInterceptor)
async githubAuthRedirect(@Request() req, @Res() res) { async githubAuthRedirect(@Request() req, @Res() res) {
const authTokens = await this.authService.generateAuthTokens(req.user.uid); const authTokens = await this.authService.generateAuthTokens(req.user.uid);
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left); if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
@@ -160,6 +164,7 @@ export class AuthController {
@Get('microsoft/callback') @Get('microsoft/callback')
@SkipThrottle() @SkipThrottle()
@UseGuards(MicrosoftSSOGuard) @UseGuards(MicrosoftSSOGuard)
@UseInterceptors(UserLastLoginInterceptor)
async microsoftAuthRedirect(@Request() req, @Res() res) { async microsoftAuthRedirect(@Request() req, @Res() res) {
const authTokens = await this.authService.generateAuthTokens(req.user.uid); const authTokens = await this.authService.generateAuthTokens(req.user.uid);
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left); if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);

View File

@@ -51,6 +51,7 @@ const user: AuthUser = {
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
isAdmin: false, isAdmin: false,
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
lastLoggedOn: currentTime,
createdOn: currentTime, createdOn: currentTime,
currentGQLSession: {}, currentGQLSession: {},
currentRESTSession: {}, currentRESTSession: {},
@@ -172,9 +173,11 @@ describe('verifyMagicLinkTokens', () => {
// generateAuthTokens // generateAuthTokens
mockJWT.sign.mockReturnValue(user.refreshToken); mockJWT.sign.mockReturnValue(user.refreshToken);
// UpdateUserRefreshToken // UpdateUserRefreshToken
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user)); mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user));
// deletePasswordlessVerificationToken // deletePasswordlessVerificationToken
mockPrisma.verificationToken.delete.mockResolvedValueOnce(passwordlessData); mockPrisma.verificationToken.delete.mockResolvedValueOnce(passwordlessData);
// usersService.updateUserLastLoggedOn
mockUser.updateUserLastLoggedOn.mockResolvedValue(E.right(true));
const result = await authService.verifyMagicLinkTokens(magicLinkVerify); const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
expect(result).toEqualRight({ expect(result).toEqualRight({
@@ -197,9 +200,11 @@ describe('verifyMagicLinkTokens', () => {
// generateAuthTokens // generateAuthTokens
mockJWT.sign.mockReturnValue(user.refreshToken); mockJWT.sign.mockReturnValue(user.refreshToken);
// UpdateUserRefreshToken // UpdateUserRefreshToken
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user)); mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user));
// deletePasswordlessVerificationToken // deletePasswordlessVerificationToken
mockPrisma.verificationToken.delete.mockResolvedValueOnce(passwordlessData); mockPrisma.verificationToken.delete.mockResolvedValueOnce(passwordlessData);
// usersService.updateUserLastLoggedOn
mockUser.updateUserLastLoggedOn.mockResolvedValue(E.right(true));
const result = await authService.verifyMagicLinkTokens(magicLinkVerify); const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
expect(result).toEqualRight({ expect(result).toEqualRight({
@@ -239,7 +244,7 @@ describe('verifyMagicLinkTokens', () => {
// generateAuthTokens // generateAuthTokens
mockJWT.sign.mockReturnValue(user.refreshToken); mockJWT.sign.mockReturnValue(user.refreshToken);
// UpdateUserRefreshToken // UpdateUserRefreshToken
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce( mockUser.updateUserRefreshToken.mockResolvedValueOnce(
E.left(USER_NOT_FOUND), E.left(USER_NOT_FOUND),
); );
@@ -264,7 +269,7 @@ describe('verifyMagicLinkTokens', () => {
// generateAuthTokens // generateAuthTokens
mockJWT.sign.mockReturnValue(user.refreshToken); mockJWT.sign.mockReturnValue(user.refreshToken);
// UpdateUserRefreshToken // UpdateUserRefreshToken
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user)); mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user));
// deletePasswordlessVerificationToken // deletePasswordlessVerificationToken
mockPrisma.verificationToken.delete.mockRejectedValueOnce('RecordNotFound'); mockPrisma.verificationToken.delete.mockRejectedValueOnce('RecordNotFound');
@@ -280,7 +285,7 @@ describe('generateAuthTokens', () => {
test('Should successfully generate tokens with valid inputs', async () => { test('Should successfully generate tokens with valid inputs', async () => {
mockJWT.sign.mockReturnValue(user.refreshToken); mockJWT.sign.mockReturnValue(user.refreshToken);
// UpdateUserRefreshToken // UpdateUserRefreshToken
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user)); mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user));
const result = await authService.generateAuthTokens(user.uid); const result = await authService.generateAuthTokens(user.uid);
expect(result).toEqualRight({ expect(result).toEqualRight({
@@ -292,7 +297,7 @@ describe('generateAuthTokens', () => {
test('Should throw USER_NOT_FOUND when updating refresh tokens fails', async () => { test('Should throw USER_NOT_FOUND when updating refresh tokens fails', async () => {
mockJWT.sign.mockReturnValue(user.refreshToken); mockJWT.sign.mockReturnValue(user.refreshToken);
// UpdateUserRefreshToken // UpdateUserRefreshToken
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce( mockUser.updateUserRefreshToken.mockResolvedValueOnce(
E.left(USER_NOT_FOUND), E.left(USER_NOT_FOUND),
); );
@@ -319,7 +324,7 @@ describe('refreshAuthTokens', () => {
// generateAuthTokens // generateAuthTokens
mockJWT.sign.mockReturnValue(user.refreshToken); mockJWT.sign.mockReturnValue(user.refreshToken);
// UpdateUserRefreshToken // UpdateUserRefreshToken
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce( mockUser.updateUserRefreshToken.mockResolvedValueOnce(
E.left(USER_NOT_FOUND), E.left(USER_NOT_FOUND),
); );
@@ -348,7 +353,7 @@ describe('refreshAuthTokens', () => {
// generateAuthTokens // generateAuthTokens
mockJWT.sign.mockReturnValue('sdhjcbjsdhcbshjdcb'); mockJWT.sign.mockReturnValue('sdhjcbjsdhcbshjdcb');
// UpdateUserRefreshToken // UpdateUserRefreshToken
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce( mockUser.updateUserRefreshToken.mockResolvedValueOnce(
E.right({ E.right({
...user, ...user,
refreshToken: 'sdhjcbjsdhcbshjdcb', refreshToken: 'sdhjcbjsdhcbshjdcb',

View File

@@ -112,7 +112,7 @@ export class AuthService {
const refreshTokenHash = await argon2.hash(refreshToken); const refreshTokenHash = await argon2.hash(refreshToken);
const updatedUser = await this.usersService.UpdateUserRefreshToken( const updatedUser = await this.usersService.updateUserRefreshToken(
refreshTokenHash, refreshTokenHash,
userUid, userUid,
); );
@@ -320,6 +320,8 @@ export class AuthService {
statusCode: HttpStatus.NOT_FOUND, statusCode: HttpStatus.NOT_FOUND,
}); });
this.usersService.updateUserLastLoggedOn(passwordlessTokens.value.userUid);
return E.right(tokens.right); return E.right(tokens.right);
} }

View File

@@ -761,3 +761,39 @@ export const POSTHOG_CLIENT_NOT_INITIALIZED = 'posthog/client_not_initialized';
* Inputs supplied are invalid * Inputs supplied are invalid
*/ */
export const INVALID_PARAMS = 'invalid_parameters' as const; 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';

View File

@@ -0,0 +1,48 @@
import {
BadRequestException,
CanActivate,
ExecutionContext,
Injectable,
} from '@nestjs/common';
import { Request } from 'express';
import { AccessTokenService } from 'src/access-token/access-token.service';
import * as E from 'fp-ts/Either';
import { DateTime } from 'luxon';
import { ACCESS_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;
}
}

View File

@@ -0,0 +1,34 @@
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;
}),
);
}
}

View File

@@ -0,0 +1,26 @@
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);
}),
);
}
}

View File

@@ -48,6 +48,7 @@ const user: AuthUser = {
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
isAdmin: false, isAdmin: false,
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
lastLoggedOn: createdOn,
createdOn: createdOn, createdOn: createdOn,
currentGQLSession: {}, currentGQLSession: {},
currentRESTSession: {}, currentRESTSession: {},

View File

@@ -1,3 +1,5 @@
import { TeamRequest } from '@prisma/client';
// Type of data returned from the query to obtain all search results // Type of data returned from the query to obtain all search results
export type SearchQueryReturnType = { export type SearchQueryReturnType = {
id: string; id: string;
@@ -12,3 +14,12 @@ export type ParentTreeQueryReturnType = {
parentID: string; parentID: string;
title: string; title: string;
}; };
// Type of data returned from the query to fetch collection details from CLI
export type GetCollectionResponse = {
id: string;
data: string | null;
title: string;
parentID: string | null;
folders: GetCollectionResponse[];
requests: TeamRequest[];
};

View File

@@ -12,6 +12,7 @@ import {
TEAM_COL_REORDERING_FAILED, TEAM_COL_REORDERING_FAILED,
TEAM_COL_SAME_NEXT_COLL, TEAM_COL_SAME_NEXT_COLL,
TEAM_INVALID_COLL_ID, TEAM_INVALID_COLL_ID,
TEAM_MEMBER_NOT_FOUND,
TEAM_NOT_OWNER, TEAM_NOT_OWNER,
} from 'src/errors'; } from 'src/errors';
import { PrismaService } from 'src/prisma/prisma.service'; import { PrismaService } from 'src/prisma/prisma.service';
@@ -19,15 +20,18 @@ import { PubSubService } from 'src/pubsub/pubsub.service';
import { AuthUser } from 'src/types/AuthUser'; import { AuthUser } from 'src/types/AuthUser';
import { TeamCollectionService } from './team-collection.service'; import { TeamCollectionService } from './team-collection.service';
import { TeamCollection } from './team-collection.model'; import { TeamCollection } from './team-collection.model';
import { TeamService } from 'src/team/team.service';
const mockPrisma = mockDeep<PrismaService>(); const mockPrisma = mockDeep<PrismaService>();
const mockPubSub = mockDeep<PubSubService>(); const mockPubSub = mockDeep<PubSubService>();
const mockTeamService = mockDeep<TeamService>();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
const teamCollectionService = new TeamCollectionService( const teamCollectionService = new TeamCollectionService(
mockPrisma, mockPrisma,
mockPubSub as any, mockPubSub as any,
mockTeamService,
); );
const currentTime = new Date(); const currentTime = new Date();
@@ -39,6 +43,7 @@ const user: AuthUser = {
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
isAdmin: false, isAdmin: false,
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
lastLoggedOn: currentTime,
createdOn: currentTime, createdOn: currentTime,
currentGQLSession: {}, currentGQLSession: {},
currentRESTSession: {}, currentRESTSession: {},
@@ -1738,3 +1743,63 @@ describe('updateTeamCollection', () => {
}); });
//ToDo: write test cases for exportCollectionsToJSON //ToDo: write test cases for exportCollectionsToJSON
describe('getCollectionForCLI', () => {
test('should throw TEAM_COLL_NOT_FOUND if collectionID is invalid', async () => {
mockPrisma.teamCollection.findUniqueOrThrow.mockRejectedValueOnce(
'NotFoundError',
);
const result = await teamCollectionService.getCollectionForCLI(
'invalidID',
user.uid,
);
expect(result).toEqualLeft(TEAM_COLL_NOT_FOUND);
});
test('should throw TEAM_MEMBER_NOT_FOUND if user not in same team', async () => {
mockPrisma.teamCollection.findUniqueOrThrow.mockResolvedValueOnce(
rootTeamCollection,
);
mockTeamService.getTeamMember.mockResolvedValue(null);
const result = await teamCollectionService.getCollectionForCLI(
rootTeamCollection.id,
user.uid,
);
expect(result).toEqualLeft(TEAM_MEMBER_NOT_FOUND);
});
// test('should return the TeamCollection data for CLI', async () => {
// mockPrisma.teamCollection.findUniqueOrThrow.mockResolvedValueOnce(
// rootTeamCollection,
// );
// mockTeamService.getTeamMember.mockResolvedValue({
// membershipID: 'sdc3sfdv',
// userUid: user.uid,
// role: TeamMemberRole.OWNER,
// });
// const result = await teamCollectionService.getCollectionForCLI(
// rootTeamCollection.id,
// user.uid,
// );
// expect(result).toEqualRight({
// id: rootTeamCollection.id,
// data: JSON.stringify(rootTeamCollection.data),
// title: rootTeamCollection.title,
// parentID: rootTeamCollection.parentID,
// folders: [
// {
// id: childTeamCollection.id,
// data: JSON.stringify(childTeamCollection.data),
// title: childTeamCollection.title,
// parentID: childTeamCollection.parentID,
// folders: [],
// requests: [],
// },
// ],
// requests: [],
// });
// });
});

View File

@@ -18,23 +18,34 @@ import {
TEAM_COL_SEARCH_FAILED, TEAM_COL_SEARCH_FAILED,
TEAM_REQ_PARENT_TREE_GEN_FAILED, TEAM_REQ_PARENT_TREE_GEN_FAILED,
TEAM_COLL_PARENT_TREE_GEN_FAILED, TEAM_COLL_PARENT_TREE_GEN_FAILED,
TEAM_MEMBER_NOT_FOUND,
} from '../errors'; } from '../errors';
import { PubSubService } from '../pubsub/pubsub.service'; import { PubSubService } from '../pubsub/pubsub.service';
import { escapeSqlLikeString, isValidLength } from 'src/utils'; import { escapeSqlLikeString, isValidLength } from 'src/utils';
import * as E from 'fp-ts/Either'; import * as E from 'fp-ts/Either';
import * as O from 'fp-ts/Option'; import * as O from 'fp-ts/Option';
import { Prisma, TeamCollection as DBTeamCollection } from '@prisma/client'; import {
Prisma,
TeamCollection as DBTeamCollection,
TeamRequest,
} from '@prisma/client';
import { CollectionFolder } from 'src/types/CollectionFolder'; import { CollectionFolder } from 'src/types/CollectionFolder';
import { stringToJson } from 'src/utils'; import { stringToJson } from 'src/utils';
import { CollectionSearchNode } from 'src/types/CollectionSearchNode'; import { CollectionSearchNode } from 'src/types/CollectionSearchNode';
import { ParentTreeQueryReturnType, SearchQueryReturnType } from './helper'; import {
GetCollectionResponse,
ParentTreeQueryReturnType,
SearchQueryReturnType,
} from './helper';
import { RESTError } from 'src/types/RESTError'; import { RESTError } from 'src/types/RESTError';
import { TeamService } from 'src/team/team.service';
@Injectable() @Injectable()
export class TeamCollectionService { export class TeamCollectionService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly pubsub: PubSubService, private readonly pubsub: PubSubService,
private readonly teamService: TeamService,
) {} ) {}
TITLE_LENGTH = 3; TITLE_LENGTH = 3;
@@ -1344,4 +1355,95 @@ export class TeamCollectionService {
return E.left(TEAM_REQ_PARENT_TREE_GEN_FAILED); 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);
}
}
} }

View File

@@ -6,19 +6,24 @@ import {
JSON_INVALID, JSON_INVALID,
TEAM_ENVIRONMENT_NOT_FOUND, TEAM_ENVIRONMENT_NOT_FOUND,
TEAM_ENVIRONMENT_SHORT_NAME, TEAM_ENVIRONMENT_SHORT_NAME,
TEAM_MEMBER_NOT_FOUND,
} from 'src/errors'; } from 'src/errors';
import { TeamService } from 'src/team/team.service';
import { TeamMemberRole } from 'src/team/team.model';
const mockPrisma = mockDeep<PrismaService>(); const mockPrisma = mockDeep<PrismaService>();
const mockPubSub = { const mockPubSub = {
publish: jest.fn().mockResolvedValue(null), publish: jest.fn().mockResolvedValue(null),
}; };
const mockTeamService = mockDeep<TeamService>();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
const teamEnvironmentsService = new TeamEnvironmentsService( const teamEnvironmentsService = new TeamEnvironmentsService(
mockPrisma, mockPrisma,
mockPubSub as any, mockPubSub as any,
mockTeamService,
); );
const teamEnvironment = { const teamEnvironment = {
@@ -380,4 +385,47 @@ describe('TeamEnvironmentsService', () => {
expect(result).toEqual(0); expect(result).toEqual(0);
}); });
}); });
describe('getTeamEnvironmentForCLI', () => {
test('should successfully return a TeamEnvironment with valid ID', async () => {
mockPrisma.teamEnvironment.findFirstOrThrow.mockResolvedValueOnce(
teamEnvironment,
);
mockTeamService.getTeamMember.mockResolvedValue({
membershipID: 'sdc3sfdv',
userUid: '123454',
role: TeamMemberRole.OWNER,
});
const result = await teamEnvironmentsService.getTeamEnvironmentForCLI(
teamEnvironment.id,
'123454',
);
expect(result).toEqualRight(teamEnvironment);
});
test('should throw TEAM_ENVIRONMENT_NOT_FOUND with invalid ID', async () => {
mockPrisma.teamEnvironment.findFirstOrThrow.mockRejectedValueOnce(
'RejectOnNotFound',
);
const result = await teamEnvironmentsService.getTeamEnvironment(
teamEnvironment.id,
);
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
});
test('should throw TEAM_MEMBER_NOT_FOUND if user not in same team', async () => {
mockPrisma.teamEnvironment.findFirstOrThrow.mockResolvedValueOnce(
teamEnvironment,
);
mockTeamService.getTeamMember.mockResolvedValue(null);
const result = await teamEnvironmentsService.getTeamEnvironmentForCLI(
teamEnvironment.id,
'333',
);
expect(result).toEqualLeft(TEAM_MEMBER_NOT_FOUND);
});
});
}); });

View File

@@ -6,14 +6,17 @@ import { TeamEnvironment } from './team-environments.model';
import { import {
TEAM_ENVIRONMENT_NOT_FOUND, TEAM_ENVIRONMENT_NOT_FOUND,
TEAM_ENVIRONMENT_SHORT_NAME, TEAM_ENVIRONMENT_SHORT_NAME,
TEAM_MEMBER_NOT_FOUND,
} from 'src/errors'; } from 'src/errors';
import * as E from 'fp-ts/Either'; import * as E from 'fp-ts/Either';
import { isValidLength } from 'src/utils'; import { isValidLength } from 'src/utils';
import { TeamService } from 'src/team/team.service';
@Injectable() @Injectable()
export class TeamEnvironmentsService { export class TeamEnvironmentsService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly pubsub: PubSubService, private readonly pubsub: PubSubService,
private readonly teamService: TeamService,
) {} ) {}
TITLE_LENGTH = 3; TITLE_LENGTH = 3;
@@ -242,4 +245,30 @@ export class TeamEnvironmentsService {
}); });
return envCount; return envCount;
} }
/**
* Get details of a TeamEnvironment for CLI.
*
* @param id TeamEnvironment ID
* @param userUid User UID
* @returns Either of a TeamEnvironment or error message
*/
async getTeamEnvironmentForCLI(id: string, userUid: string) {
try {
const teamEnvironment =
await this.prisma.teamEnvironment.findFirstOrThrow({
where: { id },
});
const teamMember = await this.teamService.getTeamMember(
teamEnvironment.teamID,
userUid,
);
if (!teamMember) return E.left(TEAM_MEMBER_NOT_FOUND);
return E.right(teamEnvironment);
} catch (error) {
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
}
}
} }

View File

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

View File

@@ -5,6 +5,6 @@ import { HttpStatus } from '@nestjs/common';
** Since its REST we need to return the HTTP status code along with the error message ** Since its REST we need to return the HTTP status code along with the error message
*/ */
export type RESTError = { export type RESTError = {
message: string; message: string | Record<string, string>;
statusCode: HttpStatus; statusCode: HttpStatus;
}; };

View File

@@ -38,6 +38,7 @@ const user: AuthUser = {
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
isAdmin: false, isAdmin: false,
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
lastLoggedOn: currentTime,
createdOn: currentTime, createdOn: currentTime,
currentGQLSession: {}, currentGQLSession: {},
currentRESTSession: {}, currentRESTSession: {},

View File

@@ -41,6 +41,7 @@ const user: AuthUser = {
photoURL: 'https://example.com/photo.png', photoURL: 'https://example.com/photo.png',
isAdmin: false, isAdmin: false,
refreshToken: null, refreshToken: null,
lastLoggedOn: new Date(),
createdOn: new Date(), createdOn: new Date(),
currentGQLSession: null, currentGQLSession: null,
currentRESTSession: null, currentRESTSession: null,

View File

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

View File

@@ -30,6 +30,12 @@ export class User {
}) })
isAdmin: boolean; isAdmin: boolean;
@Field({
nullable: true,
description: 'Date when the user last logged in',
})
lastLoggedOn: Date;
@Field({ @Field({
description: 'Date when the user account was created', description: 'Date when the user account was created',
}) })

View File

@@ -42,6 +42,7 @@ const user: AuthUser = {
currentRESTSession: {}, currentRESTSession: {},
currentGQLSession: {}, currentGQLSession: {},
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
lastLoggedOn: currentTime,
createdOn: currentTime, createdOn: currentTime,
}; };
@@ -54,6 +55,7 @@ const adminUser: AuthUser = {
currentRESTSession: {}, currentRESTSession: {},
currentGQLSession: {}, currentGQLSession: {},
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
lastLoggedOn: currentTime,
createdOn: currentTime, createdOn: currentTime,
}; };
@@ -67,6 +69,7 @@ const users: AuthUser[] = [
currentRESTSession: {}, currentRESTSession: {},
currentGQLSession: {}, currentGQLSession: {},
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
lastLoggedOn: currentTime,
createdOn: currentTime, createdOn: currentTime,
}, },
{ {
@@ -78,6 +81,7 @@ const users: AuthUser[] = [
currentRESTSession: {}, currentRESTSession: {},
currentGQLSession: {}, currentGQLSession: {},
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
lastLoggedOn: currentTime,
createdOn: currentTime, createdOn: currentTime,
}, },
{ {
@@ -89,6 +93,7 @@ const users: AuthUser[] = [
currentRESTSession: {}, currentRESTSession: {},
currentGQLSession: {}, currentGQLSession: {},
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
lastLoggedOn: currentTime,
createdOn: currentTime, createdOn: currentTime,
}, },
]; ];
@@ -103,6 +108,7 @@ const adminUsers: AuthUser[] = [
currentRESTSession: {}, currentRESTSession: {},
currentGQLSession: {}, currentGQLSession: {},
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
lastLoggedOn: currentTime,
createdOn: currentTime, createdOn: currentTime,
}, },
{ {
@@ -114,6 +120,7 @@ const adminUsers: AuthUser[] = [
currentRESTSession: {}, currentRESTSession: {},
currentGQLSession: {}, currentGQLSession: {},
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
lastLoggedOn: currentTime,
createdOn: currentTime, createdOn: currentTime,
}, },
{ {
@@ -125,6 +132,7 @@ const adminUsers: AuthUser[] = [
currentRESTSession: {}, currentRESTSession: {},
currentGQLSession: {}, currentGQLSession: {},
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
lastLoggedOn: currentTime,
createdOn: currentTime, createdOn: currentTime,
}, },
]; ];
@@ -495,6 +503,26 @@ describe('UserService', () => {
}); });
}); });
describe('updateUserLastLoggedOn', () => {
test('should resolve right and update user last logged on', async () => {
const currentTime = new Date();
mockPrisma.user.update.mockResolvedValueOnce({
...user,
lastLoggedOn: currentTime,
});
const result = await userService.updateUserLastLoggedOn(user.uid);
expect(result).toEqualRight(true);
});
test('should resolve left and error when invalid user uid is passed', async () => {
mockPrisma.user.update.mockRejectedValueOnce('NotFoundError');
const result = await userService.updateUserLastLoggedOn('invalidUserUid');
expect(result).toEqualLeft(USER_NOT_FOUND);
});
});
describe('fetchAllUsers', () => { describe('fetchAllUsers', () => {
test('should resolve right and return 20 users when cursor is null', async () => { test('should resolve right and return 20 users when cursor is null', async () => {
mockPrisma.user.findMany.mockResolvedValueOnce(users); mockPrisma.user.findMany.mockResolvedValueOnce(users);

View File

@@ -114,7 +114,7 @@ export class UserService {
* @param userUid User uid * @param userUid User uid
* @returns Either of User with updated refreshToken * @returns Either of User with updated refreshToken
*/ */
async UpdateUserRefreshToken(refreshTokenHash: string, userUid: string) { async updateUserRefreshToken(refreshTokenHash: string, userUid: string) {
try { try {
const user = await this.prisma.user.update({ const user = await this.prisma.user.update({
where: { where: {
@@ -174,6 +174,7 @@ export class UserService {
displayName: userDisplayName, displayName: userDisplayName,
email: profile.emails[0].value, email: profile.emails[0].value,
photoURL: userPhotoURL, photoURL: userPhotoURL,
lastLoggedOn: new Date(),
providerAccounts: { providerAccounts: {
create: { create: {
provider: profile.provider, provider: profile.provider,
@@ -221,7 +222,7 @@ export class UserService {
} }
/** /**
* Update User displayName and photoURL * Update User displayName and photoURL when logged in via a SSO provider
* *
* @param user User object * @param user User object
* @param profile Data received from SSO provider on the users account * @param profile Data received from SSO provider on the users account
@@ -236,6 +237,7 @@ export class UserService {
data: { data: {
displayName: !profile.displayName ? null : profile.displayName, displayName: !profile.displayName ? null : profile.displayName,
photoURL: !profile.photos ? null : profile.photos[0].value, photoURL: !profile.photos ? null : profile.photos[0].value,
lastLoggedOn: new Date(),
}, },
}); });
return E.right(updatedUser); return E.right(updatedUser);
@@ -289,7 +291,7 @@ export class UserService {
} }
/** /**
* Update a user's data * Update a user's displayName
* @param userUID User UID * @param userUID User UID
* @param displayName User's displayName * @param displayName User's displayName
* @returns a Either of User or error * @returns a Either of User or error
@@ -316,6 +318,22 @@ 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 * Validate and parse currentRESTSession and currentGQLSession
* @param sessionData string of the session * @param sessionData string of the session