Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions packages/shared/sdk-client/__tests__/context/ensureKey.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,41 @@ describe('ensureKey', () => {
const c = await ensureKey(context, mockPlatform);
expect(c.key).toEqual('random1');
});

it('should migrate anonymous key from legacy namespace', async () => {
const stored: Record<string, string> = {
LaunchDarkly_AnonymousKeys_org: 'migrated-key',
};
(mockPlatform.storage.get as jest.Mock).mockImplementation(
(key: string) => stored[key] ?? null,
);
(mockPlatform.storage.set as jest.Mock).mockImplementation((key: string, value: string) => {
stored[key] = value;
});
(mockPlatform.storage.clear as jest.Mock).mockImplementation((key: string) => {
delete stored[key];
});

const context: LDContext = { kind: 'org', anonymous: true };
const c = await ensureKey(context, mockPlatform);

expect(c.key).toEqual('migrated-key');
expect(mockPlatform.storage.set).toHaveBeenCalledWith(
'LaunchDarkly_ContextKeys_org',
'migrated-key',
);
expect(mockPlatform.storage.clear).toHaveBeenCalledWith('LaunchDarkly_AnonymousKeys_org');
});

it('should use new namespace key when it already exists', async () => {
(mockPlatform.storage.get as jest.Mock).mockImplementation((key: string) =>
key === 'LaunchDarkly_ContextKeys_org' ? 'new-ns-key' : undefined,
);

const context: LDContext = { kind: 'org', anonymous: true };
const c = await ensureKey(context, mockPlatform);

expect(c.key).toEqual('new-ns-key');
expect(mockPlatform.storage.clear).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,80 @@ describe('getOrGenerateKey', () => {
expect(k).toEqual('test-org-key-2');
});
});

describe('legacy key migration', () => {
it('migrates key from legacy location to new location', async () => {
const stored: Record<string, string> = {
LaunchDarkly_AnonymousKeys_org: 'existing-org-key',
};
(storage.get as jest.Mock).mockImplementation((key: string) => stored[key] ?? null);
(storage.set as jest.Mock).mockImplementation((key: string, value: string) => {
stored[key] = value;
});
(storage.clear as jest.Mock).mockImplementation((key: string) => {
delete stored[key];
});

const k = await getOrGenerateKey(
'LaunchDarkly_ContextKeys_org',
mockPlatform,
'LaunchDarkly_AnonymousKeys_org',
);

expect(k).toEqual('existing-org-key');
expect(crypto.randomUUID).not.toHaveBeenCalled();
expect(storage.set).toHaveBeenCalledWith('LaunchDarkly_ContextKeys_org', 'existing-org-key');
expect(storage.clear).toHaveBeenCalledWith('LaunchDarkly_AnonymousKeys_org');
});

it('does not clear legacy key when set silently fails', async () => {
(storage.get as jest.Mock).mockImplementation((key: string) =>
key === 'LaunchDarkly_AnonymousKeys_org' ? 'existing-org-key' : null,
);
// set is a no-op, simulating a silent storage failure
(storage.set as jest.Mock).mockResolvedValue(undefined);

const k = await getOrGenerateKey(
'LaunchDarkly_ContextKeys_org',
mockPlatform,
'LaunchDarkly_AnonymousKeys_org',
);

expect(k).toEqual('existing-org-key');
expect(storage.set).toHaveBeenCalledWith('LaunchDarkly_ContextKeys_org', 'existing-org-key');
expect(storage.clear).not.toHaveBeenCalled();
});

it('does not check legacy key when new key already exists', async () => {
(storage.get as jest.Mock).mockImplementation((key: string) =>
key === 'LaunchDarkly_ContextKeys_org' ? 'new-org-key' : 'legacy-org-key',
);

const k = await getOrGenerateKey(
'LaunchDarkly_ContextKeys_org',
mockPlatform,
'LaunchDarkly_AnonymousKeys_org',
);

expect(k).toEqual('new-org-key');
expect(storage.get).toHaveBeenCalledTimes(1);
expect(storage.get).toHaveBeenCalledWith('LaunchDarkly_ContextKeys_org');
expect(storage.clear).not.toHaveBeenCalled();
});

it('generates new key when neither new nor legacy key exists', async () => {
(storage.get as jest.Mock).mockResolvedValue(null);

const k = await getOrGenerateKey(
'LaunchDarkly_ContextKeys_org',
mockPlatform,
'LaunchDarkly_AnonymousKeys_org',
);

expect(k).toEqual('test-org-key-1');
expect(crypto.randomUUID).toHaveBeenCalled();
expect(storage.set).toHaveBeenCalledWith('LaunchDarkly_ContextKeys_org', 'test-org-key-1');
expect(storage.clear).not.toHaveBeenCalled();
});
});
});
10 changes: 7 additions & 3 deletions packages/shared/sdk-client/src/context/ensureKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import {

import type { LDContext, LDContextStrict } from '../api/LDContext';
import { getOrGenerateKey } from '../storage/getOrGenerateKey';
import { namespaceForAnonymousGeneratedContextKey } from '../storage/namespaceUtils';
import {
namespaceForAnonymousGeneratedContextKey,
namespaceForGeneratedContextKey,
} from '../storage/namespaceUtils';

const { isLegacyUser, isMultiKind, isSingleKind } = internal;

Expand All @@ -31,10 +34,11 @@ const ensureKeyCommon = async (kind: string, c: LDContextCommon, platform: Platf
const { anonymous, key } = c;

if (anonymous && !key) {
const storageKey = await namespaceForAnonymousGeneratedContextKey(kind);
const storageKey = await namespaceForGeneratedContextKey(kind);
const legacyStorageKey = await namespaceForAnonymousGeneratedContextKey(kind);
// This mutates a cloned copy of the original context from ensureyKey so this is safe.
// eslint-disable-next-line no-param-reassign
c.key = await getOrGenerateKey(storageKey, platform);
c.key = await getOrGenerateKey(storageKey, platform, legacyStorageKey);
}
};

Expand Down
28 changes: 24 additions & 4 deletions packages/shared/sdk-client/src/storage/getOrGenerateKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,34 @@ import { namespaceForGeneratedContextKey } from './namespaceUtils';
* @param storageKey keyed storage location where the generated key should live. See {@link namespaceForGeneratedContextKey}
* for related exmaples of generating a storage key and usage.
* @param platform crypto and storage implementations for necessary operations
* @param legacyStorageKey optional legacy storage key to migrate from. If the key is not found
* under {@link storageKey} but exists under this legacy key, it will be migrated to the new
* location and the legacy key will be cleared.
* @returns the generated key
*/
export const getOrGenerateKey = async (storageKey: string, { crypto, storage }: Platform) => {
export const getOrGenerateKey = async (
storageKey: string,
{ crypto, storage }: Platform,
legacyStorageKey?: string,
) => {
let generatedKey = await storage?.get(storageKey);

if (!generatedKey) {
generatedKey = crypto.randomUUID();
await storage?.set(storageKey, generatedKey);
if (generatedKey == null) {
if (legacyStorageKey) {
generatedKey = await storage?.get(legacyStorageKey);
if (generatedKey != null) {
await storage?.set(storageKey, generatedKey);
const verified = await storage?.get(storageKey);
if (verified != null) {
await storage?.clear(legacyStorageKey);
}
}
}

if (generatedKey == null) {
generatedKey = crypto.randomUUID();
await storage?.set(storageKey, generatedKey);
}
}

return generatedKey;
Expand Down
9 changes: 3 additions & 6 deletions packages/shared/sdk-client/src/storage/namespaceUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,9 @@ export async function namespaceForEnvironment(crypto: Crypto, sdkKey: string): P
}

/**
* @deprecated prefer {@link namespaceForGeneratedContextKey}. At one time we only generated keys for
* anonymous contexts and they were namespaced in LaunchDarkly_AnonymousKeys. Eventually we started
* generating context keys for non-anonymous contexts such as for the Auto Environment Attributes
* feature and those were namespaced in LaunchDarkly_ContextKeys. This function can be removed
* when the data under the LaunchDarkly_AnonymousKeys namespace is merged with data under the
* LaunchDarkly_ContextKeys namespace.
* @deprecated Used only for migration in ensureKey. Data stored under LaunchDarkly_AnonymousKeys
* is now migrated to LaunchDarkly_ContextKeys on first access. This function can be removed once
* all clients have had the chance to run the migration.
*/
export async function namespaceForAnonymousGeneratedContextKey(kind: string): Promise<string> {
return concatNamespacesAndValues([
Expand Down
Loading