diff --git a/package-lock.json b/package-lock.json index 2b0402c..d0f2463 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@envoy/envoy-integrations-sdk", - "version": "2.4.4", + "version": "2.5.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@envoy/envoy-integrations-sdk", - "version": "2.4.4", + "version": "2.5.0", "license": "ISC", "dependencies": { "@types/dotenv": "^8.2.0", diff --git a/package.json b/package.json index e58bfd0..df6eab2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@envoy/envoy-integrations-sdk", - "version": "2.4.4", + "version": "2.5.0", "description": "SDK for building Envoy integrations.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/base/EnvoyAPI.ts b/src/base/EnvoyAPI.ts index 3ae8aa6..489eb04 100644 --- a/src/base/EnvoyAPI.ts +++ b/src/base/EnvoyAPI.ts @@ -4,11 +4,28 @@ import JSONAPIData from '../util/json-api/JSONAPIData'; import { envoyBaseURL } from '../constants'; import { createAxiosClient } from '../util/axiosConstructor'; import { EMPTY_STORAGE_ERROR_MESSAGE } from '../util/errorHandling'; +import { buildUserAgent, buildClientInfoHeader } from '../util/userAgent'; interface EnvoyWebDataLoaderKey extends JSONAPIData { include?: string; } +/** + * Options for configuring EnvoyAPI client + */ +export interface EnvoyAPIOptions { + /** Access token for authentication */ + accessToken: string; + /** + * Custom application identifier appended to User-Agent header. + * Format: "AppName/Version" + * Example: "MyCompanyApp/1.0.0" + * + * This identifier helps track API usage by application and aids in debugging. + */ + userAgent?: string; +} + /** * Sometimes envoy-web will give us back some relationship data * with the "type" set to the relationships name instead of the actual model's name. @@ -27,6 +44,7 @@ const TYPE_ALIASES = new Map([['employee-screening-flows', 'flow export default class EnvoyAPI { /** * HTTP Client with Envoy's defaults. + * User-Agent headers are set in the constructor after client instantiation. */ readonly axios = createAxiosClient({ baseURL: envoyBaseURL, @@ -59,8 +77,51 @@ export default class EnvoyAPI { }, ); - constructor(accessToken: string) { + /** + * Create an EnvoyAPI client instance + * + * @param options - Either an access token string or an EnvoyAPIOptions object + * with accessToken and optional userAgent + * + * @example + * // Simple usage with access token only + * const client = new EnvoyAPI('access-token-here'); + * + * @example + * // Usage with custom User-Agent for tracking and debugging + * const client = new EnvoyAPI({ + * accessToken: 'access-token-here', + * userAgent: 'MyApp/1.0.0' + * }); + */ + constructor(options: EnvoyAPIOptions | string) { + // Support both string and options object formats + const { accessToken, userAgent } = typeof options === 'string' + ? { accessToken: options, userAgent: undefined } + : options; + + // Set authorization header (critical - must succeed) this.axios.defaults.headers.authorization = `Bearer ${accessToken}`; + + // Set User-Agent headers with absolute guarantee that failures won't break SDK + // GUARANTEE: This block will NEVER throw an exception, no matter what happens + // User-Agent headers are telemetry/debugging aids, not critical for SDK functionality + try { + // Primary attempt: Use full header generation functions + this.axios.defaults.headers['User-Agent'] = buildUserAgent(userAgent); + this.axios.defaults.headers['X-Envoy-Client-Info'] = buildClientInfoHeader(userAgent); + } catch (error) { + // Secondary fallback: Set minimal valid headers + try { + this.axios.defaults.headers['User-Agent'] = 'envoy-integrations-sdk/unknown'; + this.axios.defaults.headers['X-Envoy-Client-Info'] = '{"sdk":"envoy-integrations-sdk"}'; + } catch (fallbackError) { + // Tertiary fallback: Even setting minimal headers failed + // Continue without User-Agent headers - SDK remains fully functional + // This catch ensures absolute guarantee that SDK initialization succeeds + } + } + /** * Saves every model that was "include"ed in the response, * which saves us the trouble of fetching related data. diff --git a/src/index.ts b/src/index.ts index 8088310..cf5e96d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -63,6 +63,8 @@ export * from './util/EnvoySignatureVerifier'; export * from './util/axiosConstructor'; export * from './util/errorHandling'; +export type { EnvoyAPIOptions } from './base/EnvoyAPI'; + export { EntryPayload, InvitePayload, diff --git a/src/util/userAgent.ts b/src/util/userAgent.ts new file mode 100644 index 0000000..38a66d8 --- /dev/null +++ b/src/util/userAgent.ts @@ -0,0 +1,144 @@ +import os from 'os'; + +// Safe version extraction with fallback +let version = 'unknown'; +try { + // Import version from package.json + // Note: In compiled code, this resolves correctly + version = require('../../package.json').version; +} catch { + // If package.json can't be loaded, use fallback version + // This ensures SDK initialization never fails + // Silently fail - User-Agent is telemetry, not critical functionality +} + +/** + * Client information for detailed telemetry + */ +export interface ClientInfo { + /** SDK name */ + sdk: string; + /** SDK version */ + version: string; + /** Runtime (e.g., 'node') */ + runtime: string; + /** Runtime version (e.g., '18.0.0') */ + runtimeVersion: string; + /** Operating system platform */ + platform: string; + /** Optional custom application identifier (e.g., 'MyApp/1.0.0') */ + application?: string; +} + +/** + * Safely get Node.js version, with fallback + * @returns Node.js version string without 'v' prefix + */ +function getNodeVersion(): string { + try { + if (process?.version) { + return process.version.replace('v', ''); + } + } catch { + // Ignore error, use fallback + } + return 'unknown'; +} + +/** + * Safely get platform information, with fallback + * @returns Platform string (e.g., 'darwin', 'linux', 'win32') + */ +function getPlatform(): string { + try { + return os.platform(); + } catch { + // If os.platform() fails, return fallback + return 'unknown'; + } +} + +/** + * Build standard User-Agent header value + * Format: envoy-integrations-sdk/2.4.4 node/18.0.0 + * With custom app: envoy-integrations-sdk/2.4.4 node/18.0.0 MyApp/1.0.0 + * + * This function is designed to never throw exceptions. If any error occurs, + * it returns a safe fallback value to ensure SDK initialization succeeds. + * + * @param customUserAgent Optional custom application identifier + * @returns User-Agent header value (never throws) + */ +export function buildUserAgent(customUserAgent?: string): string { + try { + const nodeVersion = getNodeVersion(); + const baseUA = `envoy-integrations-sdk/${version} node/${nodeVersion}`; + return customUserAgent ? `${baseUA} ${customUserAgent}` : baseUA; + } catch { + // Critical fallback - should never happen, but ensures SDK always works + // Silently fail - User-Agent is telemetry, not critical functionality + return 'envoy-integrations-sdk/unknown node/unknown'; + } +} + +/** + * Build detailed client info for X-Envoy-Client-Info header + * + * This function is designed to never throw exceptions. If any error occurs + * during info collection, it uses safe fallback values. + * + * @param customUserAgent Optional custom application identifier + * @returns ClientInfo object (never throws) + */ +export function buildClientInfo(customUserAgent?: string): ClientInfo { + try { + const nodeVersion = getNodeVersion(); + const platform = getPlatform(); + + const clientInfo: ClientInfo = { + sdk: 'envoy-integrations-sdk', + version, + runtime: 'node', + runtimeVersion: nodeVersion, + platform, + }; + + // Only add application if it's a non-empty string + if (customUserAgent) { + clientInfo.application = customUserAgent; + } + + return clientInfo; + } catch { + // Critical fallback - return minimal safe info + // Silently fail - User-Agent is telemetry, not critical functionality + return { + sdk: 'envoy-integrations-sdk', + version: 'unknown', + runtime: 'node', + runtimeVersion: 'unknown', + platform: 'unknown', + }; + } +} + +/** + * Build X-Envoy-Client-Info header value (JSON string) + * + * This function is designed to never throw exceptions. If JSON serialization + * fails, it returns a minimal safe JSON string. + * + * @param customUserAgent Optional custom application identifier + * @returns JSON string for X-Envoy-Client-Info header (never throws) + */ +export function buildClientInfoHeader(customUserAgent?: string): string { + try { + const clientInfo = buildClientInfo(customUserAgent); + return JSON.stringify(clientInfo); + } catch { + // Critical fallback - return minimal valid JSON + // Silently fail - User-Agent is telemetry, not critical functionality + // Return minimal valid JSON that won't break parsing + return '{"sdk":"envoy-integrations-sdk","version":"unknown","runtime":"node","runtimeVersion":"unknown","platform":"unknown"}'; + } +} diff --git a/test/base/EnvoyAPI.test.ts b/test/base/EnvoyAPI.test.ts new file mode 100644 index 0000000..355fada --- /dev/null +++ b/test/base/EnvoyAPI.test.ts @@ -0,0 +1,373 @@ +import os from 'os'; +import EnvoyAPI from '../../src/base/EnvoyAPI'; +import { buildUserAgent, buildClientInfoHeader } from '../../src/util/userAgent'; + +// Mock the package.json version +jest.mock('../../package.json', () => ({ + version: '2.4.4', +})); + +describe('EnvoyAPI', () => { + const originalProcessVersion = process.version; + const originalPlatform = os.platform; + const originalConsoleError = console.error; + const testAccessToken = 'test-access-token-12345'; + + beforeEach(() => { + jest.clearAllMocks(); + // Mock console.error to avoid cluttering test output + console.error = jest.fn(); + // Set consistent test values + Object.defineProperty(process, 'version', { + value: 'v18.0.0', + writable: true, + }); + os.platform = jest.fn().mockReturnValue('darwin'); + }); + + afterEach(() => { + // Restore original values + Object.defineProperty(process, 'version', { + value: originalProcessVersion, + writable: true, + }); + os.platform = originalPlatform; + console.error = originalConsoleError; + }); + + describe('constructor', () => { + describe('backward compatibility with string parameter', () => { + it('accepts string access token (legacy usage)', () => { + const api = new EnvoyAPI(testAccessToken); + + expect(api.axios.defaults.headers.authorization).toBe(`Bearer ${testAccessToken}`); + }); + + it('sets default User-Agent when using string parameter', () => { + const api = new EnvoyAPI(testAccessToken); + + const expectedUserAgent = buildUserAgent(); + expect(api.axios.defaults.headers['User-Agent']).toBe(expectedUserAgent); + expect(api.axios.defaults.headers['User-Agent']).toBe('envoy-integrations-sdk/2.4.4 node/18.0.0'); + }); + + it('sets default X-Envoy-Client-Info when using string parameter', () => { + const api = new EnvoyAPI(testAccessToken); + + const expectedClientInfo = buildClientInfoHeader(); + expect(api.axios.defaults.headers['X-Envoy-Client-Info']).toBe(expectedClientInfo); + + const clientInfoHeader = api.axios.defaults.headers['X-Envoy-Client-Info'] as string; + const parsedClientInfo = JSON.parse(clientInfoHeader); + expect(parsedClientInfo).toEqual({ + sdk: 'envoy-integrations-sdk', + version: '2.4.4', + runtime: 'node', + runtimeVersion: '18.0.0', + platform: 'darwin', + }); + }); + }); + + describe('options object parameter', () => { + it('accepts EnvoyAPIOptions object with accessToken only', () => { + const api = new EnvoyAPI({ accessToken: testAccessToken }); + + expect(api.axios.defaults.headers.authorization).toBe(`Bearer ${testAccessToken}`); + }); + + it('sets default headers when userAgent is not provided', () => { + const api = new EnvoyAPI({ accessToken: testAccessToken }); + + const expectedUserAgent = buildUserAgent(); + const expectedClientInfo = buildClientInfoHeader(); + + expect(api.axios.defaults.headers['User-Agent']).toBe(expectedUserAgent); + expect(api.axios.defaults.headers['X-Envoy-Client-Info']).toBe(expectedClientInfo); + }); + + it('includes custom userAgent in User-Agent header', () => { + const customUserAgent = 'MyApp/1.0.0'; + const api = new EnvoyAPI({ + accessToken: testAccessToken, + userAgent: customUserAgent, + }); + + const expectedUserAgent = buildUserAgent(customUserAgent); + expect(api.axios.defaults.headers['User-Agent']).toBe(expectedUserAgent); + expect(api.axios.defaults.headers['User-Agent']).toBe( + 'envoy-integrations-sdk/2.4.4 node/18.0.0 MyApp/1.0.0', + ); + }); + + it('includes custom userAgent in X-Envoy-Client-Info header', () => { + const customUserAgent = 'MyApp/1.0.0'; + const api = new EnvoyAPI({ + accessToken: testAccessToken, + userAgent: customUserAgent, + }); + + const expectedClientInfo = buildClientInfoHeader(customUserAgent); + expect(api.axios.defaults.headers['X-Envoy-Client-Info']).toBe(expectedClientInfo); + + const clientInfoHeader = api.axios.defaults.headers['X-Envoy-Client-Info'] as string; + const parsedClientInfo = JSON.parse(clientInfoHeader); + expect(parsedClientInfo).toEqual({ + sdk: 'envoy-integrations-sdk', + version: '2.4.4', + runtime: 'node', + runtimeVersion: '18.0.0', + platform: 'darwin', + application: 'MyApp/1.0.0', + }); + }); + + it('handles empty string userAgent', () => { + const api = new EnvoyAPI({ + accessToken: testAccessToken, + userAgent: '', + }); + + // Empty string is falsy, so it's treated as no custom user agent + expect(api.axios.defaults.headers['User-Agent']).toBe('envoy-integrations-sdk/2.4.4 node/18.0.0'); + }); + + it('handles userAgent with special characters', () => { + const customUserAgent = 'My-Company_App/2.0.0-beta'; + const api = new EnvoyAPI({ + accessToken: testAccessToken, + userAgent: customUserAgent, + }); + + expect(api.axios.defaults.headers['User-Agent']).toContain(customUserAgent); + + const clientInfoHeader = api.axios.defaults.headers['X-Envoy-Client-Info'] as string; + const parsedClientInfo = JSON.parse(clientInfoHeader); + expect(parsedClientInfo.application).toBe(customUserAgent); + }); + + it('handles userAgent with emoji and unexpected characters', () => { + const customUserAgent = 'MyApp🚀/1.0.0 (test版本)'; + const api = new EnvoyAPI({ + accessToken: testAccessToken, + userAgent: customUserAgent, + }); + + // SDK should not fail even with unusual characters + expect(api).toBeDefined(); + expect(api.axios.defaults.headers.authorization).toBe(`Bearer ${testAccessToken}`); + expect(api.axios.defaults.headers['User-Agent']).toContain(customUserAgent); + + // JSON serialization should handle these characters + const clientInfoHeader = api.axios.defaults.headers['X-Envoy-Client-Info'] as string; + expect(() => JSON.parse(clientInfoHeader)).not.toThrow(); + const parsedClientInfo = JSON.parse(clientInfoHeader); + expect(parsedClientInfo.application).toBe(customUserAgent); + }); + }); + + describe('authorization header', () => { + it('sets Bearer token correctly with string parameter', () => { + const api = new EnvoyAPI(testAccessToken); + + expect(api.axios.defaults.headers.authorization).toBe(`Bearer ${testAccessToken}`); + }); + + it('sets Bearer token correctly with options parameter', () => { + const api = new EnvoyAPI({ accessToken: testAccessToken }); + + expect(api.axios.defaults.headers.authorization).toBe(`Bearer ${testAccessToken}`); + }); + + it('handles different token formats', () => { + const tokenWithSpecialChars = 'abc123-DEF456_xyz.789'; + const api = new EnvoyAPI({ accessToken: tokenWithSpecialChars }); + + expect(api.axios.defaults.headers.authorization).toBe(`Bearer ${tokenWithSpecialChars}`); + }); + }); + + describe('axios client configuration', () => { + it('has correct baseURL configured', () => { + const api = new EnvoyAPI(testAccessToken); + + expect(api.axios.defaults.baseURL).toBeDefined(); + }); + + it('has correct Content-Type header', () => { + const api = new EnvoyAPI(testAccessToken); + + expect(api.axios.defaults.headers['Content-Type']).toBe('application/vnd.api+json'); + }); + + it('has correct Accept header', () => { + const api = new EnvoyAPI(testAccessToken); + + expect(api.axios.defaults.headers.Accept).toBe('application/vnd.api+json'); + }); + + it('has dataLoader configured', () => { + const api = new EnvoyAPI(testAccessToken); + + // Access the protected dataLoader to verify it exists + expect((api as any).dataLoader).toBeDefined(); + }); + + it('has response interceptor configured', () => { + const api = new EnvoyAPI(testAccessToken); + + expect(api.axios.interceptors.response).toBeDefined(); + // Interceptor should be registered (checking internal handlers is implementation detail) + expect((api.axios.interceptors.response as any).handlers).toBeDefined(); + }); + }); + }); + + describe('header format validation', () => { + it('User-Agent follows standard format', () => { + const api = new EnvoyAPI(testAccessToken); + const userAgent = api.axios.defaults.headers['User-Agent']; + + // Format: sdk/version runtime/version + expect(userAgent).toMatch(/^envoy-integrations-sdk\/[\d.]+ node\/[\d.]+$/); + }); + + it('User-Agent with custom app follows extended format', () => { + const api = new EnvoyAPI({ + accessToken: testAccessToken, + userAgent: 'MyApp/1.0.0', + }); + const userAgent = api.axios.defaults.headers['User-Agent']; + + // Format: sdk/version runtime/version custom + expect(userAgent).toMatch(/^envoy-integrations-sdk\/[\d.]+ node\/[\d.]+ .+$/); + }); + + it('X-Envoy-Client-Info is valid JSON', () => { + const api = new EnvoyAPI(testAccessToken); + const clientInfo = api.axios.defaults.headers['X-Envoy-Client-Info'] as string; + + expect(() => JSON.parse(clientInfo)).not.toThrow(); + }); + + it('X-Envoy-Client-Info contains required fields', () => { + const api = new EnvoyAPI(testAccessToken); + const clientInfoHeader = api.axios.defaults.headers['X-Envoy-Client-Info'] as string; + const clientInfo = JSON.parse(clientInfoHeader); + + expect(clientInfo).toHaveProperty('sdk'); + expect(clientInfo).toHaveProperty('version'); + expect(clientInfo).toHaveProperty('runtime'); + expect(clientInfo).toHaveProperty('runtimeVersion'); + expect(clientInfo).toHaveProperty('platform'); + }); + + it('X-Envoy-Client-Info contains application field when userAgent provided', () => { + const api = new EnvoyAPI({ + accessToken: testAccessToken, + userAgent: 'MyApp/1.0.0', + }); + const clientInfoHeader = api.axios.defaults.headers['X-Envoy-Client-Info'] as string; + const clientInfo = JSON.parse(clientInfoHeader); + + expect(clientInfo).toHaveProperty('application', 'MyApp/1.0.0'); + }); + + it('X-Envoy-Client-Info does not contain application field when userAgent not provided', () => { + const api = new EnvoyAPI(testAccessToken); + const clientInfoHeader = api.axios.defaults.headers['X-Envoy-Client-Info'] as string; + const clientInfo = JSON.parse(clientInfoHeader); + + expect(clientInfo).not.toHaveProperty('application'); + }); + }); + + describe('error handling and resilience', () => { + it('SDK initialization succeeds even with os.platform errors', () => { + // Simulate error in platform detection + os.platform = jest.fn().mockImplementation(() => { + throw new Error('platform error'); + }); + + // SDK should still initialize successfully + const api = new EnvoyAPI(testAccessToken); + + expect(api).toBeDefined(); + expect(api.axios.defaults.headers.authorization).toBe(`Bearer ${testAccessToken}`); + // User-Agent should have fallback value + expect(api.axios.defaults.headers['User-Agent']).toBeDefined(); + }); + + it('sets fallback User-Agent headers when generation encounters errors', () => { + os.platform = jest.fn().mockImplementation(() => { + throw new Error('platform error'); + }); + + const api = new EnvoyAPI(testAccessToken); + + // Should have some User-Agent header (with fallback values) + expect(api.axios.defaults.headers['User-Agent']).toBeDefined(); + expect(typeof api.axios.defaults.headers['User-Agent']).toBe('string'); + + // Should have some X-Envoy-Client-Info header + expect(api.axios.defaults.headers['X-Envoy-Client-Info']).toBeDefined(); + + // Verify the JSON is valid even with platform error + const clientInfo = JSON.parse(api.axios.defaults.headers['X-Envoy-Client-Info'] as string); + expect(clientInfo.platform).toBe('unknown'); + }); + + it('authorization header is always set even if User-Agent generation fails', () => { + // Simulate error in User-Agent generation + os.platform = jest.fn().mockImplementation(() => { + throw new Error('critical error'); + }); + + const api = new EnvoyAPI({ + accessToken: testAccessToken, + userAgent: 'MyApp/1.0.0', + }); + + // Authorization is critical and should always work + expect(api.axios.defaults.headers.authorization).toBe(`Bearer ${testAccessToken}`); + expect(api).toBeDefined(); + }); + + it('axios client remains functional even with User-Agent errors', () => { + os.platform = jest.fn().mockImplementation(() => { + throw new Error('critical error'); + }); + + const api = new EnvoyAPI(testAccessToken); + + // Core axios functionality should work + expect(api.axios.defaults.baseURL).toBeDefined(); + expect(api.axios.defaults.headers['Content-Type']).toBe('application/vnd.api+json'); + expect(api.axios.defaults.headers.Accept).toBe('application/vnd.api+json'); + expect((api as any).dataLoader).toBeDefined(); + }); + + it('handles malformed customUserAgent gracefully', () => { + // Even with weird input, SDK should initialize + const weirdUserAgent = '\n\t\r' + String.fromCharCode(0) + 'weird'; + + const api = new EnvoyAPI({ + accessToken: testAccessToken, + userAgent: weirdUserAgent, + }); + + expect(api).toBeDefined(); + expect(api.axios.defaults.headers.authorization).toBe(`Bearer ${testAccessToken}`); + }); + + it('SDK never throws during initialization due to User-Agent errors', () => { + // Even with multiple sources of errors, initialization should succeed + os.platform = jest.fn().mockImplementation(() => { + throw new Error('critical error'); + }); + + expect(() => new EnvoyAPI(testAccessToken)).not.toThrow(); + expect(() => new EnvoyAPI({ accessToken: testAccessToken, userAgent: 'App/1.0' })).not.toThrow(); + }); + }); +}); diff --git a/test/util/userAgent.test.ts b/test/util/userAgent.test.ts new file mode 100644 index 0000000..bc994c9 --- /dev/null +++ b/test/util/userAgent.test.ts @@ -0,0 +1,330 @@ +import os from 'os'; +import { buildUserAgent, buildClientInfo, buildClientInfoHeader } from '../../src/util/userAgent'; + +// Mock the package.json version +jest.mock('../../package.json', () => ({ + version: '2.4.4', +})); + +describe('userAgent utilities', () => { + const originalProcessVersion = process.version; + const originalPlatform = os.platform; + const originalConsoleError = console.error; + + beforeEach(() => { + jest.clearAllMocks(); + // Mock console.error to avoid cluttering test output + console.error = jest.fn(); + }); + + afterEach(() => { + // Restore original values + Object.defineProperty(process, 'version', { + value: originalProcessVersion, + writable: true, + }); + os.platform = originalPlatform; + console.error = originalConsoleError; + }); + + describe('buildUserAgent', () => { + it('builds standard User-Agent without custom application', () => { + Object.defineProperty(process, 'version', { + value: 'v18.0.0', + writable: true, + }); + + const result = buildUserAgent(); + + expect(result).toBe('envoy-integrations-sdk/2.4.4 node/18.0.0'); + }); + + it('builds User-Agent with custom application identifier', () => { + Object.defineProperty(process, 'version', { + value: 'v18.0.0', + writable: true, + }); + + const result = buildUserAgent('MyApp/1.0.0'); + + expect(result).toBe('envoy-integrations-sdk/2.4.4 node/18.0.0 MyApp/1.0.0'); + }); + + it('handles Node.js version without v prefix', () => { + Object.defineProperty(process, 'version', { + value: '18.0.0', + writable: true, + }); + + const result = buildUserAgent(); + + expect(result).toBe('envoy-integrations-sdk/2.4.4 node/18.0.0'); + }); + + it('handles different Node.js versions', () => { + Object.defineProperty(process, 'version', { + value: 'v20.5.1', + writable: true, + }); + + const result = buildUserAgent(); + + expect(result).toBe('envoy-integrations-sdk/2.4.4 node/20.5.1'); + }); + + it('handles empty custom user agent', () => { + Object.defineProperty(process, 'version', { + value: 'v18.0.0', + writable: true, + }); + + const result = buildUserAgent(''); + + // Empty string is falsy, so it's treated as no custom user agent + expect(result).toBe('envoy-integrations-sdk/2.4.4 node/18.0.0'); + }); + }); + + describe('buildClientInfo', () => { + it('builds client info without custom application', () => { + Object.defineProperty(process, 'version', { + value: 'v18.0.0', + writable: true, + }); + os.platform = jest.fn().mockReturnValue('darwin'); + + const result = buildClientInfo(); + + expect(result).toEqual({ + sdk: 'envoy-integrations-sdk', + version: '2.4.4', + runtime: 'node', + runtimeVersion: '18.0.0', + platform: 'darwin', + }); + }); + + it('builds client info with custom application', () => { + Object.defineProperty(process, 'version', { + value: 'v18.0.0', + writable: true, + }); + os.platform = jest.fn().mockReturnValue('darwin'); + + const result = buildClientInfo('MyApp/1.0.0'); + + expect(result).toEqual({ + sdk: 'envoy-integrations-sdk', + version: '2.4.4', + runtime: 'node', + runtimeVersion: '18.0.0', + platform: 'darwin', + application: 'MyApp/1.0.0', + }); + }); + + it('handles different platforms', () => { + Object.defineProperty(process, 'version', { + value: 'v18.0.0', + writable: true, + }); + os.platform = jest.fn().mockReturnValue('linux'); + + const result = buildClientInfo(); + + expect(result).toEqual({ + sdk: 'envoy-integrations-sdk', + version: '2.4.4', + runtime: 'node', + runtimeVersion: '18.0.0', + platform: 'linux', + }); + }); + + it('handles Windows platform', () => { + Object.defineProperty(process, 'version', { + value: 'v18.0.0', + writable: true, + }); + os.platform = jest.fn().mockReturnValue('win32'); + + const result = buildClientInfo(); + + expect(result).toEqual({ + sdk: 'envoy-integrations-sdk', + version: '2.4.4', + runtime: 'node', + runtimeVersion: '18.0.0', + platform: 'win32', + }); + }); + + it('does not include application field when custom user agent is empty string', () => { + Object.defineProperty(process, 'version', { + value: 'v18.0.0', + writable: true, + }); + os.platform = jest.fn().mockReturnValue('darwin'); + + const result = buildClientInfo(''); + + // Empty string is falsy, so application field is not included + expect(result).toEqual({ + sdk: 'envoy-integrations-sdk', + version: '2.4.4', + runtime: 'node', + runtimeVersion: '18.0.0', + platform: 'darwin', + }); + }); + }); + + describe('buildClientInfoHeader', () => { + it('builds JSON string without custom application', () => { + Object.defineProperty(process, 'version', { + value: 'v18.0.0', + writable: true, + }); + os.platform = jest.fn().mockReturnValue('darwin'); + + const result = buildClientInfoHeader(); + + expect(result).toBe( + '{"sdk":"envoy-integrations-sdk","version":"2.4.4","runtime":"node","runtimeVersion":"18.0.0","platform":"darwin"}', + ); + expect(() => JSON.parse(result)).not.toThrow(); + }); + + it('builds JSON string with custom application', () => { + Object.defineProperty(process, 'version', { + value: 'v18.0.0', + writable: true, + }); + os.platform = jest.fn().mockReturnValue('darwin'); + + const result = buildClientInfoHeader('MyApp/1.0.0'); + + expect(result).toBe( + '{"sdk":"envoy-integrations-sdk","version":"2.4.4","runtime":"node","runtimeVersion":"18.0.0","platform":"darwin","application":"MyApp/1.0.0"}', + ); + expect(() => JSON.parse(result)).not.toThrow(); + }); + + it('returns valid JSON that can be parsed', () => { + Object.defineProperty(process, 'version', { + value: 'v18.0.0', + writable: true, + }); + os.platform = jest.fn().mockReturnValue('darwin'); + + const result = buildClientInfoHeader('MyApp/1.0.0'); + const parsed = JSON.parse(result); + + expect(parsed).toEqual({ + sdk: 'envoy-integrations-sdk', + version: '2.4.4', + runtime: 'node', + runtimeVersion: '18.0.0', + platform: 'darwin', + application: 'MyApp/1.0.0', + }); + }); + + it('handles special characters in application name', () => { + Object.defineProperty(process, 'version', { + value: 'v18.0.0', + writable: true, + }); + os.platform = jest.fn().mockReturnValue('darwin'); + + const result = buildClientInfoHeader('My App & Service/1.0.0'); + + expect(() => JSON.parse(result)).not.toThrow(); + const parsed = JSON.parse(result); + expect(parsed.application).toBe('My App & Service/1.0.0'); + }); + }); + + describe('error handling and resilience', () => { + it('buildUserAgent handles missing process.version gracefully', () => { + // Simulate missing process.version + const originalVersion = process.version; + delete (process as any).version; + + const result = buildUserAgent(); + + expect(result).toContain('envoy-integrations-sdk'); + expect(result).toContain('node/unknown'); + + // Restore + Object.defineProperty(process, 'version', { + value: originalVersion, + writable: true, + }); + }); + + it('buildClientInfo never throws - returns fallback on os.platform error', () => { + Object.defineProperty(process, 'version', { + value: 'v18.0.0', + writable: true, + }); + + // Mock os.platform to throw + os.platform = jest.fn().mockImplementation(() => { + throw new Error('platform error'); + }); + + const result = buildClientInfo(); + + expect(result.platform).toBe('unknown'); + expect(result.sdk).toBe('envoy-integrations-sdk'); + }); + + it('buildClientInfoHeader returns valid JSON even with errors', () => { + os.platform = jest.fn().mockImplementation(() => { + throw new Error('platform error'); + }); + + const result = buildClientInfoHeader(); + + // Should return valid JSON even on error + expect(() => JSON.parse(result)).not.toThrow(); + const parsed = JSON.parse(result); + expect(parsed.sdk).toBe('envoy-integrations-sdk'); + }); + + it('buildClientInfoHeader handles JSON.stringify failure with fallback', () => { + Object.defineProperty(process, 'version', { + value: 'v18.0.0', + writable: true, + }); + os.platform = jest.fn().mockReturnValue('darwin'); + + // Mock JSON.stringify to fail (edge case, but we should handle it) + const originalStringify = JSON.stringify; + JSON.stringify = jest.fn().mockImplementation(() => { + throw new Error('stringify failed'); + }); + + const result = buildClientInfoHeader(); + + // Should return fallback valid JSON string + expect(result).toBe('{"sdk":"envoy-integrations-sdk","version":"unknown","runtime":"node","runtimeVersion":"unknown","platform":"unknown"}'); + expect(() => JSON.parse(result)).not.toThrow(); + + // Restore + JSON.stringify = originalStringify; + }); + + it('functions never throw exceptions', () => { + // Even with os.platform throwing, functions should not throw + os.platform = jest.fn().mockImplementation(() => { + throw new Error('critical error'); + }); + + expect(() => buildUserAgent()).not.toThrow(); + expect(() => buildClientInfo()).not.toThrow(); + expect(() => buildClientInfoHeader()).not.toThrow(); + }); + }); +});