From c4e1f02abf53659347532c05872754b585581016 Mon Sep 17 00:00:00 2001 From: Mir Arif Hasan Date: Wed, 29 Apr 2026 00:40:03 +0600 Subject: [PATCH] fix(backend): harden onboarding config endpoint (#6240) Co-authored-by: James George <25279263+jamesgeorge007@users.noreply.github.com> --- .../infra-config/infra-config.service.spec.ts | 157 ++++++++++++++++++ .../src/infra-config/infra-config.service.ts | 6 +- .../src/infra-config/onboarding.controller.ts | 18 ++ 3 files changed, 180 insertions(+), 1 deletion(-) diff --git a/packages/hoppscotch-backend/src/infra-config/infra-config.service.spec.ts b/packages/hoppscotch-backend/src/infra-config/infra-config.service.spec.ts index 64dc6ab9..bac79540 100644 --- a/packages/hoppscotch-backend/src/infra-config/infra-config.service.spec.ts +++ b/packages/hoppscotch-backend/src/infra-config/infra-config.service.spec.ts @@ -347,4 +347,161 @@ describe('InfraConfigService', () => { }); }); }); + + describe('getOnboardingConfig', () => { + const RECOVERY_TOKEN = 'valid-recovery-token-123'; + + const mockConfigs = [ + { + name: InfraConfigEnum.ONBOARDING_RECOVERY_TOKEN, + value: RECOVERY_TOKEN, + }, + { name: InfraConfigEnum.GOOGLE_CLIENT_ID, value: 'google-id' }, + { + name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS, + value: 'google', + }, + ]; + + it('should return config values when token is valid', async () => { + jest + .spyOn(infraConfigService, 'getMany') + .mockResolvedValueOnce(E.right(mockConfigs)); + + const result = + await infraConfigService.getOnboardingConfig(RECOVERY_TOKEN); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right[InfraConfigEnum.ONBOARDING_RECOVERY_TOKEN]).toBe( + RECOVERY_TOKEN, + ); + expect(result.right[InfraConfigEnum.GOOGLE_CLIENT_ID]).toBe( + 'google-id', + ); + } + }); + + it('should return null values when token does not match', async () => { + jest + .spyOn(infraConfigService, 'getMany') + .mockResolvedValueOnce(E.right(mockConfigs)); + + const result = + await infraConfigService.getOnboardingConfig('wrong-token'); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right[InfraConfigEnum.GOOGLE_CLIENT_ID]).toBeNull(); + } + }); + + it('should return null values when token is empty string', async () => { + jest + .spyOn(infraConfigService, 'getMany') + .mockResolvedValueOnce(E.right(mockConfigs)); + + const result = await infraConfigService.getOnboardingConfig(''); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right[InfraConfigEnum.GOOGLE_CLIENT_ID]).toBeNull(); + } + }); + + it('should return null values when token is whitespace only', async () => { + jest + .spyOn(infraConfigService, 'getMany') + .mockResolvedValueOnce(E.right(mockConfigs)); + + const result = await infraConfigService.getOnboardingConfig(' '); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right[InfraConfigEnum.GOOGLE_CLIENT_ID]).toBeNull(); + } + }); + + it('should return null values when token is null', async () => { + jest + .spyOn(infraConfigService, 'getMany') + .mockResolvedValueOnce(E.right(mockConfigs)); + + const result = await infraConfigService.getOnboardingConfig(null); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right[InfraConfigEnum.GOOGLE_CLIENT_ID]).toBeNull(); + } + }); + + it('should return null values when token is undefined', async () => { + jest + .spyOn(infraConfigService, 'getMany') + .mockResolvedValueOnce(E.right(mockConfigs)); + + const result = await infraConfigService.getOnboardingConfig(undefined); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right[InfraConfigEnum.GOOGLE_CLIENT_ID]).toBeNull(); + } + }); + + it('should return null values when token is an array', async () => { + jest + .spyOn(infraConfigService, 'getMany') + .mockResolvedValueOnce(E.right(mockConfigs)); + + // @ts-expect-error Testing invalid input + const result = await infraConfigService.getOnboardingConfig([]); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right[InfraConfigEnum.GOOGLE_CLIENT_ID]).toBeNull(); + } + }); + + it('should return null values when token is a number', async () => { + jest + .spyOn(infraConfigService, 'getMany') + .mockResolvedValueOnce(E.right(mockConfigs)); + + // @ts-expect-error Testing invalid input + const result = await infraConfigService.getOnboardingConfig(42); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right[InfraConfigEnum.GOOGLE_CLIENT_ID]).toBeNull(); + } + }); + + it('should return null values when ONBOARDING_RECOVERY_TOKEN is absent from configs', async () => { + const configsWithoutToken = mockConfigs.filter( + (c) => c.name !== InfraConfigEnum.ONBOARDING_RECOVERY_TOKEN, + ); + jest + .spyOn(infraConfigService, 'getMany') + .mockResolvedValueOnce(E.right(configsWithoutToken)); + + const result = + await infraConfigService.getOnboardingConfig(RECOVERY_TOKEN); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right[InfraConfigEnum.GOOGLE_CLIENT_ID]).toBeNull(); + } + }); + + it('should return left with error when getMany fails', async () => { + jest + .spyOn(infraConfigService, 'getMany') + .mockResolvedValueOnce(E.left(INFRA_CONFIG_NOT_FOUND)); + + const result = + await infraConfigService.getOnboardingConfig(RECOVERY_TOKEN); + + expect(result).toEqualLeft(INFRA_CONFIG_NOT_FOUND); + }); + }); }); diff --git a/packages/hoppscotch-backend/src/infra-config/infra-config.service.ts b/packages/hoppscotch-backend/src/infra-config/infra-config.service.ts index fd4ba93f..601d5ebb 100644 --- a/packages/hoppscotch-backend/src/infra-config/infra-config.service.ts +++ b/packages/hoppscotch-backend/src/infra-config/infra-config.service.ts @@ -608,7 +608,11 @@ export class InfraConfigService implements OnModuleInit, OnModuleDestroy { const recoveryToken = configs.right.find( (config) => config.name === InfraConfigEnum.ONBOARDING_RECOVERY_TOKEN, )?.value; - const tokenIsValid = token === recoveryToken; + + const tokenIsValid = + typeof token === 'string' && + token.trim().length > 0 && + token === recoveryToken; const onboardingConfig = configs.right.reduce((acc, config) => { acc[config.name] = tokenIsValid ? config.value : null; diff --git a/packages/hoppscotch-backend/src/infra-config/onboarding.controller.ts b/packages/hoppscotch-backend/src/infra-config/onboarding.controller.ts index f0c2ef63..404ed6b3 100644 --- a/packages/hoppscotch-backend/src/infra-config/onboarding.controller.ts +++ b/packages/hoppscotch-backend/src/infra-config/onboarding.controller.ts @@ -104,6 +104,24 @@ export class OnboardingController { type: GetOnboardingConfigResponse, }) async getOnboardingConfig(@Query('token') token: string) { + const onboardingStatus = + await this.infraConfigService.getOnboardingStatus(); + + if (E.isLeft(onboardingStatus)) + throwHTTPErr({ + message: onboardingStatus.left, + statusCode: HttpStatus.UNPROCESSABLE_ENTITY, + }); + + if ( + onboardingStatus.right.onboardingCompleted && + !onboardingStatus.right.canReRunOnboarding + ) + throwHTTPErr({ + message: ONBOARDING_CANNOT_BE_RERUN, + statusCode: HttpStatus.BAD_REQUEST, + }); + const onboardingConfig = await this.infraConfigService.getOnboardingConfig(token);