diff --git a/src/datasources/locking-api/entities/fingerprint-unsealed-data.entity.spec.ts b/src/datasources/locking-api/entities/fingerprint-unsealed-data.entity.spec.ts index 23319b9ea0..6a16591a9f 100644 --- a/src/datasources/locking-api/entities/fingerprint-unsealed-data.entity.spec.ts +++ b/src/datasources/locking-api/entities/fingerprint-unsealed-data.entity.spec.ts @@ -3,14 +3,17 @@ import { fingerprintIpInfoBuilder, fingerprintLocationSpoofingBuilder, fingerprintUnsealedDataBuilder, + fingerprintVpnBuilder, } from '@/datasources/locking-api/entities/__tests__/fingerprint-unsealed-data.entity.builder'; import { FingerprintIpDataSchema, FingerprintIpInfoSchema, FingerprintLocationSpoofingSchema, FingerprintUnsealedDataSchema, + FingerprintVpnSchema, } from '@/datasources/locking-api/entities/fingerprint-unsealed-data.entity'; import { ZodError } from 'zod'; +import { faker } from '@faker-js/faker'; describe('FingerprintUnsealedData schemas', () => { describe('FingerprintUnsealedDataEntity', () => { @@ -158,4 +161,60 @@ describe('FingerprintUnsealedData schemas', () => { ); }); }); + + describe('FingerprintVpnSchema', () => { + it('should validate a FingerprintVpnSchema', () => { + const fingerprintVpn = fingerprintVpnBuilder().build(); + + const result = FingerprintVpnSchema.safeParse(fingerprintVpn); + + expect(result.success).toBe(true); + }); + + it('should allow undefined data, defaulting to null', () => { + const fingerprintVpn = fingerprintVpnBuilder().build(); + + // @ts-expect-error - inferred types don't allow optional fields + delete fingerprintVpn.data; + + const result = FingerprintVpnSchema.safeParse(fingerprintVpn); + + expect(result.success && result.data.data).toBe(null); + }); + + it('should fallback to unknown for an invalid confidence value', () => { + const fingerprintVpn = { + data: { + ...fingerprintVpnBuilder().build().data, + confidence: faker.string.sample(), + }, + }; + + const result = FingerprintVpnSchema.safeParse(fingerprintVpn); + + expect(result.success && result.data.data?.confidence).toEqual('unknown'); + }); + + it('should not allow non-boolean result', () => { + const fingerprintVpn = fingerprintVpnBuilder().build(); + + // @ts-expect-error - value is expected to be a boolean + fingerprintVpn.data.result = 'true'; + + const result = + FingerprintLocationSpoofingSchema.safeParse(fingerprintVpn); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'boolean', + received: 'string', + path: ['data', 'result'], + message: 'Expected boolean, received string', + }, + ]), + ); + }); + }); }); diff --git a/src/datasources/locking-api/entities/fingerprint-unsealed-data.entity.ts b/src/datasources/locking-api/entities/fingerprint-unsealed-data.entity.ts index f1ef72d773..9cb5b4497f 100644 --- a/src/datasources/locking-api/entities/fingerprint-unsealed-data.entity.ts +++ b/src/datasources/locking-api/entities/fingerprint-unsealed-data.entity.ts @@ -28,11 +28,15 @@ export const FingerprintIpInfoSchema = z.object({ export type FingerprintIpInfo = z.infer; +export const FingerprintConfidenceLevels = ['low', 'medium', 'high'] as const; + export const FingerprintVpnSchema = z.object({ data: z .object({ result: z.boolean(), - confidence: z.enum(['low', 'medium', 'high']), + confidence: z + .enum([...FingerprintConfidenceLevels, 'unknown']) + .catch('unknown'), }) .nullish() .default(null), diff --git a/src/datasources/locking-api/fingerprint-api.service.spec.ts b/src/datasources/locking-api/fingerprint-api.service.spec.ts index d4e2b1658f..338b535b67 100644 --- a/src/datasources/locking-api/fingerprint-api.service.spec.ts +++ b/src/datasources/locking-api/fingerprint-api.service.spec.ts @@ -277,5 +277,27 @@ describe('FingerprintApiService', () => { isVpn: true, }); }); + + it('should return isVpn:false for an unknown confidence score', async () => { + const eligibilityRequest = eligibilityRequestBuilder().build(); + const unsealedData = fingerprintUnsealedDataBuilder() + .with('products', { + ipInfo: null, + locationSpoofing: fingerprintLocationSpoofingBuilder().build(), + vpn: fingerprintVpnBuilder() + .with('data', { result: true, confidence: 'unknown' }) + .build(), + }) + .build(); + (unsealEventsResponse as jest.Mock).mockResolvedValue(unsealedData); + + const result = await service.checkEligibility(eligibilityRequest); + + expect(result).toEqual({ + requestId: eligibilityRequest.requestId, + isAllowed: expect.anything(), + isVpn: false, + }); + }); }); }); diff --git a/src/datasources/locking-api/fingerprint-api.service.ts b/src/datasources/locking-api/fingerprint-api.service.ts index fbd7d1a8cb..7c3a788292 100644 --- a/src/datasources/locking-api/fingerprint-api.service.ts +++ b/src/datasources/locking-api/fingerprint-api.service.ts @@ -39,9 +39,7 @@ export class FingerprintApiService implements IIdentityApi { return { requestId, isAllowed: this.isAllowed(unsealedData), - isVpn: - unsealedData.products.vpn?.data?.result === true && - unsealedData.products.vpn?.data?.confidence === 'high', + isVpn: this.isVpn(unsealedData), }; } @@ -75,4 +73,11 @@ export class FingerprintApiService implements IIdentityApi { (code) => code === null || !this.nonEligibleCountryCodes.includes(code), ); } + + private isVpn(unsealedData: FingerprintUnsealedData): boolean { + return ( + unsealedData.products.vpn?.data?.result === true && + unsealedData.products.vpn?.data?.confidence === 'high' + ); + } }