diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d6f27d8..f1ebadc 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,9 +1,3 @@ ---- -labels: mergeable ---- -[//]: # (Link to the issue corresponding to this chunk of work) -Fixes: #__issue__ - ## Motivation and Context [//]: # (Why is this change required? What problem does it solve?) @@ -13,6 +7,5 @@ Fixes: #__issue__ ## How has this been tested? [//]: # (Please describe in detail how you tested your changes) - -[//]: # (OPTIONAL) -[//]: # (Add one or multiple labels: enhancement, refactoring, bugfix) +## Documentation +[//]: # (Does this PR require documentation updates?) diff --git a/src/events/sdk-key-decoder.spec.ts b/src/events/sdk-key-decoder.spec.ts index aa29f7b..53d0e6c 100644 --- a/src/events/sdk-key-decoder.spec.ts +++ b/src/events/sdk-key-decoder.spec.ts @@ -1,24 +1,113 @@ +import { Base64 } from 'js-base64'; + import SdkKeyDecoder from './sdk-key-decoder'; describe('SdkKeyDecoder', () => { - const decoder = new SdkKeyDecoder(); - it('should decode the event ingestion hostname from the SDK key', () => { - const hostname = decoder.decodeEventIngestionUrl( - 'zCsQuoHJxVPp895.ZWg9MTIzNDU2LmUudGVzdGluZy5lcHBvLmNsb3Vk', - ); - expect(hostname).toEqual('https://123456.e.testing.eppo.cloud/v0/i'); + let decoder: SdkKeyDecoder; + const sdkKeyPrefix = 'zCsQuoHJxVPp895'; + + beforeEach(() => { + decoder = new SdkKeyDecoder(); }); - it('should decode strings with non URL-safe characters', () => { - // this is not a really valid ingestion URL, but it's useful for testing the decoder - const invalidUrl = 'eh=12+3456/.e.testing.eppo.cloud'; - const encoded = Buffer.from(invalidUrl).toString('base64url'); - const hostname = decoder.decodeEventIngestionUrl(`zCsQuoHJxVPp895.${encoded}`); - expect(hostname).toEqual('https://12 3456/.e.testing.eppo.cloud/v0/i'); + describe('generations of SDK keys', () => { + it('should return null for all URLs when no hosts are encoded', () => { + const sdkKey = 'invalid.sdk.key'; + expect(decoder.decodeEventIngestionUrl(sdkKey)).toBeNull(); + expect(decoder.decodeAssignmentConfigurationUrl(sdkKey)).toBeNull(); + expect(decoder.decodeEdgeConfigurationUrl(sdkKey)).toBeNull(); + }); + + it('should return event ingestion URL when only event ingestion host is encoded', () => { + const sdkKey = `${sdkKeyPrefix}.${Base64.encode('eh=123456.e.testing.eppo.cloud')}.signature`; + expect(decoder.decodeEventIngestionUrl(sdkKey)).toBe( + 'https://123456.e.testing.eppo.cloud/v0/i', + ); + expect(decoder.decodeAssignmentConfigurationUrl(sdkKey)).toBeNull(); + expect(decoder.decodeEdgeConfigurationUrl(sdkKey)).toBeNull(); + }); + + it('should return assignment configuration URL when only configuration host is encoded', () => { + const sdkKey = `${sdkKeyPrefix}.${Base64.encode('ch=123456.fscdn.eppo.cloud')}.signature`; + expect(decoder.decodeEventIngestionUrl(sdkKey)).toBeNull(); + expect(decoder.decodeAssignmentConfigurationUrl(sdkKey)).toBe( + 'https://123456.fscdn.eppo.cloud/assignment', + ); + expect(decoder.decodeEdgeConfigurationUrl(sdkKey)).toBe( + 'https://123456.fscdn.eppo.cloud/edge', + ); + }); + + it('should return URLs when both hosts are encoded', () => { + const sdkKey = `${sdkKeyPrefix}.${Base64.encode( + 'eh=123456.e.testing.eppo.cloud&ch=123456.fscdn.eppo.cloud', + )}.signature`; + expect(decoder.decodeEventIngestionUrl(sdkKey)).toBe( + 'https://123456.e.testing.eppo.cloud/v0/i', + ); + expect(decoder.decodeAssignmentConfigurationUrl(sdkKey)).toBe( + 'https://123456.fscdn.eppo.cloud/assignment', + ); + expect(decoder.decodeEdgeConfigurationUrl(sdkKey)).toBe( + 'https://123456.fscdn.eppo.cloud/edge', + ); + }); }); - it("should return null if the SDK key doesn't contain the event ingestion hostname", () => { - expect(decoder.decodeEventIngestionUrl('zCsQuoHJxVPp895')).toBeNull(); - expect(decoder.decodeEventIngestionUrl('zCsQuoHJxVPp895.xxxxxx')).toBeNull(); + describe('corner cases', () => { + it('should decode strings with non URL-safe characters', () => { + // this is not a really valid ingestion URL, but it's useful for testing the decoder + const invalidUrl = 'eh=12+3456/.e.testing.eppo.cloud'; + const encoded = Buffer.from(invalidUrl).toString('base64url'); + const hostname = decoder.decodeEventIngestionUrl(`zCsQuoHJxVPp895.${encoded}`); + expect(hostname).toEqual('https://12 3456/.e.testing.eppo.cloud/v0/i'); + }); + + it('should handle malformed SDK keys gracefully', () => { + const malformedKeys = [ + '', + 'invalid', + 'invalid.', + 'invalid.invalid', + 'invalid.invalid.invalid', + `valid.${Base64.encode('invalid=host')}.signature`, + ]; + + malformedKeys.forEach((key) => { + expect(decoder.decodeEventIngestionUrl(key)).toBeNull(); + expect(decoder.decodeAssignmentConfigurationUrl(key)).toBeNull(); + expect(decoder.decodeEdgeConfigurationUrl(key)).toBeNull(); + }); + }); + + it('should handle URLs with existing schemes', () => { + const hosts = ['eh=http://event.host', 'ch=https://config.host'].join('&'); + const sdkKey = `${sdkKeyPrefix}.${Base64.encode(hosts)}.signature`; + + expect(decoder.decodeEventIngestionUrl(sdkKey)).toBe('http://event.host/v0/i'); + expect(decoder.decodeAssignmentConfigurationUrl(sdkKey)).toBe( + 'https://config.host/assignment', + ); + expect(decoder.decodeEdgeConfigurationUrl(sdkKey)).toBe('https://config.host/edge'); + }); + + it('should add https scheme when protocol is missing', () => { + const hosts = ['eh=event.host', 'ch=config.host:8080'].join('&'); + const sdkKey = `${sdkKeyPrefix}.${Base64.encode(hosts)}.signature`; + + expect(decoder.decodeEventIngestionUrl(sdkKey)).toBe('https://event.host/v0/i'); + expect(decoder.decodeAssignmentConfigurationUrl(sdkKey)).toBe( + 'https://config.host:8080/assignment', + ); + expect(decoder.decodeEdgeConfigurationUrl(sdkKey)).toBe('https://config.host:8080/edge'); + }); + + it('should handle special characters in URLs', () => { + const specialHost = 'eh=test.host/with+special@chars?param=value'; + const sdkKey = `${sdkKeyPrefix}.${Base64.encode(specialHost)}.signature`; + expect(decoder.decodeEventIngestionUrl(sdkKey)).toBe( + 'https://test.host/with special@chars?param=value/v0/i', + ); + }); }); }); diff --git a/src/events/sdk-key-decoder.ts b/src/events/sdk-key-decoder.ts index 47dd422..297c850 100644 --- a/src/events/sdk-key-decoder.ts +++ b/src/events/sdk-key-decoder.ts @@ -1,6 +1,11 @@ import { Base64 } from 'js-base64'; -const PATH = 'v0/i'; +const EVENT_INGESTION_HOSTNAME_KEY = 'eh'; +const CONFIGURATION_HOSTNAME_KEY = 'ch'; + +const EVENT_INGESTION_PATH = 'v0/i'; +const ASSIGNMENT_CONFIG_PATH = 'assignment'; +const EDGE_CONFIG_PATH = 'edge'; export default class SdkKeyDecoder { /** @@ -8,20 +13,58 @@ export default class SdkKeyDecoder { * If the SDK key doesn't contain the event ingestion hostname, or it's invalid, it returns null. */ decodeEventIngestionUrl(sdkKey: string): string | null { + return this.decodeHostnames(sdkKey, EVENT_INGESTION_PATH).eventIngestionHostname; + } + + /** + * Decodes and returns the configuration hostname from the provided Eppo SDK key string. + * If the SDK key doesn't contain the configuration hostname, or it's invalid, it returns null. + */ + decodeAssignmentConfigurationUrl(sdkKey: string): string | null { + return this.decodeHostnames(sdkKey, ASSIGNMENT_CONFIG_PATH).configurationHostname; + } + + /** + * Decodes and returns the edge configuration hostname from the provided Eppo SDK key string. + * If the SDK key doesn't contain the edge configuration hostname, or it's invalid, it returns null. + */ + decodeEdgeConfigurationUrl(sdkKey: string): string | null { + return this.decodeHostnames(sdkKey, EDGE_CONFIG_PATH).configurationHostname; + } + + private decodeHostnames( + sdkKey: string, + path: string, + ): { + eventIngestionHostname: string | null; + configurationHostname: string | null; + } { const encodedPayload = sdkKey.split('.')[1]; - if (!encodedPayload) return null; + if (!encodedPayload) return { eventIngestionHostname: null, configurationHostname: null }; const decodedPayload = Base64.decode(encodedPayload); const params = new URLSearchParams(decodedPayload); - const hostname = params.get('eh'); - if (!hostname) return null; - - const hostAndPath = hostname.endsWith('/') ? `${hostname}${PATH}` : `${hostname}/${PATH}`; - if (!hostAndPath.startsWith('http://') && !hostAndPath.startsWith('https://')) { - // prefix hostname with https scheme if none present - return `https://${hostAndPath}`; - } else { - return hostAndPath; + const eventIngestionHostname = params.get(EVENT_INGESTION_HOSTNAME_KEY); + const configurationHostname = params.get(CONFIGURATION_HOSTNAME_KEY); + + return { + eventIngestionHostname: eventIngestionHostname + ? this.ensureHttps(this.ensurePath(eventIngestionHostname, path)) + : null, + configurationHostname: configurationHostname + ? this.ensureHttps(this.ensurePath(configurationHostname, path)) + : null, + }; + } + + private ensureHttps(hostname: string): string { + if (!hostname.startsWith('http://') && !hostname.startsWith('https://')) { + return `https://${hostname}`; } + return hostname; + } + + private ensurePath(hostname: string, path: string): string { + return hostname.endsWith('/') ? `${hostname}${path}` : `${hostname}/${path}`; } }