Skip to content

Commit

Permalink
fix: Implement anonymous context processing (#350)
Browse files Browse the repository at this point in the history
Apologies, I missed implementing this when moving the project to
js-core.
  • Loading branch information
yusinto authored Jan 19, 2024
1 parent 2c6add5 commit 308100d
Show file tree
Hide file tree
Showing 11 changed files with 302 additions and 63 deletions.
43 changes: 8 additions & 35 deletions packages/shared/common/src/Context.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
/* eslint-disable no-underscore-dangle */
// eslint-disable-next-line max-classes-per-file
import { LDContextCommon, LDMultiKindContext, LDSingleKindContext, LDUser } from './api/context';
import { LDContext } from './api/context/LDContext';
import type {
LDContext,
LDContextCommon,
LDMultiKindContext,
LDSingleKindContext,
LDUser,
} from './api';
import AttributeReference from './AttributeReference';
import { isLegacyUser, isMultiKind, isSingleKind } from './internal/context';
import { TypeValidators } from './validators';

// The general strategy for the context is to transform the passed in context
Expand Down Expand Up @@ -38,39 +44,6 @@ function encodeKey(key: string): string {
return key;
}

/**
* Check if a context is a single kind context.
* @param context
* @returns true if the context is a single kind context.
*/
function isSingleKind(context: LDContext): context is LDSingleKindContext {
if ('kind' in context) {
return TypeValidators.String.is(context.kind) && context.kind !== 'multi';
}
return false;
}

/**
* Check if a context is a multi-kind context.
* @param context
* @returns true if it is a multi-kind context.
*/
function isMultiKind(context: LDContext): context is LDMultiKindContext {
if ('kind' in context) {
return TypeValidators.String.is(context.kind) && context.kind === 'multi';
}
return false;
}

/**
* Check if a context is a legacy user context.
* @param context
* @returns true if it is a legacy user context.
*/
function isLegacyUser(context: LDContext): context is LDUser {
return !('kind' in context) || context.kind === null || context.kind === undefined;
}

/**
* Check if the given value is a LDContextCommon.
* @param kindOrContext
Expand Down
39 changes: 39 additions & 0 deletions packages/shared/common/src/internal/context/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Internal use only. These functions should only be used as part of the initial validation of
* the LDContext object. Thereafter, the Context object should be used.
*/
import type { LDContext, LDMultiKindContext, LDSingleKindContext, LDUser } from '../../api';
import { TypeValidators } from '../../validators';

/**
* Check if a context is a single kind context.
* @param context
* @returns true if the context is a single kind context.
*/
export function isSingleKind(context: LDContext): context is LDSingleKindContext {
if ('kind' in context) {
return TypeValidators.String.is(context.kind) && context.kind !== 'multi';
}
return false;
}

/**
* Check if a context is a multi-kind context.
* @param context
* @returns true if it is a multi-kind context.
*/
export function isMultiKind(context: LDContext): context is LDMultiKindContext {
if ('kind' in context) {
return TypeValidators.String.is(context.kind) && context.kind === 'multi';
}
return false;
}

/**
* Check if a context is a legacy user context.
* @param context
* @returns true if it is a legacy user context.
*/
export function isLegacyUser(context: LDContext): context is LDUser {
return !('kind' in context) || context.kind === null || context.kind === undefined;
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ describe('given a diagnostics manager', () => {
});

beforeEach(() => {
basicPlatform.crypto.randomUUID.mockReturnValueOnce('random1').mockReturnValueOnce('random2');
manager = new DiagnosticsManager('my-sdk-key', basicPlatform, { test1: 'value1' });
});

Expand Down
1 change: 1 addition & 0 deletions packages/shared/common/src/internal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './diagnostics';
export * from './evaluation';
export * from './events';
export * from './stream';
export * from './context';
4 changes: 2 additions & 2 deletions packages/shared/mocks/src/hasher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ export const crypto: Crypto = {
// Not used for this test.
throw new Error(`Function not implemented.${algorithm}${key}`);
},
randomUUID(): string {
randomUUID: jest.fn(() => {
counter += 1;
// Will provide a unique value for tests.
// Very much not a UUID of course.
return `${counter}`;
},
}),
};
16 changes: 16 additions & 0 deletions packages/shared/sdk-client/src/LDClientImpl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ jest.mock('@launchdarkly/js-sdk-common', () => {
};
});

const { crypto } = basicPlatform;
const testSdkKey = 'test-sdk-key';
const context: LDContext = { kind: 'org', key: 'Testy Pizza' };
let ldc: LDClientImpl;
Expand All @@ -28,6 +29,7 @@ describe('sdk-client object', () => {
beforeEach(() => {
defaultPutResponse = clone<Flags>(mockResponseJson);
setupMockStreamingProcessor(false, defaultPutResponse);
crypto.randomUUID.mockReturnValueOnce('random1');

ldc = new LDClientImpl(testSdkKey, basicPlatform, { logger, sendEvents: false });
jest
Expand Down Expand Up @@ -133,6 +135,20 @@ describe('sdk-client object', () => {
});
});

test('identify anonymous', async () => {
defaultPutResponse['dev-test-flag'].value = false;
const carContext: LDContext = { kind: 'car', anonymous: true, key: '' };

await ldc.identify(carContext);
const c = ldc.getContext();
const all = ldc.allFlags();

expect(c!.key).toEqual('random1');
expect(all).toMatchObject({
'dev-test-flag': false,
});
});

test('identify error invalid context', async () => {
// @ts-ignore
const carContext: LDContext = { kind: 'car', key: undefined };
Expand Down
6 changes: 4 additions & 2 deletions packages/shared/sdk-client/src/LDClientImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import createDiagnosticsManager from './diagnostics/createDiagnosticsManager';
import createEventProcessor from './events/createEventProcessor';
import EventFactory from './events/EventFactory';
import { DeleteFlag, Flags, PatchFlag } from './types';
import { calculateFlagChanges } from './utils';
import { calculateFlagChanges, ensureKey } from './utils';

const { createErrorEvaluationDetail, createSuccessEvaluationDetail, ClientMessages, ErrorKinds } =
internal;
Expand Down Expand Up @@ -231,7 +231,9 @@ export default class LDClientImpl implements LDClient {
}

// TODO: implement secure mode
async identify(context: LDContext, _hash?: string): Promise<void> {
async identify(pristineContext: LDContext, _hash?: string): Promise<void> {
const context = await ensureKey(pristineContext, this.platform);

const checkedContext = Context.fromLDContext(context);
if (!checkedContext.valid) {
const error = new Error('Context was unspecified or had no key');
Expand Down
25 changes: 25 additions & 0 deletions packages/shared/sdk-client/src/utils/calculateFlagChanges.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { fastDeepEqual } from '@launchdarkly/js-sdk-common';

import { Flags } from '../types';

// eslint-disable-next-line import/prefer-default-export
export default function calculateFlagChanges(flags: Flags, incomingFlags: Flags) {
const changedKeys: string[] = [];

// flag deleted or updated
Object.entries(flags).forEach(([k, f]) => {
const incoming = incomingFlags[k];
if (!incoming || !fastDeepEqual(f, incoming)) {
changedKeys.push(k);
}
});

// flag added
Object.keys(incomingFlags).forEach((k) => {
if (!flags[k]) {
changedKeys.push(k);
}
});

return changedKeys;
}
116 changes: 116 additions & 0 deletions packages/shared/sdk-client/src/utils/ensureKey.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import type {
LDContext,
LDContextCommon,
LDMultiKindContext,
LDUser,
} from '@launchdarkly/js-sdk-common';
import { basicPlatform } from '@launchdarkly/private-js-mocks';

import ensureKey, { addNamespace, getOrGenerateKey } from './ensureKey';

const { crypto, storage } = basicPlatform;
describe('ensureKey', () => {
beforeEach(() => {
crypto.randomUUID.mockReturnValueOnce('random1').mockReturnValueOnce('random2');
});

afterEach(() => {
jest.resetAllMocks();
});

test('addNamespace', async () => {
const nsKey = addNamespace('org');
expect(nsKey).toEqual('LaunchDarkly_AnonKeys_org');
});

test('getOrGenerateKey create new key', async () => {
const key = await getOrGenerateKey('org', basicPlatform);

expect(key).toEqual('random1');
expect(crypto.randomUUID).toHaveBeenCalled();
expect(storage.get).toHaveBeenCalledWith('LaunchDarkly_AnonKeys_org');
expect(storage.set).toHaveBeenCalledWith('LaunchDarkly_AnonKeys_org', 'random1');
});

test('getOrGenerateKey existing key', async () => {
storage.get.mockImplementation((nsKind: string) =>
nsKind === 'LaunchDarkly_AnonKeys_org' ? 'random1' : undefined,
);

const key = await getOrGenerateKey('org', basicPlatform);

expect(key).toEqual('random1');
expect(crypto.randomUUID).not.toHaveBeenCalled();
expect(storage.get).toHaveBeenCalledWith('LaunchDarkly_AnonKeys_org');
expect(storage.set).not.toHaveBeenCalled();
});

test('ensureKey should not override anonymous key if specified', async () => {
const context: LDContext = { kind: 'org', anonymous: true, key: 'Testy Pizza' };
const c = await ensureKey(context, basicPlatform);

expect(c.key).toEqual('Testy Pizza');
});

test('ensureKey non-anonymous single context should be unchanged', async () => {
const context: LDContext = { kind: 'org', key: 'Testy Pizza' };
const c = await ensureKey(context, basicPlatform);

expect(c.key).toEqual('Testy Pizza');
expect(c.anonymous).toBeFalsy();
});

test('ensureKey non-anonymous contexts in multi should be unchanged', async () => {
const context: LDContext = {
kind: 'multi',
user: { key: 'userKey' },
org: { key: 'orgKey' },
};

const c = (await ensureKey(context, basicPlatform)) as LDMultiKindContext;

expect((c.user as LDContextCommon).key).toEqual('userKey');
expect((c.org as LDContextCommon).key).toEqual('orgKey');
});

test('ensureKey should create key for single anonymous context', async () => {
const context: LDContext = { kind: 'org', anonymous: true, key: '' };
const c = await ensureKey(context, basicPlatform);
expect(c.key).toEqual('random1');
});

test('ensureKey should create key for an anonymous context in multi', async () => {
const context: LDContext = {
kind: 'multi',
user: { anonymous: true, key: '' },
org: { key: 'orgKey' },
};

const c = (await ensureKey(context, basicPlatform)) as LDMultiKindContext;

expect((c.user as LDContextCommon).key).toEqual('random1');
expect((c.org as LDContextCommon).key).toEqual('orgKey');
});

test('ensureKey should create key for all anonymous contexts in multi', async () => {
const context: LDContext = {
kind: 'multi',
user: { anonymous: true, key: '' },
org: { anonymous: true, key: '' },
};

const c = (await ensureKey(context, basicPlatform)) as LDMultiKindContext;

expect((c.user as LDContextCommon).key).toEqual('random1');
expect((c.org as LDContextCommon).key).toEqual('random2');
});

test('ensureKey should create key for anonymous legacy user', async () => {
const context: LDUser = {
anonymous: true,
key: '',
};
const c = await ensureKey(context, basicPlatform);
expect(c.key).toEqual('random1');
});
});
Loading

0 comments on commit 308100d

Please sign in to comment.