diff --git a/.github/workflows/create-pr.yml b/.github/workflows/create-pr.yml index 2692bc1b..a87064b7 100644 --- a/.github/workflows/create-pr.yml +++ b/.github/workflows/create-pr.yml @@ -10,6 +10,6 @@ jobs: name: Create PR uses: fingerprintjs/dx-team-toolkit/.github/workflows/create-pr.yml@v1 with: - target_branch: ${{ github.event.release.prerelease && 'main' || 'test' }} + target_branch: ${{ github.event.release.prerelease && 'main' || 'rc' }} tag_name: ${{ github.event.release.tag_name }} prerelease: ${{ github.event.release.prerelease }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9b0361bb..a68a5994 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,7 @@ on: push: branches: - main - - test + - rc jobs: build-and-release: diff --git a/.releaserc b/.releaserc index b2aa8ef0..4f46d65b 100644 --- a/.releaserc +++ b/.releaserc @@ -2,7 +2,7 @@ "branches": [ "main", { - "name": "test", + "name": "rc", "prerelease": true } ], diff --git a/CHANGELOG.md b/CHANGELOG.md index 60814492..b6be95ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [1.2.4-rc.1](https://github.com/fingerprintjs/fingerprint-pro-azure-integration/compare/v1.2.3...v1.2.4-rc.1) (2023-12-13) + + +### Bug Fixes + +* improve endpoint creation ([dd84407](https://github.com/fingerprintjs/fingerprint-pro-azure-integration/commit/dd84407f10cb4a010c3cfc73b02ae41e95d086e5)) + ## [1.2.3](https://github.com/fingerprintjs/fingerprint-pro-azure-integration/compare/v1.2.2...v1.2.3) (2023-12-01) diff --git a/package.json b/package.json index 6d09e1ef..252675a8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fingerprintjs/fingerprint-pro-azure-integration", - "version": "1.2.3", + "version": "1.2.4-rc.1", "license": "MIT", "sideEffects": false, "private": true, diff --git a/proxy/handlers/agent.test.ts b/proxy/handlers/agent.test.ts index ea7d8c3e..3f021d2d 100644 --- a/proxy/handlers/agent.test.ts +++ b/proxy/handlers/agent.test.ts @@ -97,6 +97,20 @@ describe('Agent Endpoint', () => { ) }) + test('invalid apiKey, version and loaderVersion', async () => { + const req = mockRequestGet('https://fp.domain.com', 'fpjs/agent', { + apiKey: 'foo.bar/baz', + version: 'foo.bar1/baz', + loaderVersion: 'foo.bar2/baz', + }) + + await proxy(mockContext(req), req) + + const [url] = requestSpy.mock.calls[0] + + expect(url.origin).toEqual(`https://${origin}`) + }) + test('Browser cache set to an hour when original value is higher', async () => { const req = mockRequestGet('https://fp.domain.com', 'fpjs/agent', { apiKey: 'ujKG34hUYKLJKJ1F', diff --git a/proxy/handlers/ingress.test.ts b/proxy/handlers/ingress.test.ts index 34c38a2d..1a781a88 100644 --- a/proxy/handlers/ingress.test.ts +++ b/proxy/handlers/ingress.test.ts @@ -6,11 +6,49 @@ import { Socket } from 'net' import { CustomerVariableType } from '../../shared/customer-variables/types' import { EventEmitter } from 'events' import { mockContext, mockRequestGet, mockRequestPost } from '../../shared/test/azure' +import { Region } from '../utils/region' describe('Result Endpoint', function () { let requestSpy: jest.MockInstance - const origin: string = 'https://__ingress_api__' + const mockSuccessfulResponse = ({ + checkRequestUrl, + responseHeaders = {}, + }: { + checkRequestUrl: (url: URL) => void + responseHeaders?: Record + }) => { + requestSpy.mockImplementationOnce((...args: any[]): any => { + const [url, , callback] = args + + checkRequestUrl(url) + + const response = new EventEmitter() + const request = new EventEmitter() + + Object.assign(response, { + headers: responseHeaders, + }) + + Object.assign(request, { + end: jest.fn(), + write: jest.fn(), + }) + + callback(response) + + setTimeout(() => { + response.emit('data', Buffer.from('data', 'utf-8')) + response.emit('end') + }, 10) + + return request + }) + } + + const getOrigin = (region?: string) => (region ? `https://${region}.__ingress_api__` : 'https://__ingress_api__') + const defaultOrigin: string = getOrigin() const search: string = '?ii=fingerprint-pro-azure%2F__azure_function_version__%2Fingress' + const getSearchWithRegion = (region: string) => `?region=${region}&${search.replace('?', '')}` beforeAll(() => { jest.spyOn(ingress, 'handleIngress') @@ -29,7 +67,7 @@ describe('Result Endpoint', function () { const req = mockRequestGet('https://fp.domain.com', 'fpjs/resultId') requestSpy.mockImplementationOnce((...args) => { const [url, options] = args - expect(url.toString()).toBe(`${origin}/${search}`) + expect(url.toString()).toBe(`${defaultOrigin}/${search}`) options.agent = new Agent() return Reflect.construct(ClientRequest, args) }) @@ -46,13 +84,14 @@ describe('Result Endpoint', function () { [CustomerVariableType.PreSharedSecret]: 'secret', }) - const req = mockRequestGet('https://fp.domain.com', 'fpjs/resultId') - requestSpy.mockImplementationOnce((...args) => { - const [url, options] = args - expect(url.toString()).toBe(`${origin}/${search}`) - options.agent = new Agent() - return Reflect.construct(ClientRequest, args) + mockSuccessfulResponse({ + checkRequestUrl: (url) => { + expect(url.toString()).toBe(`${defaultOrigin}/${search}`) + }, }) + + const req = mockRequestGet('https://fp.domain.com', 'fpjs/resultId') + await proxy(mockContext(req), req) const [, options] = requestSpy.mock.calls[0] @@ -62,12 +101,13 @@ describe('Result Endpoint', function () { test('Cookies should include only _iidt', async () => { const req = mockRequestGet('https://fp.domain.com', 'fpjs/resultId') - requestSpy.mockImplementationOnce((...args) => { - const [url, options] = args - expect(url.toString()).toBe(`${origin}/${search}`) - options.agent = new Agent() - return Reflect.construct(ClientRequest, args) + + mockSuccessfulResponse({ + checkRequestUrl: (url) => { + expect(url.toString()).toBe(`${defaultOrigin}/${search}`) + }, }) + await proxy(mockContext(req), req) const [, options] = requestSpy.mock.calls[0] @@ -156,36 +196,16 @@ describe('Result Endpoint', function () { 'transfer-encoding': 'chunked', } - const resBody = 'data' - requestSpy.mockImplementationOnce((...args: any[]): any => { - const [url, , callback] = args - expect(url.toString()).toBe(`${origin}/${search}`) - - const response = new EventEmitter() - const request = new EventEmitter() - - Object.assign(request, { - end: jest.fn(), - write: jest.fn(), - }) - - Object.assign(response, { - headers: resHeaders, - }) - - callback(response) - - setTimeout(() => { - response.emit('data', Buffer.from(resBody, 'utf-8')) - response.emit('end') - }, 10) - - return request + mockSuccessfulResponse({ + checkRequestUrl: (url) => { + expect(url.toString()).toBe(`${defaultOrigin}/${search}`) + }, + responseHeaders: resHeaders, }) const ctx = mockContext(req) await proxy(ctx, req) - expect(ctx.res?.body.toString()).toBe(resBody) + expect(ctx.res?.body.toString()).toBe('data') expect(ctx.res?.headers).toEqual({ 'access-control-allow-credentials': 'true', 'access-control-expose-headers': 'Retry-After', @@ -257,17 +277,17 @@ describe('Result Endpoint', function () { test('HTTP GET without suffix', async () => { const req = mockRequestGet('https://fp.domain.com', 'fpjs/resultId') - requestSpy.mockImplementationOnce((...args) => { - const [url, options] = args - expect(url.toString()).toBe(`${origin}/${search}`) - options.agent = new Agent() - return Reflect.construct(ClientRequest, args) + mockSuccessfulResponse({ + checkRequestUrl: (url) => { + expect(url.toString()).toBe(`${defaultOrigin}/${search}`) + }, }) + await proxy(mockContext(req), req) expect(ingress.handleIngress).toHaveBeenCalledTimes(1) expect(https.request).toHaveBeenCalledWith( expect.objectContaining({ - origin, + origin: defaultOrigin, pathname: '/', search, }), @@ -279,17 +299,16 @@ describe('Result Endpoint', function () { test('HTTP GET with suffix', async () => { const req = mockRequestGet('https://fp.domain.com', 'fpjs/resultId/with/suffix') - requestSpy.mockImplementationOnce((...args) => { - const [url, options] = args - expect(url.toString()).toBe(`${origin}/with/suffix${search}`) - options.agent = new Agent() - return Reflect.construct(ClientRequest, args) + mockSuccessfulResponse({ + checkRequestUrl: (url) => { + expect(url.toString()).toBe(`${defaultOrigin}/with/suffix${search}`) + }, }) await proxy(mockContext(req), req) expect(ingress.handleIngress).toHaveBeenCalledTimes(1) expect(https.request).toHaveBeenCalledWith( expect.objectContaining({ - origin, + origin: defaultOrigin, pathname: '/with/suffix', search, }), @@ -308,17 +327,16 @@ describe('Result Endpoint', function () { test('HTTP POST without suffix', async () => { const req = mockRequestPost('https://fp.domain.com', 'fpjs/resultId') - requestSpy.mockImplementationOnce((...args) => { - const [url, options] = args - expect(url.toString()).toBe(`${origin}/${search}`) - options.agent = new Agent() - return Reflect.construct(ClientRequest, args) + mockSuccessfulResponse({ + checkRequestUrl: (url) => { + expect(url.toString()).toBe(`${defaultOrigin}/${search}`) + }, }) await proxy(mockContext(req), req) expect(ingress.handleIngress).toHaveBeenCalledTimes(1) expect(https.request).toHaveBeenCalledWith( expect.objectContaining({ - origin, + origin: defaultOrigin, pathname: '/', search, }), @@ -330,17 +348,16 @@ describe('Result Endpoint', function () { test('HTTP POST with suffix', async () => { const req = mockRequestPost('https://fp.domain.com', 'fpjs/resultId/with/suffix') - requestSpy.mockImplementationOnce((...args) => { - const [url, options] = args - expect(url.toString()).toBe(`${origin}/with/suffix${search}`) - options.agent = new Agent() - return Reflect.construct(ClientRequest, args) + mockSuccessfulResponse({ + checkRequestUrl: (url) => { + expect(url.toString()).toBe(`${defaultOrigin}/with/suffix${search}`) + }, }) await proxy(mockContext(req), req) expect(ingress.handleIngress).toHaveBeenCalledTimes(1) expect(https.request).toHaveBeenCalledWith( expect.objectContaining({ - origin, + origin: defaultOrigin, pathname: '/with/suffix', search, }), @@ -356,6 +373,79 @@ describe('Result Endpoint', function () { expect(ingress.handleIngress).toHaveBeenCalledTimes(0) expect(https.request).toHaveBeenCalledTimes(0) }) + + test('Suffix with a dot', async () => { + const req = mockRequestGet('https://fp.domain.com', 'fpjs/resultId/.suffix') + + mockSuccessfulResponse({ + checkRequestUrl: (url) => { + expect(url.toString()).toEqual(`${defaultOrigin}/.suffix${search}`) + }, + }) + + const ctx = mockContext(req) + + await proxy(ctx, req) + }) + + Object.values(Region).forEach((region) => { + test(`Suffix with a dot for region ${region}`, async () => { + const req = mockRequestGet('https://fp.domain.com', 'fpjs/resultId/.suffix', { + region, + }) + + mockSuccessfulResponse({ + checkRequestUrl: (url) => { + const expectedQuery = getSearchWithRegion(region) + + if (region === Region.us) { + expect(url.toString()).toEqual(`${defaultOrigin}/.suffix${expectedQuery}`) + } else { + expect(url.toString()).toEqual(`${getOrigin(region)}/.suffix${expectedQuery}`) + } + }, + }) + + const ctx = mockContext(req) + + await proxy(ctx, req) + }) + }) + + test('Suffix with a dot for invalid region', async () => { + const req = mockRequestGet('https://fp.domain.com', 'fpjs/resultId/.suffix', { + region: 'invalid', + }) + + mockSuccessfulResponse({ + checkRequestUrl: (url) => { + expect(url.toString()).toEqual(`${defaultOrigin}/.suffix${getSearchWithRegion('invalid')}`) + }, + }) + + const ctx = mockContext(req) + + await proxy(ctx, req) + }) + + test.each(['invalid', 'usa', 'EU', 'US', 'AP', '.invalid'])( + 'Should set default (US) region when invalid region is provided in query parameter: %s', + async (region) => { + const req = mockRequestGet('https://fp.domain.com', 'fpjs/resultId', { + region, + }) + + mockSuccessfulResponse({ + checkRequestUrl: (url) => { + expect(url.toString()).toBe(`${defaultOrigin}/${getSearchWithRegion(region)}`) + }, + }) + + const ctx = mockContext(req) + + await proxy(ctx, req) + }, + ) }) describe('Browser caching endpoint', () => { diff --git a/proxy/handlers/ingress.ts b/proxy/handlers/ingress.ts index cac5805d..a64eda06 100644 --- a/proxy/handlers/ingress.ts +++ b/proxy/handlers/ingress.ts @@ -6,6 +6,7 @@ import { HttpResponseSimple } from '@azure/functions/types/http' import { generateErrorResponse } from '../utils/errorResponse' import { getEffectiveTLDPlusOne } from '../domain/tld' import { addTrafficMonitoringSearchParamsForVisitorIdRequest } from '../utils/traffic' +import { getValidRegion, Region } from '../utils/region' export interface HandleIngressParams { httpRequest: HttpRequest @@ -20,7 +21,11 @@ export function handleIngress({ preSharedSecret, suffix, }: HandleIngressParams): Promise { - const { region = 'us' } = httpRequest.query + if (suffix && !suffix.startsWith('/')) { + suffix = '/' + suffix + } + + const { region = Region.us } = httpRequest.query const domain = getEffectiveTLDPlusOne(getHost(httpRequest)) const url = new URL(getIngressAPIHost(region) + suffix) @@ -87,7 +92,9 @@ export function handleIngress({ } function getIngressAPIHost(region: string): string { - const prefix = region === 'us' ? '' : `${region}.` + const validRegion = getValidRegion(region) + + const prefix = validRegion === Region.us ? '' : `${region}.` return `https://${prefix}${config.ingressApi}` } diff --git a/proxy/utils/region.ts b/proxy/utils/region.ts new file mode 100644 index 00000000..ffe97c86 --- /dev/null +++ b/proxy/utils/region.ts @@ -0,0 +1,13 @@ +export enum Region { + us = 'us', + eu = 'eu', + ap = 'ap', +} + +export function getValidRegion(region: string) { + if (region in Region) { + return region + } + + return Region.us +}