diff --git a/packages/sdk/react-native/__tests__/MobileDataManager.test.ts b/packages/sdk/react-native/__tests__/MobileDataManager.test.ts index fdbf72ad35..30c718e11b 100644 --- a/packages/sdk/react-native/__tests__/MobileDataManager.test.ts +++ b/packages/sdk/react-native/__tests__/MobileDataManager.test.ts @@ -527,6 +527,76 @@ describe('given a MobileDataManager with mocked dependencies', () => { ); }); + it('includes the secure mode hash as a query parameter when hash is provided in identify options', async () => { + await mobileDataManager.setConnectionMode('polling'); + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false, hash: 'test-hash-abc123' }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + await mobileDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(platform.requests.fetch).toHaveBeenCalledWith( + expect.stringContaining('h=test-hash-abc123'), + expect.anything(), + ); + }); + + it('does not include the secure mode hash query parameter when hash is not provided', async () => { + await mobileDataManager.setConnectionMode('polling'); + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + await mobileDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(platform.requests.fetch).toHaveBeenCalledWith( + expect.not.stringContaining('h='), + expect.anything(), + ); + }); + + it('persists the secure mode hash when connection mode changes after identify', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false, hash: 'my-secure-hash' }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + // Identify in streaming mode with a hash + await mobileDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + // Switch to polling — hash should be forwarded to the new connection + await mobileDataManager.setConnectionMode('polling'); + + expect(platform.requests.fetch).toHaveBeenCalledWith( + expect.stringContaining('h=my-secure-hash'), + expect.anything(), + ); + }); + + it('clears the secure mode hash when identify is called without a hash after a previous hash was set', async () => { + await mobileDataManager.setConnectionMode('polling'); + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + // First identify with hash + await mobileDataManager.identify(identifyResolve, identifyReject, context, { + waitForNetworkResults: false, + hash: 'initial-hash', + }); + + // Second identify without hash — previous hash should not be forwarded + await mobileDataManager.identify(identifyResolve, identifyReject, context, { + waitForNetworkResults: false, + }); + + const fetchCalls = (platform.requests.fetch as jest.Mock).mock.calls; + const lastCallUrl = fetchCalls[fetchCalls.length - 1][0]; + expect(lastCallUrl).not.toContain('h='); + }); + it('does not include withReasons query parameter when withReasons is false', async () => { const withReasonsConfig = { ...config, withReasons: false }; mobileDataManager = new MobileDataManager( diff --git a/packages/sdk/react-native/src/MobileDataManager.ts b/packages/sdk/react-native/src/MobileDataManager.ts index 0ba41a69ba..c37d6c1dc6 100644 --- a/packages/sdk/react-native/src/MobileDataManager.ts +++ b/packages/sdk/react-native/src/MobileDataManager.ts @@ -22,6 +22,7 @@ export default class MobileDataManager extends BaseDataManager { // Not implemented yet. protected networkAvailable: boolean = true; protected connectionMode: ConnectionMode = 'streaming'; + protected secureModeHash?: string; constructor( platform: Platform, @@ -65,6 +66,9 @@ export default class MobileDataManager extends BaseDataManager { } this.context = context; + // Capture the secure mode hash (if set), so that it can be forwarded to makeRequestor on each re(connection). + this.secureModeHash = identifyOptions?.hash; + // When bootstrap is provided, resolve identify immediately then fall through to connect. if (identifyOptions?.bootstrap) { this._finishIdentifyFromBootstrap(context, identifyOptions, identifyResolve); @@ -137,6 +141,7 @@ export default class MobileDataManager extends BaseDataManager { [], this.config.withReasons, this.config.useReport, + this.secureModeHash, ); this.updateProcessor?.close(); diff --git a/packages/shared/sdk-client/src/api/LDIdentifyOptions.ts b/packages/shared/sdk-client/src/api/LDIdentifyOptions.ts index 20369b8fe7..05d3c85485 100644 --- a/packages/shared/sdk-client/src/api/LDIdentifyOptions.ts +++ b/packages/shared/sdk-client/src/api/LDIdentifyOptions.ts @@ -63,4 +63,13 @@ export interface LDIdentifyOptions { * @hidden */ bootstrapParsed?: { [key: string]: ItemDescriptor }; + + /** + * The secure mode hash for the context being identified. Used to verify the context + * key on the LaunchDarkly server when secure mode is enabled for the environment. + * + * Generate this hash server-side using the SDK key and the context key. + */ + + hash?: string; }