Skip to content

Commit

Permalink
Merge pull request #174 from frontegg/next
Browse files Browse the repository at this point in the history
FeatureFlags FTW
  • Loading branch information
eran-frontegg authored Nov 2, 2023
2 parents 4f43917 + 5fdafa5 commit 0a7eafe
Show file tree
Hide file tree
Showing 26 changed files with 667 additions and 229 deletions.
28 changes: 28 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,31 @@
# [5.2.0-alpha.3](https://github.com/frontegg/nodejs-sdk/compare/5.2.0-alpha.2...5.2.0-alpha.3) (2023-10-31)


### Bug Fixes

* general fixes ([#173](https://github.com/frontegg/nodejs-sdk/issues/173)) ([d386c5d](https://github.com/frontegg/nodejs-sdk/commit/d386c5d9c8b1c8593ed5f22330a620aec4d75bd7))

# [5.2.0-alpha.2](https://github.com/frontegg/nodejs-sdk/compare/5.2.0-alpha.1...5.2.0-alpha.2) (2023-10-31)


### Bug Fixes

* upgrade commons lib ([#172](https://github.com/frontegg/nodejs-sdk/issues/172)) ([674ec51](https://github.com/frontegg/nodejs-sdk/commit/674ec5142b6f89a25612d22be2edeeb2c77be01a))

# [5.2.0-alpha.1](https://github.com/frontegg/nodejs-sdk/compare/5.1.3-alpha.1...5.2.0-alpha.1) (2023-10-31)


### Features

* **feature-flags:** introduced feature-flags functionality ([#165](https://github.com/frontegg/nodejs-sdk/issues/165)) ([1923129](https://github.com/frontegg/nodejs-sdk/commit/1923129f669811f52ff2afc451de6cbf7e033b67))

## [5.1.3-alpha.1](https://github.com/frontegg/nodejs-sdk/compare/5.1.2...5.1.3-alpha.1) (2023-10-31)


### Bug Fixes

* remove redundant imports ([#166](https://github.com/frontegg/nodejs-sdk/issues/166)) ([680a54f](https://github.com/frontegg/nodejs-sdk/commit/680a54f880a123f9c7d7e39080e49a4f88acf5f8))

## [5.1.2](https://github.com/frontegg/nodejs-sdk/compare/5.1.1...5.1.2) (2023-10-18)


Expand Down
29 changes: 29 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"license": "ISC",
"homepage": "https://github.com/frontegg/nodejs-sdk",
"dependencies": {
"@frontegg/entitlements-javascript-commons": "^1.0.0",
"@slack/web-api": "^6.7.2",
"axios": "^0.27.2",
"jsonwebtoken": "^9.0.0",
Expand All @@ -41,6 +42,7 @@
}
},
"devDependencies": {
"@fast-check/jest": "^1.7.3",
"@semantic-release/changelog": "^6.0.1",
"@semantic-release/git": "^10.0.1",
"@types/axios-mock-adapter": "^1.10.0",
Expand Down
3 changes: 3 additions & 0 deletions src/clients/entitlements/api-types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// TODO: remove this completely some time to not replicate API types
export * as VendorEntitlementsV1 from './vendor-entitlements/v1';

Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { Id as FeatureId } from './feature';

export type Id = string;

export type Tuple = [Id, FeatureId[]];
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export type Id = string;
export type Key = string;
type PermissionId = string;

export type Tuple = [Id, Key, PermissionId[]];
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type * as FeatureSet from './feature-set';

type TenantId = string;
type UserId = string;
type ExpirationDate = string;
export type Tuple = [FeatureSet.Id, TenantId, UserId | undefined, ExpirationDate | undefined];

export * as Feature from './feature';
export * as FeatureSet from './feature-set';
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { Feature } from '../entitlements';
import type { OperationEnum } from '@frontegg/entitlements-javascript-commons';

type On = boolean;
type TreatmentEnum = 'true' | 'false';
type DefaultTreatment = TreatmentEnum;
type OffTreatment = TreatmentEnum;
type TreatmentType = 'boolean';

export interface IRule {
description: string;
conditionLogic: 'and';
conditions: {
attribute: string;
attributeType: 'custom' | 'frontegg';
negate: boolean;
op: OperationEnum;
value: Record<string, any>;
}[];
treatment: TreatmentEnum;
}

export type Tuple = [Feature.Key, On, TreatmentType, DefaultTreatment, OffTreatment, IRule[]];
15 changes: 15 additions & 0 deletions src/clients/entitlements/api-types/vendor-entitlements/v1/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import * as Entitlements from './entitlements';
import * as FeatureFlags from './feature-flags';

export * as FeatureFlags from './feature-flags';
export * as Entitlements from './entitlements';

export type GetDTO = {
data: {
features: Entitlements.Feature.Tuple[];
featureBundles: Entitlements.FeatureSet.Tuple[];
entitlements: Entitlements.Tuple[];
featureFlags: FeatureFlags.Tuple[];
};
snapshotOffset: number;
};
9 changes: 6 additions & 3 deletions src/clients/entitlements/entitlements-client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import { FronteggContext } from '../../components/frontegg-context';
import { FronteggAuthenticator } from '../../authenticator';
import { HttpClient } from '../http';
import { mock, mockClear } from 'jest-mock-extended';
import { VendorEntitlementsDto, VendorEntitlementsSnapshotOffsetDto } from './types';
import { VendorEntitlementsSnapshotOffsetDto } from './types';
import { AxiosResponse } from 'axios';
import * as Sinon from 'sinon';
import { useFakeTimers } from 'sinon';
import { IUserAccessTokenWithRoles, tokenTypes } from '../identity/types';
import { EntitlementsUserScoped } from './entitlements.user-scoped';
import { InMemoryEntitlementsCache } from './storage/in-memory/in-memory.cache';
import type { VendorEntitlementsV1 } from './api-types';

const { EntitlementsUserScoped: EntitlementsUserScopedActual } = jest.requireActual('./entitlements.user-scoped');

Expand Down Expand Up @@ -38,10 +39,11 @@ describe(EntitlementsClient.name, () => {
entitlements: [],
features: [],
featureBundles: [],
featureFlags: [],
},
snapshotOffset: 1234,
},
} as unknown as AxiosResponse<VendorEntitlementsDto>);
} as unknown as AxiosResponse<VendorEntitlementsV1.GetDTO>);
httpMock.get.calledWith('/api/v1/vendor-entitlements-snapshot-offset').mockResolvedValue({
data: {
snapshotOffset: 1234,
Expand Down Expand Up @@ -123,10 +125,11 @@ describe(EntitlementsClient.name, () => {
entitlements: [],
features: [],
featureBundles: [],
featureFlags: [],
},
snapshotOffset: 2345,
},
} as unknown as AxiosResponse<VendorEntitlementsDto>);
} as unknown as AxiosResponse<VendorEntitlementsV1.GetDTO>);

mockClear(httpMock);
});
Expand Down
26 changes: 23 additions & 3 deletions src/clients/entitlements/entitlements-client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { IFronteggContext } from '../../components/frontegg-context/types';
import { FronteggContext } from '../../components/frontegg-context';
import { FronteggAuthenticator } from '../../authenticator';
import { EntitlementsClientOptions, VendorEntitlementsDto, VendorEntitlementsSnapshotOffsetDto } from './types';
import { EntitlementsClientOptions, VendorEntitlementsSnapshotOffsetDto } from './types';
import { config } from '../../config';
import { HttpClient } from '../http';
import Logger from '../../components/logger';
Expand All @@ -10,8 +10,12 @@ import * as events from 'events';
import { EntitlementsClientEvents } from './entitlements-client.events';
import { EntitlementsCache } from './storage/types';
import { InMemoryEntitlementsCache } from './storage/in-memory/in-memory.cache';
import { TEntity } from '../identity/types';
import { TEntity, TUserEntity } from '../identity/types';
import { EntitlementsUserScoped } from './entitlements.user-scoped';
import type { VendorEntitlementsV1 } from './api-types';
import { IdentityClient } from '../identity';
import { CustomAttributes, prepareAttributes } from '@frontegg/entitlements-javascript-commons';
import { appendUserIdAttribute } from './helpers/frontegg-entity.helper';

export class EntitlementsClient extends events.EventEmitter {
// periodical refresh handler
Expand Down Expand Up @@ -64,8 +68,24 @@ export class EntitlementsClient extends events.EventEmitter {
return new EntitlementsUserScoped<T>(entity, this.cache);
}

async forFronteggToken(token: string): Promise<EntitlementsUserScoped> {
if (!this.cache) {
throw new Error('EntitlementsClient is not initialized yet.');
}

const tokenData = await IdentityClient.getInstance().validateToken(token);
const customAttributes = appendUserIdAttribute(
prepareAttributes({
jwt: tokenData,
}) as CustomAttributes,
tokenData as TUserEntity,
);

return new EntitlementsUserScoped(tokenData, this.cache, customAttributes);
}

private async loadVendorEntitlements(): Promise<void> {
const entitlementsData = await this.httpClient.get<VendorEntitlementsDto>('/api/v1/vendor-entitlements');
const entitlementsData = await this.httpClient.get<VendorEntitlementsV1.GetDTO>('/api/v1/vendor-entitlements');

const vendorEntitlementsDto = entitlementsData.data;
const newOffset = entitlementsData.data.snapshotOffset;
Expand Down
82 changes: 80 additions & 2 deletions src/clients/entitlements/entitlements.user-scoped.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import { IUser, IUserAccessToken, IUserApiToken, TEntityWithRoles, tokenTypes }
import { mock, mockReset } from 'jest-mock-extended';
import { EntitlementsCache, NO_EXPIRE } from './storage/types';
import { EntitlementJustifications } from './types';
import { evaluateFeatureFlag, TreatmentEnum } from '@frontegg/entitlements-javascript-commons';
import SpyInstance = jest.SpyInstance;
import { FeatureFlag } from '@frontegg/entitlements-javascript-commons/dist/feature-flags/types';

jest.mock('@frontegg/entitlements-javascript-commons');

const userApiTokenBase: Pick<
IUserApiToken,
Expand Down Expand Up @@ -43,6 +47,7 @@ describe(EntitlementsUserScoped.name, () => {

afterEach(() => {
mockReset(cacheMock);
jest.mocked(evaluateFeatureFlag).mockReset();
});

describe.each([
Expand Down Expand Up @@ -116,6 +121,9 @@ describe(EntitlementsUserScoped.name, () => {
cacheMock.getEntitlementExpirationTime
.calledWith('bar', 'the-tenant-id', undefined)
.mockResolvedValue(undefined);
cacheMock.getFeatureFlags.calledWith('bar').mockResolvedValue([
// given: no feature flags
]);
});

afterEach(() => {
Expand Down Expand Up @@ -148,6 +156,52 @@ describe(EntitlementsUserScoped.name, () => {
});
});
});

describe('and no entitlement to "bar" has ever been granted to user', () => {
const dummyFF: FeatureFlag = {
on: true,
offTreatment: TreatmentEnum.False,
defaultTreatment: TreatmentEnum.True,
};

beforeEach(() => {
cacheMock.getFeatureFlags.calledWith('bar').mockResolvedValue([dummyFF]);
cacheMock.getEntitlementExpirationTime
.calledWith('bar', entity.tenantId, entity.userId)
.mockResolvedValue(undefined);
});

describe('and feature flag is enabled for the user', () => {
beforeEach(() => {
jest.mocked(evaluateFeatureFlag).mockReturnValue({ treatment: TreatmentEnum.True });
});

it('when .isEntitledTo({ permissionKey: "foo" }) is executed, then it resolves to TRUE treatment.', async () => {
await expect(cut.isEntitledTo({ permissionKey: 'foo' })).resolves.toEqual({
result: true,
});

// and: feature flag has been evaluated
expect(evaluateFeatureFlag).toHaveBeenCalledWith(dummyFF, expect.anything());
});
});

describe('and feature flag is disabled for the user', () => {
beforeEach(() => {
jest.mocked(evaluateFeatureFlag).mockReturnValue({ treatment: TreatmentEnum.False });
});

it('when .isEntitledTo({ permissionKey: "foo" }) is executed, then the user is not entitled with "missing feature" justification.', async () => {
await expect(cut.isEntitledTo({ permissionKey: 'foo' })).resolves.toEqual({
result: false,
justification: EntitlementJustifications.MISSING_FEATURE,
});

// and: feature flag has been evaluated
expect(evaluateFeatureFlag).toHaveBeenCalledWith(dummyFF, expect.anything());
});
});
});
});

describe('and no feature is linked to permissions "foo" and "bar"', () => {
Expand Down Expand Up @@ -199,7 +253,7 @@ describe(EntitlementsUserScoped.name, () => {
await cut.isEntitledTo({ permissionKey: 'foo' });

// then
expect(isEntitledToPermissionSpy).toHaveBeenCalledWith('foo');
expect(isEntitledToPermissionSpy).toHaveBeenCalledWith('foo', {});
expect(isEntitledToFeatureSpy).not.toHaveBeenCalled();
});

Expand All @@ -209,9 +263,33 @@ describe(EntitlementsUserScoped.name, () => {

// then
expect(isEntitledToPermissionSpy).not.toHaveBeenCalled();
expect(isEntitledToFeatureSpy).toHaveBeenCalledWith('foo');
expect(isEntitledToFeatureSpy).toHaveBeenCalledWith('foo', {});
});

it.each([
{
key: 'featureKey' as const,
method: 'isEntitledToFeature',
run: (attrs) => cut.isEntitledTo({ featureKey: 'foo' }, attrs),
getSpy: () => isEntitledToFeatureSpy,
},
{
key: 'permissionKey' as const,
method: 'isEntitledToPermission',
run: (attrs) => cut.isEntitledTo({ permissionKey: 'foo' }, attrs),
getSpy: () => isEntitledToPermissionSpy,
},
])(
'with $key and additional attributes, then they are passed down to $method method.',
async ({ key, run, getSpy }) => {
// when
await run({ bar: 'baz' });

// then
expect(getSpy()).toHaveBeenCalledWith('foo', { bar: 'baz' });
},
);

it('with both featureKey and permissionKey, then the Error is thrown.', async () => {
// when & then
await expect(
Expand Down
Loading

0 comments on commit 0a7eafe

Please sign in to comment.