Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion src/app/app.component.html
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<router-outlet></router-outlet>
<router-outlet></router-outlet>
21 changes: 19 additions & 2 deletions src/app/modules/login/services/azure.ad.b2c.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { TestBed, inject } from '@angular/core/testing';
import { inject, TestBed } from '@angular/core/testing';
import { Account, UserAgentApplication } from 'msal';
import { AzureAdB2CService } from './azure.ad.b2c.service';
import { UserAgentApplication, Account } from 'msal';


describe('AzureAdB2CService', () => {
let service: AzureAdB2CService;
Expand Down Expand Up @@ -130,4 +131,20 @@ describe('AzureAdB2CService', () => {
expect(sessionStorage.getItem).toHaveBeenCalled();
expect(resp).toEqual(token);
});

it('should get email from UserAgentApplication', () => {
spyOn(UserAgentApplication.prototype, 'getAccount').and.returnValues(account);

const name = service.getName();

expect(UserAgentApplication.prototype.getAccount).toHaveBeenCalled();
});

it('should group from UserAgentApplication', () => {
spyOn(UserAgentApplication.prototype, 'getAccount').and.returnValues(account);

const name = service.getUserGroup();

expect(UserAgentApplication.prototype.getAccount).toHaveBeenCalled();
});
});
13 changes: 11 additions & 2 deletions src/app/modules/login/services/azure.ad.b2c.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Injectable } from '@angular/core';
import { Observable, from } from 'rxjs';
import { CLIENT_ID, AUTHORITY, SCOPES } from '../../../../environments/environment';
import { UserAgentApplication } from 'msal';
import { from, Observable } from 'rxjs';

import { AUTHORITY, CLIENT_ID, SCOPES } from '../../../../environments/environment';

@Injectable({
providedIn: 'root',
Expand Down Expand Up @@ -55,4 +56,12 @@ export class AzureAdB2CService {
getBearerToken(): string {
return sessionStorage.getItem('msal.idtoken');
}

getUserEmail(): string {
return this.msal.getAccount().idToken?.emails[0];
}

getUserGroup(): string {
return this.msal.getAccount().idToken?.extension_role;
}
}
10 changes: 2 additions & 8 deletions src/app/modules/reports/pages/reports.component.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,9 @@
import { Component, OnInit } from '@angular/core';
import { Component } from '@angular/core';

@Component({
selector: 'app-reports',
templateUrl: './reports.component.html',
styleUrls: ['./reports.component.scss']
})
export class ReportsComponent implements OnInit {

constructor() { }

ngOnInit(): void {
}

export class ReportsComponent {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { FeatureFilterConfiguration } from './filters/feature-filter-configuration';

export interface FeatureToggleConfiguration {
id: string;
enabled: boolean;
description: string;
conditions: {
client_filters: FeatureFilterConfiguration[];
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { AppConfigurationClient } from '@azure/app-configuration';
import { of } from 'rxjs';
import { AzureAdB2CService } from '../../login/services/azure.ad.b2c.service';
import { FeatureManagerService } from './feature-toggle-manager.service';
import { FeatureToggleProvider } from './feature-toggle-provider.service';
import { FeatureToggleModel } from './feature-toggle.model';
import { FeatureFilterProvider } from './filters/feature-filter-provider.service';
import { TargetingFeatureFilterModel } from './filters/targeting/targeting-feature-filter.model';


describe('FeatureToggleManager', () => {
const featureToggleKey = 'foo';
const featureToggleLabel = 'dev';
const fakeAppConfigurationConnectionString = 'Endpoint=http://fake.foo;Id=fake.id;Secret=fake.secret';
const aFeatureToggle = new FeatureToggleModel('any-id', true, []);
let service: FeatureManagerService;
let fakeFeatureToggleProvider;

describe('Features without filters', () => {
beforeEach(() => {

fakeFeatureToggleProvider = new FeatureToggleProvider(
new AppConfigurationClient(fakeAppConfigurationConnectionString),
new FeatureFilterProvider(new AzureAdB2CService())
);
spyOn(fakeFeatureToggleProvider, 'getFeatureToggle').and.returnValue(of(aFeatureToggle));
service = new FeatureManagerService(fakeFeatureToggleProvider);
});
it('manager uses feature provider to build feature toggle model', async () => {
service.isToggleEnabled(featureToggleKey, featureToggleLabel).subscribe((value) => {

expect(fakeFeatureToggleProvider).toHaveBeenCalledWith(featureToggleKey, featureToggleLabel);
});
});

it('manager extracts enabled attribute from feature toggle model', async () => {
service.isToggleEnabled(featureToggleKey, featureToggleLabel).subscribe((value) => {
expect(value).toEqual(aFeatureToggle.enabled);
});
});
});


describe('Features with filters', () => {
const anyMatchingFilter = new TargetingFeatureFilterModel(
{ Audience: { Groups: ['group-a'], Users: ['user-a'] } },
{ username: 'user-b', group: 'group-a' }
);
const anyNotMatchingFilter = new TargetingFeatureFilterModel(
{ Audience: { Groups: ['a-group'], Users: ['user-a'] } },
{ username: 'user-b', group: 'b-group' }
);

let aToggleWithFilters;
let getFeatureToggleSpy;

beforeEach(() => {
aToggleWithFilters = new FeatureToggleModel('any-other-id', true, [anyMatchingFilter]);
fakeFeatureToggleProvider = new FeatureToggleProvider(
new AppConfigurationClient(fakeAppConfigurationConnectionString),
new FeatureFilterProvider(new AzureAdB2CService())
);
getFeatureToggleSpy = spyOn(fakeFeatureToggleProvider, 'getFeatureToggle').and.returnValue(of(aToggleWithFilters));
service = new FeatureManagerService(fakeFeatureToggleProvider);
});

it('manager uses feature provider to build feature toggle model', async () => {
service.isToggleEnabledForUser(featureToggleKey, featureToggleLabel).subscribe((value) => {
expect(getFeatureToggleSpy).toHaveBeenCalledWith(featureToggleKey, featureToggleLabel);
});
});

it('given a feature toggle with filters which match the verification, then the response is true', async () => {
service.isToggleEnabledForUser(featureToggleKey, featureToggleLabel).subscribe((value) => {
expect(value).toEqual(true);
});
});

it('given a feature toggle with filters which do not match the verification, then the response is false', async () => {

aToggleWithFilters = new FeatureToggleModel('any-other-id', true, [anyNotMatchingFilter]);
fakeFeatureToggleProvider = new FeatureToggleProvider(
new AppConfigurationClient(fakeAppConfigurationConnectionString),
new FeatureFilterProvider(new AzureAdB2CService())
);
spyOn(fakeFeatureToggleProvider, 'getFeatureToggle').and.returnValue(of(aToggleWithFilters));
service = new FeatureManagerService(fakeFeatureToggleProvider);

service.isToggleEnabledForUser(featureToggleKey, featureToggleLabel).subscribe((value) => {
expect(value).toEqual(false);
});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { FeatureToggleProvider } from './feature-toggle-provider.service';


@Injectable({
providedIn: 'root',
})
export class FeatureManagerService {

constructor(private featureToggleProvider: FeatureToggleProvider) { }

public isToggleEnabled(toggleName: string, toggleLabel?: string): Observable<boolean> {
return this.featureToggleProvider.getFeatureToggle(toggleName, toggleLabel).pipe(
map(featureToggle => featureToggle.enabled)
);
}

public isToggleEnabledForUser(toggleName: string, toggleLabel?: string): Observable<boolean> {
return this.featureToggleProvider.getFeatureToggle(toggleName, toggleLabel).pipe(
map(featureToggle => featureToggle.filters),
map(filters => filters.map(filter => filter.evaluate())),
map(filterEvaluations => filterEvaluations.includes(true))
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { AppConfigurationClient, GetConfigurationSettingResponse } from '@azure/app-configuration';
import { of } from 'rxjs';
import { AzureAdB2CService } from '../../login/services/azure.ad.b2c.service';
import { FeatureToggleConfiguration } from './feature-toggle-configuration';
import { FeatureToggleProvider } from './feature-toggle-provider.service';
import { FeatureToggleModel } from './feature-toggle.model';
import { FeatureFilterProvider } from './filters/feature-filter-provider.service';
import { TargetingFeatureFilterModel } from './filters/targeting/targeting-feature-filter.model';


describe('FeatureToggleProvider', () => {
const anyToggleResponse: FeatureToggleConfiguration = {
id: '1',
enabled: true,
description: 'any description',
conditions: {
client_filters: [{ name: 'any-name', parameters: {} }]
},
};

const fakeAppConfigurationConnectionString = 'Endpoint=http://fake.foo;Id=fake.id;Secret=fake.secret';
const featureToggleKey = 'foo';
const featureToggleLabel = 'dev';
const fakeResponse: GetConfigurationSettingResponse = {
isReadOnly: true,
key: featureToggleKey,
_response: {
request: null,
bodyAsText: 'any-response',
headers: null,
parsedHeaders: null,
status: 200
},
statusCode: 200,
value: JSON.stringify(anyToggleResponse)
};
const anyFilter = new TargetingFeatureFilterModel(
{ Audience: { Groups: ['a-group'], Users: ['any-user'] } },
{ username: '[email protected]', group: 'fake-group' }
);
let fakeConfigurationClient;
let fakeGetConfigurationSetting;
let fakeFeatureFilterProvider;
let getFilterConfigurationSpy;
let service;



beforeEach(() => {
fakeConfigurationClient = new AppConfigurationClient(fakeAppConfigurationConnectionString);
fakeGetConfigurationSetting = spyOn(fakeConfigurationClient, 'getConfigurationSetting').and.callFake(
() => of(fakeResponse).toPromise());

fakeFeatureFilterProvider = new FeatureFilterProvider(new AzureAdB2CService());
getFilterConfigurationSpy = spyOn(fakeFeatureFilterProvider, 'getFilterFromConfiguration').and.
returnValue(anyFilter);
service = new FeatureToggleProvider(fakeConfigurationClient, fakeFeatureFilterProvider);
});

it('toggles are read using azure configuration client', async () => {
service.getFeatureToggle(featureToggleKey, featureToggleLabel).subscribe((value) => {

expect(fakeGetConfigurationSetting).toHaveBeenCalledWith(
{ key: `.appconfig.featureflag/${featureToggleKey}`, label: featureToggleLabel }
);
});
});

it('filters are built using the filterProvider', async () => {
service.getFeatureToggle(featureToggleKey, featureToggleLabel).subscribe((value) => {
expect(getFilterConfigurationSpy).toHaveBeenCalled();
});
});

it('toggle model is built', async () => {
service.getFeatureToggle(featureToggleKey, featureToggleLabel).subscribe((value) => {
expect(value).toEqual(new FeatureToggleModel(anyToggleResponse.id, anyToggleResponse.enabled, [anyFilter]));
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { AppConfigurationClient } from '@azure/app-configuration';
import { from, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { AZURE_APP_CONFIGURATION_CONNECTION_STRING } from 'src/environments/environment';
import { FeatureToggleConfiguration } from './feature-toggle-configuration';
import { FeatureToggleModel } from './feature-toggle.model';
import { FeatureFilterProvider } from './filters/feature-filter-provider.service';


const APP_CONFIGURATION_CLIENT = new InjectionToken<AppConfigurationClient>('Azure configuration client', {
providedIn: 'root',
factory: () => new AppConfigurationClient(AZURE_APP_CONFIGURATION_CONNECTION_STRING)
});
@Injectable({
providedIn: 'root',

})
export class FeatureToggleProvider {
constructor(
@Inject(APP_CONFIGURATION_CLIENT)
private client: AppConfigurationClient,
private featureFilterProvider: FeatureFilterProvider
) { }

public getFeatureToggle(toggleName: string, toggleLabel?: string): Observable<FeatureToggleModel> {
return from(this.client.getConfigurationSetting({ key: `.appconfig.featureflag/${toggleName}`, label: toggleLabel })).pipe(
map(featureToggleResponse => JSON.parse(featureToggleResponse.value) as FeatureToggleConfiguration),
map(featureToggleConfiguration => {
const filters = featureToggleConfiguration.conditions.client_filters.map(filterConfiguration =>
this.featureFilterProvider.getFilterFromConfiguration(filterConfiguration));
return new FeatureToggleModel(
featureToggleConfiguration.id,
featureToggleConfiguration.enabled,
filters);
}
)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { FeatureFilterModel } from './filters/feature-filter.model';

export class FeatureToggleModel {
constructor(
public readonly name: string,
public readonly enabled: boolean,
public readonly filters: FeatureFilterModel[]
) { }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface FeatureFilterConfiguration {
name: string;
parameters: any;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { AzureAdB2CService } from 'src/app/modules/login/services/azure.ad.b2c.service';
import { FeatureFilterConfiguration } from './feature-filter-configuration';
import { FeatureFilterProvider } from './feature-filter-provider.service';
import { FeatureFilterTypes } from './feature-filter-types';
import { TargetingFeatureFilterModel } from './targeting/targeting-feature-filter.model';


describe('FeatureFilterProvider', () => {
let fakeUserService: AzureAdB2CService;
let service: FeatureFilterProvider;
let featureFilterConfiguration: FeatureFilterConfiguration;

beforeEach(() => {
fakeUserService = new AzureAdB2CService();
spyOn(fakeUserService, 'getUserEmail').and.returnValue('any-user-email');
spyOn(fakeUserService, 'getUserGroup').and.returnValue('any-user-group');
service = new FeatureFilterProvider(fakeUserService);
featureFilterConfiguration = { name: FeatureFilterTypes.TARGETING, parameters: {} };
});

it('filter model type is created based on the filter configuration', () => {
const filter = service.getFilterFromConfiguration(featureFilterConfiguration);

expect(filter.constructor.name).toBe(TargetingFeatureFilterModel.name);
});
});
Loading