@frontegg/entitlements-javascript-commons 1.0.0-alpha.4 → 1.0.0-alpha.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/conditions/condition.evaluator.js +13 -1
  2. package/dist/conditions/condition.evaluator.js.map +1 -1
  3. package/dist/conditions/types.d.ts +2 -2
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.js +4 -1
  6. package/dist/index.js.map +1 -1
  7. package/dist/operations/boolean/index.d.ts +1 -0
  8. package/dist/operations/boolean/index.js +1 -0
  9. package/dist/operations/boolean/index.js.map +1 -1
  10. package/dist/operations/boolean/sanitizers.d.ts +4 -0
  11. package/dist/operations/boolean/sanitizers.js +17 -0
  12. package/dist/operations/boolean/sanitizers.js.map +1 -0
  13. package/dist/operations/components/sanitizers.resolver.d.ts +2 -0
  14. package/dist/operations/components/sanitizers.resolver.js +18 -0
  15. package/dist/operations/components/sanitizers.resolver.js.map +1 -0
  16. package/dist/operations/date/sanitizers.d.ts +5 -0
  17. package/dist/operations/date/sanitizers.js +27 -0
  18. package/dist/operations/date/sanitizers.js.map +1 -0
  19. package/dist/operations/numeric/index.d.ts +1 -0
  20. package/dist/operations/numeric/index.js +1 -0
  21. package/dist/operations/numeric/index.js.map +1 -1
  22. package/dist/operations/numeric/sanitizers.d.ts +5 -0
  23. package/dist/operations/numeric/sanitizers.js +32 -0
  24. package/dist/operations/numeric/sanitizers.js.map +1 -0
  25. package/dist/operations/string/sanitizers.d.ts +5 -0
  26. package/dist/operations/string/sanitizers.js +31 -0
  27. package/dist/operations/string/sanitizers.js.map +1 -0
  28. package/dist/operations/types/index.d.ts +9 -0
  29. package/dist/operations/types/index.js.map +1 -1
  30. package/dist/user-entitlements/index.d.ts +1 -0
  31. package/dist/user-entitlements/index.js +18 -0
  32. package/dist/user-entitlements/index.js.map +1 -0
  33. package/dist/user-entitlements/is-entitled.evaluator.d.ts +3 -0
  34. package/dist/user-entitlements/is-entitled.evaluator.js +53 -0
  35. package/dist/user-entitlements/is-entitled.evaluator.js.map +1 -0
  36. package/dist/user-entitlements/types.d.ts +20 -0
  37. package/dist/user-entitlements/types.js +11 -0
  38. package/dist/user-entitlements/types.js.map +1 -0
  39. package/docs/CHANGELOG.md +14 -0
  40. package/package.json +1 -1
  41. package/src/conditions/condition.evaluator.ts +16 -1
  42. package/src/conditions/types.ts +2 -2
  43. package/src/index.ts +1 -0
  44. package/src/operations/boolean/index.ts +1 -0
  45. package/src/operations/boolean/sanitizers.ts +18 -0
  46. package/src/operations/boolean/{operations.spec.ts → tests/operations.spec.ts} +1 -1
  47. package/src/operations/boolean/tests/sanitizers.spec.ts +22 -0
  48. package/src/operations/components/sanitizers.resolver.ts +16 -0
  49. package/src/operations/components/tests/sanitizers.resolver.spec.ts +14 -0
  50. package/src/operations/date/sanitizers.ts +27 -0
  51. package/src/operations/date/{operations.spec.ts → tests/operations.spec.ts} +1 -1
  52. package/src/operations/date/tests/sanitizers.spec.ts +43 -0
  53. package/src/operations/numeric/index.ts +1 -0
  54. package/src/operations/numeric/sanitizers.ts +34 -0
  55. package/src/operations/numeric/tests/sanitizers.spec.ts +44 -0
  56. package/src/operations/string/sanitizers.ts +33 -0
  57. package/src/operations/string/tests/sanitizers.spec.ts +45 -0
  58. package/src/operations/types/index.ts +8 -0
  59. package/src/user-entitlements/index.ts +1 -0
  60. package/src/user-entitlements/is-entitled.evaluator.ts +71 -0
  61. package/src/user-entitlements/tests/is-entitled.evaluator.spec.ts +291 -0
  62. package/src/user-entitlements/types.ts +27 -0
  63. package/src/operations/numeric/operations.spec.ts +0 -63
  64. package/src/operations/string/operations.spec.ts +0 -38
@@ -0,0 +1,14 @@
1
+ import { useSanitizer } from '../sanitizers.resolver';
2
+ import { OperationEnum } from '../../types';
3
+
4
+ describe('SanitizerResolver', () => {
5
+ it('should return undefined when operation is not supported', () => {
6
+ const sanitizer = useSanitizer('not supported' as any);
7
+ expect(sanitizer).toEqual(undefined);
8
+ });
9
+
10
+ it('should return sanitizer when operation is supported', () => {
11
+ const sanitizer = useSanitizer(OperationEnum.On);
12
+ expect(sanitizer).toEqual(expect.any(Function));
13
+ });
14
+ });
@@ -0,0 +1,27 @@
1
+ import { OperationEnum, Sanitizer, SanitizersMapper } from '../types';
2
+ import { BetweenDateOperationPayload, DateOperationPayload, SingleDateOperationPayload } from './types';
3
+
4
+ export const sanitizeSingleDate: Sanitizer<SingleDateOperationPayload> = (value) => {
5
+ const sanitizedValue = value.date ? { date: value.date as Date } : undefined;
6
+
7
+ return {
8
+ isSanitized: !!sanitizedValue,
9
+ sanitizedValue,
10
+ };
11
+ };
12
+
13
+ export const sanitizeDateRange: Sanitizer<BetweenDateOperationPayload> = (value) => {
14
+ const sanitizedValue = value.start && value.end ? { start: value.start as Date, end: value.end as Date } : undefined;
15
+
16
+ return {
17
+ isSanitized: !!sanitizedValue,
18
+ sanitizedValue,
19
+ };
20
+ };
21
+
22
+ export const DateSanitizersMapper: SanitizersMapper<DateOperationPayload> = {
23
+ [OperationEnum.On]: sanitizeSingleDate,
24
+ [OperationEnum.OnOrAfter]: sanitizeDateRange,
25
+ [OperationEnum.OnOrBefore]: sanitizeSingleDate,
26
+ [OperationEnum.BetweenDate]: sanitizeSingleDate,
27
+ };
@@ -3,7 +3,7 @@ import {
3
3
  useDateOnOperation,
4
4
  useDateOnOrAfterOperation,
5
5
  useDateOnOrBeforeOperation,
6
- } from './index';
6
+ } from '../index';
7
7
  import { fc, test } from '@fast-check/jest';
8
8
 
9
9
  describe('Date operations', () => {
@@ -0,0 +1,43 @@
1
+ import { fc, test } from '@fast-check/jest';
2
+ import { sanitizeDateRange, sanitizeSingleDate } from '../sanitizers';
3
+
4
+ describe('Date sanitizers', () => {
5
+ test.prop([fc.record({ date: fc.date() })], { verbose: true })(
6
+ 'should return sanitized when date value exists',
7
+ (value) => {
8
+ const sanitizationResult = sanitizeSingleDate(value);
9
+
10
+ expect(sanitizationResult.isSanitized).toBe(true);
11
+ expect(typeof sanitizationResult.sanitizedValue).toBe('object');
12
+ expect(sanitizationResult.sanitizedValue).toEqual({ date: value.date });
13
+ },
14
+ );
15
+
16
+ test.prop([fc.object()], { verbose: true })('should fail sanitization when date value does not exist', (value) => {
17
+ const sanitizationResult = sanitizeSingleDate(value);
18
+
19
+ expect(sanitizationResult.isSanitized).toBe(false);
20
+ expect(sanitizationResult.sanitizedValue).toBeUndefined();
21
+ });
22
+
23
+ test.prop([fc.record({ start: fc.date(), end: fc.date() })], { verbose: true })(
24
+ 'should return sanitized when start and end values exist',
25
+ (value) => {
26
+ const sanitizationResult = sanitizeDateRange(value);
27
+
28
+ expect(sanitizationResult.isSanitized).toBe(true);
29
+ expect(typeof sanitizationResult.sanitizedValue).toBe('object');
30
+ expect(sanitizationResult.sanitizedValue).toEqual({ start: value.start, end: value.end });
31
+ },
32
+ );
33
+
34
+ test.prop([fc.object()], { verbose: true })(
35
+ 'should fail sanitization when start and end values do not exist',
36
+ (value) => {
37
+ const sanitizationResult = sanitizeDateRange(value);
38
+
39
+ expect(sanitizationResult.isSanitized).toBe(false);
40
+ expect(sanitizationResult.sanitizedValue).toBeUndefined();
41
+ },
42
+ );
43
+ });
@@ -10,6 +10,7 @@ import {
10
10
  import { BetweenNumericOperationPayload, SingleNumericOperationPayload } from './types';
11
11
 
12
12
  export * from './operations';
13
+ export * from './sanitizers';
13
14
  export * from './types';
14
15
 
15
16
  export const NumericOperationsMapper: OperationsMapper = {
@@ -0,0 +1,34 @@
1
+ import { OperationEnum, Sanitizer, SanitizersMapper } from '../types';
2
+ import { BetweenNumericOperationPayload, NumericOperationPayload, SingleNumericOperationPayload } from './types';
3
+
4
+ const isNumber = (value: unknown): value is number => typeof value === 'number';
5
+
6
+ export const sanitizeSingleNumber: Sanitizer<SingleNumericOperationPayload> = (value) => {
7
+ const sanitizedValue = value.number !== undefined && isNumber(value.number) ? { number: value.number } : undefined;
8
+
9
+ return {
10
+ isSanitized: !!sanitizedValue,
11
+ sanitizedValue,
12
+ };
13
+ };
14
+
15
+ export const sanitizeNumericRange: Sanitizer<BetweenNumericOperationPayload> = (value) => {
16
+ const sanitizedValue =
17
+ value.start !== undefined && value.end !== undefined && isNumber(value.start) && isNumber(value.end)
18
+ ? { start: value.start, end: value.end }
19
+ : undefined;
20
+
21
+ return {
22
+ isSanitized: !!sanitizedValue,
23
+ sanitizedValue,
24
+ };
25
+ };
26
+
27
+ export const NumericSanitizersMapper: SanitizersMapper<NumericOperationPayload> = {
28
+ [OperationEnum.Equal]: sanitizeSingleNumber,
29
+ [OperationEnum.GreaterThan]: sanitizeNumericRange,
30
+ [OperationEnum.GreaterThanEqual]: sanitizeSingleNumber,
31
+ [OperationEnum.LesserThan]: sanitizeSingleNumber,
32
+ [OperationEnum.LesserThanEqual]: sanitizeNumericRange,
33
+ [OperationEnum.BetweenNumeric]: sanitizeNumericRange,
34
+ };
@@ -0,0 +1,44 @@
1
+ import { fc, test } from '@fast-check/jest';
2
+ import { sanitizeNumericRange, sanitizeSingleNumber } from '../sanitizers';
3
+
4
+ describe('Numeric sanitizers', () => {
5
+ test.prop([fc.record({ number: fc.integer() })], { verbose: true })('should return sanitized number', (value) => {
6
+ const sanitizationResult = sanitizeSingleNumber(value);
7
+
8
+ expect(sanitizationResult.isSanitized).toBe(true);
9
+ expect(typeof sanitizationResult.sanitizedValue?.number).toBe('number');
10
+ expect(sanitizationResult.sanitizedValue).toEqual({ number: value.number });
11
+ });
12
+
13
+ test.prop([fc.object()], { verbose: true })(
14
+ 'should not return sanitization values when number does not exist in value',
15
+ (value) => {
16
+ const sanitizationResult = sanitizeSingleNumber(value);
17
+
18
+ expect(sanitizationResult.isSanitized).toBe(false);
19
+ expect(sanitizationResult.sanitizedValue).toBeUndefined();
20
+ },
21
+ );
22
+
23
+ test.prop([fc.record({ start: fc.integer(), end: fc.integer() })], { verbose: true })(
24
+ 'should return sanitized range',
25
+ (value) => {
26
+ const sanitizationResult = sanitizeNumericRange(value);
27
+
28
+ expect(sanitizationResult.isSanitized).toBe(true);
29
+ expect(typeof sanitizationResult.sanitizedValue?.start).toBe('number');
30
+ expect(typeof sanitizationResult.sanitizedValue?.end).toBe('number');
31
+ expect(sanitizationResult.sanitizedValue).toEqual({ start: value.start, end: value.end });
32
+ },
33
+ );
34
+
35
+ test.prop([fc.object()], { verbose: true })(
36
+ 'should not return sanitization values when start or end does not exist in value',
37
+ (value) => {
38
+ const sanitizationResult = sanitizeNumericRange(value);
39
+
40
+ expect(sanitizationResult.isSanitized).toBe(false);
41
+ expect(sanitizationResult.sanitizedValue).toBeUndefined();
42
+ },
43
+ );
44
+ });
@@ -0,0 +1,33 @@
1
+ import { OperationEnum, Sanitizer, SanitizersMapper } from '../types';
2
+ import { ListStringOperationPayload, SingleStringOperationPayload, StringOperationPayload } from './types';
3
+
4
+ const isString = (value: unknown): value is string => typeof value === 'string';
5
+
6
+ export const sanitizeSingleString: Sanitizer<SingleStringOperationPayload> = (value) => {
7
+ const sanitizedValue = value.string !== undefined && isString(value.string) ? { string: value.string } : undefined;
8
+
9
+ return {
10
+ isSanitized: !!sanitizedValue,
11
+ sanitizedValue,
12
+ };
13
+ };
14
+
15
+ export const sanitizeListString: Sanitizer<ListStringOperationPayload> = (value) => {
16
+ const sanitizedValue =
17
+ value.list !== undefined && (<unknown[]>value.list).every((str) => isString(str))
18
+ ? { list: value.list as string[] }
19
+ : undefined;
20
+
21
+ return {
22
+ isSanitized: !!sanitizedValue,
23
+ sanitizedValue,
24
+ };
25
+ };
26
+
27
+ export const StringSanitizersMapper: SanitizersMapper<StringOperationPayload> = {
28
+ [OperationEnum.Matches]: sanitizeSingleString,
29
+ [OperationEnum.Contains]: sanitizeListString,
30
+ [OperationEnum.StartsWith]: sanitizeSingleString,
31
+ [OperationEnum.EndsWith]: sanitizeSingleString,
32
+ [OperationEnum.InList]: sanitizeListString,
33
+ };
@@ -0,0 +1,45 @@
1
+ import { fc, test } from '@fast-check/jest';
2
+ import { sanitizeListString, sanitizeSingleString } from '../sanitizers';
3
+
4
+ describe('String sanitizers', () => {
5
+ test.prop([fc.record({ list: fc.array(fc.string()) })], { verbose: true })(
6
+ 'should return sanitized strings list',
7
+ (value) => {
8
+ const sanitizationResult = sanitizeListString(value);
9
+
10
+ expect(sanitizationResult.isSanitized).toBe(true);
11
+ expect(sanitizationResult.sanitizedValue?.list).toEqual(value.list);
12
+ },
13
+ );
14
+
15
+ test.prop([fc.object()], { verbose: true })(
16
+ 'should not return sanitized strings list when list property does not exist',
17
+ (value) => {
18
+ const sanitizationResult = sanitizeListString(value);
19
+
20
+ expect(sanitizationResult.isSanitized).toBe(false);
21
+ expect(sanitizationResult.sanitizedValue).toBeUndefined();
22
+ },
23
+ );
24
+
25
+ test.prop([fc.record({ string: fc.string() })], { verbose: true })(
26
+ 'should sanitized string string property exists',
27
+ (value) => {
28
+ const sanitizationResult = sanitizeSingleString(value);
29
+
30
+ expect(sanitizationResult.isSanitized).toBe(true);
31
+ expect(typeof sanitizationResult.sanitizedValue?.string).toEqual('string');
32
+ expect(sanitizationResult.sanitizedValue?.string).toEqual(value.string);
33
+ },
34
+ );
35
+
36
+ test.prop([fc.object()], { verbose: true })(
37
+ 'should not return sanitized string when string property does not exist',
38
+ (value) => {
39
+ const sanitizationResult = sanitizeSingleString(value);
40
+
41
+ expect(sanitizationResult.isSanitized).toBe(false);
42
+ expect(sanitizationResult.sanitizedValue).toBeUndefined();
43
+ },
44
+ );
45
+ });
@@ -8,6 +8,7 @@ export interface OperationResult {
8
8
  isValid: boolean;
9
9
  }
10
10
 
11
+ export type RawConditionValue = Record<string, unknown>;
11
12
  export type ConditionValue =
12
13
  | StringOperationPayload
13
14
  | NumericOperationPayload
@@ -19,4 +20,11 @@ export type OperationHandler = (attribute: any) => OperationResult;
19
20
  export type OperationContextEnricher = (value: ConditionValue) => OperationHandler;
20
21
  export type OperationsMapper = { [key in OperationEnum]?: OperationContextEnricher };
21
22
 
23
+ export interface SanitizationResult<T extends ConditionValue> {
24
+ isSanitized: boolean;
25
+ sanitizedValue: T | undefined;
26
+ }
27
+ export type Sanitizer<T extends ConditionValue> = (value: RawConditionValue) => SanitizationResult<T>;
28
+ export type SanitizersMapper<T extends ConditionValue> = { [key in OperationEnum]?: Sanitizer<T> };
29
+
22
30
  export * from './operation.enum';
@@ -0,0 +1 @@
1
+ export * from './is-entitled.evaluator';
@@ -0,0 +1,71 @@
1
+ import { Attributes, EntitlementResult, Justification, NO_EXPIRATION_TIME, UserEntitlementsContext } from './types';
2
+ import { evaluateFeatureFlag } from '../feature-flags';
3
+ import { TreatmentEnum } from '../rules';
4
+ export function evaluateIsEntitledToFeature(
5
+ featureKey: string,
6
+ userEntitlementsContext: UserEntitlementsContext,
7
+ attributes: Attributes,
8
+ ): EntitlementResult {
9
+ const feature = userEntitlementsContext.features[featureKey];
10
+
11
+ let hasExpired = false;
12
+ if (feature && feature.expireTime !== null) {
13
+ hasExpired = feature.expireTime !== NO_EXPIRATION_TIME && feature.expireTime < Date.now();
14
+
15
+ if (!hasExpired) {
16
+ return { isEntitled: true };
17
+ }
18
+ }
19
+
20
+ if (feature && feature.featureFlag) {
21
+ const { treatment } = evaluateFeatureFlag(feature.featureFlag, attributes);
22
+ if (treatment === TreatmentEnum.True) {
23
+ return { isEntitled: true };
24
+ }
25
+ }
26
+
27
+ return { isEntitled: false, justification: hasExpired ? Justification.BundleExpired : Justification.MissingFeature };
28
+ }
29
+
30
+ export function evaluateIsEntitledToPermissions(
31
+ permissionKey: string,
32
+ userEntitlementsContext: UserEntitlementsContext,
33
+ attributes: Attributes,
34
+ ): EntitlementResult {
35
+ const permission = userEntitlementsContext.permissions[permissionKey];
36
+
37
+ if (!permission) {
38
+ return { isEntitled: false, justification: Justification.MissingPermission };
39
+ }
40
+
41
+ const linkedFeatures = getLinkedFeatures(permissionKey, userEntitlementsContext);
42
+
43
+ if (!linkedFeatures.length) {
44
+ return { isEntitled: true };
45
+ }
46
+
47
+ let hasExpired = false;
48
+
49
+ for (const featureKey of linkedFeatures) {
50
+ const { isEntitled, justification } = evaluateIsEntitledToFeature(featureKey, userEntitlementsContext, attributes);
51
+
52
+ if (isEntitled) {
53
+ return { isEntitled: true };
54
+ }
55
+
56
+ if (justification === Justification.BundleExpired) {
57
+ hasExpired = true;
58
+ }
59
+ }
60
+
61
+ return {
62
+ isEntitled: false,
63
+ justification: hasExpired ? Justification.BundleExpired : Justification.MissingFeature,
64
+ };
65
+ }
66
+
67
+ function getLinkedFeatures(permissionKey: string, userEntitlementsContext: UserEntitlementsContext): string[] {
68
+ return Object.keys(userEntitlementsContext.features).filter((featureKey) =>
69
+ userEntitlementsContext.features[featureKey].linkedPermissions.includes(permissionKey),
70
+ );
71
+ }
@@ -0,0 +1,291 @@
1
+ import * as FeatureFlags from '../../feature-flags/feature-flag.evaluator';
2
+ import * as IsEntitledEvaluators from '../is-entitled.evaluator';
3
+ import { TreatmentEnum } from '../../rules';
4
+ import { EntitlementResult, Justification, NO_EXPIRATION_TIME, UserEntitlementsContext } from '../types';
5
+ import { FeatureFlag } from '../../feature-flags/types';
6
+ const mockFeatureFlag: FeatureFlag = {
7
+ on: true,
8
+ defaultTreatment: TreatmentEnum.True,
9
+ offTreatment: TreatmentEnum.False,
10
+ rules: [],
11
+ };
12
+ const truthyEntitlementResult: EntitlementResult = {
13
+ isEntitled: true,
14
+ };
15
+ const falsyEntitlementResultMissingFeature: EntitlementResult = {
16
+ isEntitled: false,
17
+ justification: Justification.MissingFeature,
18
+ };
19
+ const falsyEntitlementResultBundleExpired: EntitlementResult = {
20
+ isEntitled: false,
21
+ justification: Justification.BundleExpired,
22
+ };
23
+ const falsyEntitlementResultMissingPermission: EntitlementResult = {
24
+ isEntitled: false,
25
+ justification: Justification.MissingPermission,
26
+ };
27
+ describe('evaluateIsEntitledToFeature', () => {
28
+ describe('entitled', () => {
29
+ describe('feature-flag evaluated truthy', () => {
30
+ beforeAll(async () => {
31
+ jest.spyOn(FeatureFlags, 'evaluateFeatureFlag').mockReturnValue({ treatment: TreatmentEnum.True });
32
+ });
33
+
34
+ test('feature granted with valid expiration date', async () => {
35
+ const userEntitlementContext: UserEntitlementsContext = {
36
+ features: {
37
+ 'test-feature': {
38
+ expireTime: new Date().getTime() + 3600,
39
+ linkedPermissions: [],
40
+ featureFlag: mockFeatureFlag,
41
+ },
42
+ },
43
+ permissions: {},
44
+ };
45
+ const result = IsEntitledEvaluators.evaluateIsEntitledToFeature('test-feature', userEntitlementContext, {});
46
+
47
+ expect(result).toEqual(truthyEntitlementResult);
48
+ });
49
+ test('feature granted with expired expiration date', async () => {
50
+ const userEntitlementContext: UserEntitlementsContext = {
51
+ features: {
52
+ 'test-feature': {
53
+ expireTime: new Date().getTime() - 3600,
54
+ linkedPermissions: [],
55
+ featureFlag: mockFeatureFlag,
56
+ },
57
+ },
58
+ permissions: {},
59
+ };
60
+ const result = IsEntitledEvaluators.evaluateIsEntitledToFeature('test-feature', userEntitlementContext, {});
61
+
62
+ expect(result).toEqual(truthyEntitlementResult);
63
+ });
64
+ test('feature granted with no expiration date', async () => {
65
+ const userEntitlementContext: UserEntitlementsContext = {
66
+ features: {
67
+ 'test-feature': {
68
+ expireTime: NO_EXPIRATION_TIME,
69
+ linkedPermissions: [],
70
+ featureFlag: mockFeatureFlag,
71
+ },
72
+ },
73
+ permissions: {},
74
+ };
75
+ const result = IsEntitledEvaluators.evaluateIsEntitledToFeature('test-feature', userEntitlementContext, {});
76
+
77
+ expect(result).toEqual(truthyEntitlementResult);
78
+ });
79
+ test('feature has not been granted', async () => {
80
+ const userEntitlementContext: UserEntitlementsContext = {
81
+ features: {
82
+ 'test-feature': {
83
+ expireTime: null,
84
+ linkedPermissions: [],
85
+ featureFlag: mockFeatureFlag,
86
+ },
87
+ },
88
+ permissions: {},
89
+ };
90
+ const result = IsEntitledEvaluators.evaluateIsEntitledToFeature('test-feature', userEntitlementContext, {});
91
+
92
+ expect(result).toEqual(truthyEntitlementResult);
93
+ });
94
+ });
95
+ describe('feature-flag evaluated falsy', () => {
96
+ beforeAll(async () => {
97
+ jest.spyOn(FeatureFlags, 'evaluateFeatureFlag').mockReturnValue({ treatment: TreatmentEnum.False });
98
+ });
99
+ test('feature granted with no expiration date', async () => {
100
+ const userEntitlementContext: UserEntitlementsContext = {
101
+ features: {
102
+ 'test-feature': {
103
+ expireTime: NO_EXPIRATION_TIME,
104
+ linkedPermissions: [],
105
+ featureFlag: mockFeatureFlag,
106
+ },
107
+ },
108
+ permissions: {},
109
+ };
110
+ const result = IsEntitledEvaluators.evaluateIsEntitledToFeature('test-feature', userEntitlementContext, {});
111
+
112
+ expect(result).toEqual(truthyEntitlementResult);
113
+ });
114
+ test('feature granted with valid expiration date', async () => {
115
+ const userEntitlementContext: UserEntitlementsContext = {
116
+ features: {
117
+ 'test-feature': {
118
+ expireTime: Date.now() + 3600,
119
+ linkedPermissions: [],
120
+ featureFlag: mockFeatureFlag,
121
+ },
122
+ },
123
+ permissions: {},
124
+ };
125
+ const result = IsEntitledEvaluators.evaluateIsEntitledToFeature('test-feature', userEntitlementContext, {});
126
+
127
+ expect(result).toEqual(truthyEntitlementResult);
128
+ });
129
+ });
130
+ describe('no feature flag', () => {
131
+ test('feature granted with no expiration date', async () => {
132
+ const userEntitlementContext: UserEntitlementsContext = {
133
+ features: {
134
+ 'test-feature': {
135
+ expireTime: NO_EXPIRATION_TIME,
136
+ linkedPermissions: [],
137
+ },
138
+ },
139
+ permissions: {},
140
+ };
141
+ const result = IsEntitledEvaluators.evaluateIsEntitledToFeature('test-feature', userEntitlementContext, {});
142
+
143
+ expect(result).toEqual(truthyEntitlementResult);
144
+ });
145
+ test('feature granted with valid expiration date', async () => {
146
+ const userEntitlementContext: UserEntitlementsContext = {
147
+ features: {
148
+ 'test-feature': {
149
+ expireTime: Date.now() + 3600,
150
+ linkedPermissions: [],
151
+ },
152
+ },
153
+ permissions: {},
154
+ };
155
+ const result = IsEntitledEvaluators.evaluateIsEntitledToFeature('test-feature', userEntitlementContext, {});
156
+
157
+ expect(result).toEqual(truthyEntitlementResult);
158
+ });
159
+ });
160
+ });
161
+
162
+ describe('not entitled', () => {
163
+ describe('feature-flag evaluated falsy', () => {
164
+ beforeAll(async () => {
165
+ jest.spyOn(FeatureFlags, 'evaluateFeatureFlag').mockReturnValue({ treatment: TreatmentEnum.False });
166
+ });
167
+ test('feature has not been granted', async () => {
168
+ const userEntitlementContext: UserEntitlementsContext = {
169
+ features: {
170
+ 'test-feature': {
171
+ expireTime: null,
172
+ linkedPermissions: [],
173
+ },
174
+ },
175
+ permissions: {},
176
+ };
177
+ const result = IsEntitledEvaluators.evaluateIsEntitledToFeature('test-feature', userEntitlementContext, {});
178
+
179
+ expect(result).toEqual(falsyEntitlementResultMissingFeature);
180
+ });
181
+ test('feature granted with expired expiration date', async () => {
182
+ const userEntitlementContext: UserEntitlementsContext = {
183
+ features: {
184
+ 'test-feature': {
185
+ expireTime: Date.now() - 3600,
186
+ linkedPermissions: [],
187
+ },
188
+ },
189
+ permissions: {},
190
+ };
191
+ const result = IsEntitledEvaluators.evaluateIsEntitledToFeature('test-feature', userEntitlementContext, {});
192
+
193
+ expect(result).toEqual(falsyEntitlementResultBundleExpired);
194
+ });
195
+ });
196
+
197
+ describe('no feature', () => {
198
+ test('feature does not exist', async () => {
199
+ const userEntitlementContext: UserEntitlementsContext = {
200
+ features: {},
201
+ permissions: {},
202
+ };
203
+ const result = IsEntitledEvaluators.evaluateIsEntitledToFeature('test-feature', userEntitlementContext, {});
204
+
205
+ expect(result).toEqual(falsyEntitlementResultMissingFeature);
206
+ });
207
+ });
208
+ });
209
+ });
210
+
211
+ describe('evaluateIsEntitledToPermission', () => {
212
+ describe('entitled', () => {
213
+ test('permission granted, no linked feature/s to it', async () => {
214
+ const userEntitlementContext: UserEntitlementsContext = {
215
+ features: {},
216
+ permissions: { 'test.permission': true },
217
+ };
218
+ const result = IsEntitledEvaluators.evaluateIsEntitledToPermissions(
219
+ 'test.permission',
220
+ userEntitlementContext,
221
+ {},
222
+ );
223
+
224
+ expect(result).toEqual(truthyEntitlementResult);
225
+ });
226
+ test('permission granted with linked feature/s, feature is entitled', async () => {
227
+ jest.spyOn(IsEntitledEvaluators, 'evaluateIsEntitledToFeature').mockReturnValue({ isEntitled: true });
228
+
229
+ const userEntitlementContext: UserEntitlementsContext = {
230
+ features: { 'test-feature': { expireTime: NO_EXPIRATION_TIME, linkedPermissions: ['test.permission'] } },
231
+ permissions: { 'test.permission': true },
232
+ };
233
+
234
+ const result = IsEntitledEvaluators.evaluateIsEntitledToPermissions(
235
+ 'test.permission',
236
+ userEntitlementContext,
237
+ {},
238
+ );
239
+
240
+ expect(result).toEqual(truthyEntitlementResult);
241
+ });
242
+ });
243
+
244
+ describe('not entitled', () => {
245
+ test('permission not granted', async () => {
246
+ const userEntitlementContext: UserEntitlementsContext = {
247
+ features: {},
248
+ permissions: {},
249
+ };
250
+ const result = IsEntitledEvaluators.evaluateIsEntitledToPermissions(
251
+ 'test.permission',
252
+ userEntitlementContext,
253
+ {},
254
+ );
255
+
256
+ expect(result).toEqual(falsyEntitlementResultMissingPermission);
257
+ });
258
+ test('permission granted with linked feature/s, no feature is entiteld', async () => {
259
+ jest
260
+ .spyOn(IsEntitledEvaluators, 'evaluateIsEntitledToFeature')
261
+ .mockReturnValue({ isEntitled: false, justification: Justification.MissingFeature });
262
+
263
+ const userEntitlementContext: UserEntitlementsContext = {
264
+ features: { 'test-feature': { expireTime: null, linkedPermissions: ['test.permission'] } },
265
+ permissions: { 'test.permission': true },
266
+ };
267
+
268
+ const result = IsEntitledEvaluators.evaluateIsEntitledToPermissions(
269
+ 'test.permission',
270
+ userEntitlementContext,
271
+ {},
272
+ );
273
+
274
+ expect(result).toEqual(falsyEntitlementResultMissingFeature);
275
+ });
276
+ test('permission granted with linked feature/s, no feature is entiteld with expired bundle', async () => {
277
+ jest
278
+ .spyOn(IsEntitledEvaluators, 'evaluateIsEntitledToFeature')
279
+ .mockReturnValue({ isEntitled: false, justification: Justification.BundleExpired });
280
+ });
281
+
282
+ const userEntitlementContext: UserEntitlementsContext = {
283
+ features: { 'test-feature': { expireTime: Date.now() - 3600, linkedPermissions: ['test.permission'] } },
284
+ permissions: { 'test.permission': true },
285
+ };
286
+
287
+ const result = IsEntitledEvaluators.evaluateIsEntitledToPermissions('test.permission', userEntitlementContext, {});
288
+
289
+ expect(result).toEqual(falsyEntitlementResultBundleExpired);
290
+ });
291
+ });
@@ -0,0 +1,27 @@
1
+ import { FeatureFlag } from '../feature-flags/types';
2
+ export type UserEntitlementsContext = {
3
+ features: Record<
4
+ string,
5
+ {
6
+ expireTime: number | null;
7
+ linkedPermissions: string[];
8
+ featureFlag?: FeatureFlag;
9
+ }
10
+ >;
11
+ permissions: Record<string, true>;
12
+ };
13
+
14
+ export type EntitlementResult = {
15
+ isEntitled: boolean;
16
+ justification?: Justification;
17
+ };
18
+
19
+ export enum Justification {
20
+ MissingFeature = 'MISSING_FEATURE',
21
+ MissingPermission = 'MISSING_PERMISSION',
22
+ BundleExpired = 'BUNDLE_EXPIRED',
23
+ }
24
+
25
+ export type Attributes = Record<string, string | number | boolean | Date>;
26
+
27
+ export const NO_EXPIRATION_TIME = -1;