Skip to content

Commit

Permalink
feat: display captcha component according to the related ICM captcha …
Browse files Browse the repository at this point in the history
…service (#200)

- introduces lazy components for captcha
- use temporary feature toggle 'noCaptcha' to disable captchas in PWA (server API bug)

BREAKING CHANGE: feature toggles captchaV2 and captchaV3 are obsolete
  • Loading branch information
dhhyi committed May 18, 2020
1 parent e3ffc18 commit 8bfc9d3
Show file tree
Hide file tree
Showing 30 changed files with 497 additions and 323 deletions.
3 changes: 2 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ jobs:
PROXY_ICM: true
ICM_BASE_URL: ${{ secrets.ICM_BASE_URL }}
BROWSER: chrome
FEATURES: noCaptcha,compare,recently,rating,wishlists

- name: Set B2B Environment
if: matrix.test == 'b2b'
Expand All @@ -246,7 +247,7 @@ jobs:
overwrite: true
THEME: blue
ICM_CHANNEL: inSPIRED-inTRONICS_Business-Site
FEATURES: quoting,quickorder,compare,recently,businessCustomerRegistration,advancedVariationHandling
FEATURES: noCaptcha,compare,recently,rating,quoting,quickorder,businessCustomerRegistration,advancedVariationHandling

- name: Start SSR
if: matrix.test != 'mock'
Expand Down
5 changes: 0 additions & 5 deletions src/app/core/configuration.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import localeDe from '@angular/common/locales/de';
import localeFr from '@angular/common/locales/fr';
import { Inject, LOCALE_ID, NgModule } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { RECAPTCHA_V3_SITE_KEY } from 'ng-recaptcha';

import { environment } from '../../environments/environment';

Expand All @@ -14,8 +13,6 @@ import { ThemeService } from './utils/theme/theme.service';
@NgModule({
imports: [FeatureToggleModule],
providers: [
// tslint:disable-next-line:no-string-literal
{ provide: RECAPTCHA_V3_SITE_KEY, useValue: environment['captchaSiteKey'] },
// tslint:disable-next-line:no-string-literal
{ provide: injectionKeys.MOCK_SERVER_API, useValue: environment['mockServerAPI'] },
// tslint:disable-next-line:no-string-literal
Expand All @@ -28,8 +25,6 @@ import { ThemeService } from './utils/theme/theme.service';
{ provide: injectionKeys.DEFAULT_PRODUCT_LISTING_VIEW_TYPE, useValue: environment.defaultProductListingViewType },
// TODO: get from REST call
{ provide: injectionKeys.USER_REGISTRATION_LOGIN_TYPE, useValue: 'email' },
// tslint:disable-next-line:no-string-literal
{ provide: injectionKeys.CAPTCHA_SITE_KEY, useValue: environment['captchaSiteKey'] },
{ provide: injectionKeys.SMALL_BREAKPOINT_WIDTH, useValue: environment.smallBreakpointWidth },
{ provide: injectionKeys.MEDIUM_BREAKPOINT_WIDTH, useValue: environment.mediumBreakpointWidth },
{ provide: injectionKeys.LARGE_BREAKPOINT_WIDTH, useValue: environment.largeBreakpointWidth },
Expand Down
5 changes: 0 additions & 5 deletions src/app/core/configurations/injection-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,6 @@ export const MEDIUM_BREAKPOINT_WIDTH = new InjectionToken<number>('mediumBreakpo
export const LARGE_BREAKPOINT_WIDTH = new InjectionToken<number>('largeBreakpointWidth');
export const EXTRALARGE_BREAKPOINT_WIDTH = new InjectionToken<number>('extralargeBreakpointWidth');

/**
* The captcha configuration siteKey
*/
export const CAPTCHA_SITE_KEY = new InjectionToken<string>('captchaSiteKey');

/**
* The configured theme for the application (or 'default' if not configured)
*/
Expand Down
2 changes: 1 addition & 1 deletion src/app/core/store/configuration/configuration.effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ export class ConfigurationEffects {
);

@Effect()
setDeviceType$ = iif(
setDeviceType$ = iif<ApplyConfiguration, ApplyConfiguration>(
() => isPlatformBrowser(this.platformId),
defer(() =>
merge(this.actions$.pipe(ofType(ROOT_EFFECTS_INIT)), fromEvent(window, 'resize')).pipe(
Expand Down
14 changes: 14 additions & 0 deletions src/app/extensions/captcha/exports/captcha-exports.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';

import { SitekeyProviderService } from '../services/sitekey-provider/sitekey-provider.service';

import { LazyCaptchaComponent } from './captcha/lazy-captcha/lazy-captcha.component';

@NgModule({
imports: [CommonModule],
declarations: [LazyCaptchaComponent],
exports: [LazyCaptchaComponent],
providers: [SitekeyProviderService],
})
export class CaptchaExportsModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<ng-template #anchor></ng-template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { ComponentFixture, TestBed, async, fakeAsync, tick } from '@angular/core/testing';
import { FormControl, FormGroup } from '@angular/forms';
import { By } from '@angular/platform-browser';
import { RouterTestingModule } from '@angular/router/testing';
import { TranslateModule } from '@ngx-translate/core';
import { MockComponent } from 'ng-mocks';
import { RECAPTCHA_V3_SITE_KEY, RecaptchaComponent } from 'ng-recaptcha';
import { EMPTY, of } from 'rxjs';
import { anyString, instance, mock, when } from 'ts-mockito';

import { configurationReducer } from 'ish-core/store/configuration/configuration.reducer';
import { ngrxTesting } from 'ish-core/utils/dev/ngrx-testing';

import { CaptchaFacade } from '../../../facades/captcha.facade';
import { CaptchaV2Component, CaptchaV2ComponentModule } from '../../../shared/captcha/captcha-v2/captcha-v2.component';
import { CaptchaV3Component, CaptchaV3ComponentModule } from '../../../shared/captcha/captcha-v3/captcha-v3.component';

import { LazyCaptchaComponent } from './lazy-captcha.component';

describe('Lazy Captcha Component', () => {
let fixture: ComponentFixture<LazyCaptchaComponent>;
let component: LazyCaptchaComponent;
let element: HTMLElement;
let captchaFacade: CaptchaFacade;

beforeEach(async(() => {
captchaFacade = mock(CaptchaFacade);
when(captchaFacade.captchaVersion$).thenReturn(EMPTY);
when(captchaFacade.captchaSiteKey$).thenReturn(of('captchaSiteKeyASDF'));
when(captchaFacade.captchaActive$(anyString())).thenReturn(of(true));

TestBed.configureTestingModule({
declarations: [MockComponent(RecaptchaComponent)],
imports: [
CaptchaV2ComponentModule,
CaptchaV3ComponentModule,
RouterTestingModule,
TranslateModule.forRoot(),
ngrxTesting({
reducers: { configuration: configurationReducer },
}),
],
providers: [
{ provide: CaptchaFacade, useFactory: () => instance(captchaFacade) },
{ provide: RECAPTCHA_V3_SITE_KEY, useValue: 'captchaSiteKeyQWERTY' },
],
})
.overrideModule(CaptchaV2ComponentModule, { set: { entryComponents: [CaptchaV2Component] } })
.overrideModule(CaptchaV3ComponentModule, { set: { entryComponents: [CaptchaV3Component] } })
// .overrideComponent(LazyCaptchaComponent, { set: { entryComponents: [CaptchaV2Component, CaptchaV3Component] } })
.compileComponents();
}));

beforeEach(() => {
fixture = TestBed.createComponent(LazyCaptchaComponent);
component = fixture.componentInstance;
element = fixture.nativeElement;
component.form = new FormGroup({
captcha: new FormControl(''),
captchaAction: new FormControl(''),
});
component.cssClass = 'd-none';
component.topic = 'register';
});

it('should be created', () => {
expect(component).toBeTruthy();
expect(element).toBeTruthy();
expect(() => fixture.detectChanges()).not.toThrow();
});

it('should render v2 component when configured', fakeAsync(() => {
when(captchaFacade.captchaVersion$).thenReturn(of(2 as 2));
fixture.detectChanges();

tick(500);
expect(element).toMatchInlineSnapshot(`<ish-captcha-v2></ish-captcha-v2>`);
const v2Cmp: CaptchaV2Component = fixture.debugElement.query(By.css('ish-captcha-v2'))?.componentInstance;
expect(v2Cmp).toBeTruthy();
expect(v2Cmp.cssClass).toEqual('d-none');
}));
it('should render v3 component when configured', fakeAsync(() => {
when(captchaFacade.captchaVersion$).thenReturn(of(3 as 3));
fixture.detectChanges();

tick(500);
expect(element).toMatchInlineSnapshot(`<ish-captcha-v3></ish-captcha-v3>`);
const v3Cmp: CaptchaV3Component = fixture.debugElement.query(By.css('ish-captcha-v3'))?.componentInstance;
expect(v3Cmp).toBeTruthy();
}));

// errors are thrown if required input parameters are missing
it('should throw an error if there is no form set as input parameter', () => {
component.form = undefined;
expect(() => fixture.detectChanges()).toThrowErrorMatchingInlineSnapshot(
`"required input parameter <form> is missing for FormElementComponent"`
);
});

it('should throw an error if there is no control "captcha" in the given form', () => {
delete component.form.controls.captcha;
expect(() => fixture.detectChanges()).toThrowErrorMatchingInlineSnapshot(
`"form control 'captcha' does not exist in the given form for LazyCaptchaComponent"`
);
});

it('should throw an error if there is no control "captchaAction" in the given form', () => {
delete component.form.controls.captchaAction;
expect(() => fixture.detectChanges()).toThrowErrorMatchingInlineSnapshot(
`"form control 'captchaAction' does not exist in the given form for LazyCaptchaComponent"`
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import {
ChangeDetectionStrategy,
Compiler,
Component,
Injector,
Input,
NgModuleFactory,
OnDestroy,
OnInit,
ViewChild,
ViewContainerRef,
} from '@angular/core';
import { AbstractControl, FormGroup, Validators } from '@angular/forms';
import { Subject } from 'rxjs';
import { switchMapTo, takeUntil } from 'rxjs/operators';

import { CaptchaFacade } from '../../../facades/captcha.facade';

/**
* The Captcha Component
*
* Displays a captcha form control (V2) or widget (V3) if the captchaV2 or the captchaV3 feature is enabled.
* It expects the given form to have the form controls for the captcha (controlName) and the captcha action (actionControlName).
* If the captcha is confirmed the captcha form control contains the captcha response token provided by the captcha service.
*
* The parent form supplied must have controls for 'captcha' and 'captchaAction'
*
* @example
* <ish-captcha [form]="form" cssClass="offset-md-2 col-md-8" topic="contactUs"></ish-captcha>
*/
@Component({
selector: 'ish-lazy-captcha',
templateUrl: './lazy-captcha.component.html',
changeDetection: ChangeDetectionStrategy.Default,
})
export class LazyCaptchaComponent implements OnInit, OnDestroy {
@ViewChild('anchor', { read: ViewContainerRef, static: true }) anchor: ViewContainerRef;

/**
form containing the captcha form controls
*/
@Input() form: FormGroup;

/**
css Class for rendering the captcha V2 control, default='offset-md-4 col-md-8'
*/
@Input() cssClass = 'offset-md-4 col-md-8';

@Input() topic:
| 'contactUs'
| 'emailShoppingCart'
| 'forgotPassword'
| 'redemptionOfGiftCardsAndCertificates'
| 'register';

private destroy$ = new Subject();

constructor(private captchaFacade: CaptchaFacade, private compiler: Compiler, private injector: Injector) {}

ngOnInit() {
this.sanityCheck();

this.captchaFacade
.captchaActive$(this.topic)
.pipe(switchMapTo(this.captchaFacade.captchaVersion$), takeUntil(this.destroy$))
.subscribe(async version => {
if (version === 3) {
this.actionFormControl.setValue(this.topic);

const { CaptchaV3Component, CaptchaV3ComponentModule } = await import(
'../../../shared/captcha/captcha-v3/captcha-v3.component'
);
const moduleFactory = await this.loadModuleFactory(CaptchaV3ComponentModule);
const moduleRef = moduleFactory.create(this.injector);
const factory = moduleRef.componentFactoryResolver.resolveComponentFactory(CaptchaV3Component);

const componentRef = this.anchor.createComponent(factory);

componentRef.instance.parentForm = this.form;
} else if (version === 2) {
this.formControl.setValidators([Validators.required]);
this.formControl.updateValueAndValidity();

const { CaptchaV2Component, CaptchaV2ComponentModule } = await import(
'../../../shared/captcha/captcha-v2/captcha-v2.component'
);
const moduleFactory = await this.loadModuleFactory(CaptchaV2ComponentModule);
const moduleRef = moduleFactory.create(this.injector);
const factory = moduleRef.componentFactoryResolver.resolveComponentFactory(CaptchaV2Component);

const componentRef = this.anchor.createComponent(factory);

componentRef.instance.cssClass = this.cssClass;
componentRef.instance.parentForm = this.form;
}
});
}

// tslint:disable-next-line: no-any
private async loadModuleFactory(t: any) {
if (t instanceof NgModuleFactory) {
return t;
} else {
return await this.compiler.compileModuleAsync(t);
}
}

private sanityCheck() {
if (!this.form) {
throw new Error('required input parameter <form> is missing for FormElementComponent');
}
if (!this.formControl) {
throw new Error(`form control 'captcha' does not exist in the given form for LazyCaptchaComponent`);
}
if (!this.actionFormControl) {
throw new Error(`form control 'captchaAction' does not exist in the given form for LazyCaptchaComponent`);
}
}

private get formControl(): AbstractControl {
return this.form?.get('captcha');
}

private get actionFormControl(): AbstractControl {
return this.form?.get('captchaAction');
}

ngOnDestroy() {
this.destroy$.next();
}
}
57 changes: 57 additions & 0 deletions src/app/extensions/captcha/facades/captcha.facade.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Injectable } from '@angular/core';
import { Store, select } from '@ngrx/store';
import { Observable } from 'rxjs';
import { filter, map, switchMap, switchMapTo } from 'rxjs/operators';

import { getFeatures, getServerConfigParameter } from 'ish-core/store/configuration';
import { whenTruthy } from 'ish-core/utils/operators';

// tslint:disable:member-ordering
@Injectable({ providedIn: 'root' })
export class CaptchaFacade {
constructor(private store: Store) {}

captchaVersion$: Observable<2 | 3 | undefined> = this.store.pipe(
select(
getServerConfigParameter<{
ReCaptchaV2ServiceDefinition: { runnable: boolean };
ReCaptchaV3ServiceDefinition: { runnable: boolean };
}>('services')
),
whenTruthy(),
map(services =>
services.ReCaptchaV3ServiceDefinition && services.ReCaptchaV3ServiceDefinition.runnable
? 3
: services.ReCaptchaV2ServiceDefinition && services.ReCaptchaV2ServiceDefinition.runnable
? 2
: undefined
)
);

captchaSiteKey$ = this.captchaVersion$.pipe(
whenTruthy(),
switchMap(version =>
this.store.pipe(
select(getServerConfigParameter<string>(`services.ReCaptchaV${version}ServiceDefinition.SiteKey`)),
whenTruthy()
)
)
);

/**
* @param key feature name according to the captcha ICM configuration, e.g. register, forgotPassword, contactUs
*/
captchaActive$(key: string): Observable<boolean> {
return this.store.pipe(
filter(() => !!key),
select(getFeatures),
filter(features => !features.includes('noCaptcha')),
switchMapTo(this.captchaVersion$),
map(x => !!x),
whenTruthy()
/* TODO: replace with the following when configuration REST API sends correct values
select(getServerConfigParameter<boolean>('captcha.' + key))
*/
);
}
}
Loading

0 comments on commit 8bfc9d3

Please sign in to comment.