Skip to content

Commit

Permalink
cxspa-4006: implement CHAPTCHA framework with mock CHAPTCHA service (#…
Browse files Browse the repository at this point in the history
…18904)

Co-authored-by: i822892 <[email protected]>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Arkadiy Sukharenko <[email protected]>
Co-authored-by: Lee <[email protected]>
Co-authored-by: Grace <[email protected]>
Co-authored-by: Grace Dong <[email protected]>
Co-authored-by: Giancarlo Cordero Ortiz <[email protected]>
Co-authored-by: Radhep Sabapathipillai <[email protected]>
  • Loading branch information
9 people authored Aug 8, 2024
1 parent d7a9b6c commit 5826242
Show file tree
Hide file tree
Showing 29 changed files with 892 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,10 @@
</label>
</div>
</div>
<cx-captcha (confirmed)="captchaConfirmed()"></cx-captcha>
<cx-form-errors
[control]="registerForm.get('captcha')"
></cx-form-errors>
<button type="submit" class="btn btn-block btn-primary">
{{ 'register.register' | cxTranslate }}
</button>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Component, Pipe, PipeTransform } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { AbstractControl, ReactiveFormsModule } from '@angular/forms';
import { By } from '@angular/platform-browser';
import { RouterTestingModule } from '@angular/router/testing';
import { NgSelectModule } from '@ng-select/ng-select';
Expand All @@ -18,8 +18,13 @@ import {
OAuthFlow,
RoutingService,
Title,
BaseSite,
SiteAdapter,
BaseSiteService,
LanguageService,
} from '@spartacus/core';
import {
CaptchaModule,
FormErrorsModule,
NgSelectA11yModule,
PasswordVisibilityToggleModule,
Expand All @@ -40,6 +45,7 @@ const mockRegisterFormData: any = {
password: 'strongPass$!123',
passwordconf: 'strongPass$!123',
newsletter: true,
captcha: true,
};

const mockTitlesList: Title[] = [
Expand Down Expand Up @@ -115,6 +121,30 @@ class MockRegisterComponentService
generateAdditionalConsentsFormControl = createSpy();
}

class MockSiteAdapter {
public loadBaseSite(siteUid?: string): Observable<BaseSite> {
return of<BaseSite>({
uid: siteUid,
captchaConfig: {
enabled: true,
publicKey: 'mock-key',
},
});
}
}

class MockBaseSiteService {
getActive(): Observable<string> {
return of('mock-site');
}
}

class MockLanguageService {
getActive(): Observable<string> {
return of('mock-lang');
}
}

describe('RegisterComponent', () => {
let controls: any;
let component: RegisterComponent;
Expand All @@ -137,6 +167,7 @@ describe('RegisterComponent', () => {
NgSelectModule,
PasswordVisibilityToggleModule,
NgSelectA11yModule,
CaptchaModule,
],
declarations: [
RegisterComponent,
Expand Down Expand Up @@ -169,6 +200,18 @@ describe('RegisterComponent', () => {
provide: AuthConfigService,
useClass: MockAuthConfigService,
},
{
provide: SiteAdapter,
useClass: MockSiteAdapter,
},
{
provide: BaseSiteService,
useClass: MockBaseSiteService,
},
{
provide: LanguageService,
useClass: MockLanguageService,
},
],
}).compileComponents();
}));
Expand Down Expand Up @@ -358,4 +401,42 @@ describe('RegisterComponent', () => {
expect(controls['newsletter'].status).toEqual('DISABLED');
});
});

describe('captcha', () => {
let captchaComponent;
beforeEach(() => {
captchaComponent = fixture.debugElement.query(By.css('cx-captcha'));
spyOn(component, 'registerUser').and.callThrough();
mockRegisterFormData.captcha = false;
component.registerForm.patchValue(mockRegisterFormData);
});

function getCaptchaControl(component: RegisterComponent): AbstractControl {
return component.registerForm.get('captcha') as AbstractControl;
}

it('should create captcha component', () => {
expect(captchaComponent).toBeTruthy();
});

it('should enable captcha', () => {
captchaComponent.triggerEventHandler('enabled', true);
component.submitForm();

expect(getCaptchaControl(component).valid).toEqual(false);
expect(component.registerUser).toHaveBeenCalledTimes(0);
});

it('should confirm captcha', () => {
spyOn(component, 'captchaConfirmed').and.callThrough();

captchaComponent.triggerEventHandler('enabled', true);
captchaComponent.triggerEventHandler('confirmed', true);
component.submitForm();

expect(getCaptchaControl(component).value).toBe(true);
expect(getCaptchaControl(component).valid).toEqual(true);
expect(component.registerUser).toHaveBeenCalledTimes(1);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export class RegisterComponent implements OnInit, OnDestroy {
this.registerComponentService.generateAdditionalConsentsFormControl?.() ??
this.fb.array([]),
termsandconditions: [false, Validators.requiredTrue],
captcha: [false, Validators.requiredTrue],
},
{
validators: CustomFormValidators.passwordsMustMatch(
Expand Down Expand Up @@ -244,6 +245,13 @@ export class RegisterComponent implements OnInit, OnDestroy {
}
}

/**
* Triggered via CaptchaComponent when a user confirms captcha
*/
captchaConfirmed() {
this.registerForm.get('captcha')?.setValue(true);
}

ngOnDestroy() {
this.subscription.unsubscribe();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
provideDefaultConfig,
} from '@spartacus/core';
import {
CaptchaModule,
FormErrorsModule,
NgSelectA11yModule,
PasswordVisibilityToggleModule,
Expand All @@ -30,6 +31,7 @@ import { RegisterComponent } from './register.component';

@NgModule({
imports: [
CaptchaModule,
CommonModule,
ReactiveFormsModule,
RouterModule,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import {
import { TestBed } from '@angular/core/testing';
import {
BaseOccUrlProperties,
CaptchaConfig,
ConverterService,
DynamicAttributes,
Occ,
OccConfig,
OccEndpointsService,
USE_CAPTCHA_TOKEN,
} from '@spartacus/core';
import { User } from '@spartacus/user/account/root';
import {
Expand All @@ -19,6 +21,8 @@ import {
} from '@spartacus/user/profile/core';
import { UserSignUp } from '@spartacus/user/profile/root';
import { OccUserProfileAdapter } from './occ-user-profile.adapter';
import { Observable, of } from 'rxjs';
import { CaptchaApiConfig, CaptchaProvider } from '@spartacus/storefront';

export const mockOccModuleConfig: OccConfig = {
backend: {
Expand Down Expand Up @@ -63,6 +67,30 @@ const user: User = {
displayUid: password,
};

const mockToken = 'mock-token';
class MockCaptchaService implements CaptchaProvider {
getCaptchaConfig(): Observable<CaptchaConfig> {
return of({
enabled: true,
publicKey: 'mock-key',
});
}

getToken(): string {
return mockToken;
}

renderCaptcha(): Observable<string> {
return of('');
}
}

const mockCaptchaApiConfig: CaptchaApiConfig = {
apiUrl: 'mock-url',
fields: { 'mock-field-key': 'mock-field-value' },
captchaProvider: MockCaptchaService,
};

describe('OccUserProfileAdapter', () => {
let occUserAdapter: OccUserProfileAdapter;
let httpMock: HttpTestingController;
Expand All @@ -79,6 +107,8 @@ describe('OccUserProfileAdapter', () => {
provide: OccEndpointsService,
useClass: MockOccEndpointsService,
},
{ provide: CaptchaApiConfig, useValue: mockCaptchaApiConfig },
MockCaptchaService,
],
});

Expand Down Expand Up @@ -152,6 +182,7 @@ describe('OccUserProfileAdapter', () => {

expect(mockReq.cancelled).toBeFalsy();
expect(mockReq.request.responseType).toEqual('json');
expect(mockReq.request.headers.get(USE_CAPTCHA_TOKEN)).toEqual(mockToken);
expect(mockReq.request.body).toEqual(userSignUp);
mockReq.flush(userSignUp);
});
Expand Down
27 changes: 25 additions & 2 deletions feature-libs/user/profile/occ/adapters/occ-user-profile.adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@
*/

import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Injectable, Injector, inject } from '@angular/core';
import {
ConverterService,
InterceptorUtil,
LoggerService,
Occ,
OccEndpointsService,
USE_CAPTCHA_TOKEN,
USE_CLIENT_TOKEN,
normalizeHttpError,
} from '@spartacus/core';
Expand All @@ -26,6 +27,7 @@ import {
import { Title, UserSignUp } from '@spartacus/user/profile/root';
import { Observable } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { CaptchaApiConfig, CaptchaProvider } from '@spartacus/storefront';

const CONTENT_TYPE_JSON_HEADER = { 'Content-Type': 'application/json' };
const CONTENT_TYPE_URLENCODED_HEADER = {
Expand All @@ -35,6 +37,8 @@ const CONTENT_TYPE_URLENCODED_HEADER = {
@Injectable()
export class OccUserProfileAdapter implements UserProfileAdapter {
protected logger = inject(LoggerService);
protected captchaConfig = inject(CaptchaApiConfig, { optional: true });
protected injector = inject(Injector, { optional: true });

constructor(
protected http: HttpClient,
Expand All @@ -61,6 +65,7 @@ export class OccUserProfileAdapter implements UserProfileAdapter {
...CONTENT_TYPE_JSON_HEADER,
});
headers = InterceptorUtil.createHeader(USE_CLIENT_TOKEN, true, headers);
headers = this.appendCaptchaToken(headers);
user = this.converter.convert(user, USER_SIGN_UP_SERIALIZER);

return this.http.post<User>(url, user, { headers }).pipe(
Expand All @@ -77,7 +82,7 @@ export class OccUserProfileAdapter implements UserProfileAdapter {
...CONTENT_TYPE_URLENCODED_HEADER,
});
headers = InterceptorUtil.createHeader(USE_CLIENT_TOKEN, true, headers);

headers = this.appendCaptchaToken(headers);
const httpParams: HttpParams = new HttpParams()
.set('guid', guid)
.set('password', password);
Expand Down Expand Up @@ -185,4 +190,22 @@ export class OccUserProfileAdapter implements UserProfileAdapter {
this.converter.pipeableMany(TITLE_NORMALIZER)
);
}

protected appendCaptchaToken(currentHeaders: HttpHeaders): HttpHeaders {
if (this.injector && this.captchaConfig?.captchaProvider) {
const provider = this.injector.get<CaptchaProvider>(
this.captchaConfig.captchaProvider
);
const isCaptchaEnabled = provider
.getCaptchaConfig()
.subscribe((config) => {
return config.enabled;
});

if (provider?.getToken() && isCaptchaEnabled) {
return currentHeaders.append(USE_CAPTCHA_TOKEN, provider.getToken());
}
}
return currentHeaders;
}
}
2 changes: 2 additions & 0 deletions feature-libs/user/profile/user-profile.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ import { NgModule } from '@angular/core';
import { UserProfileComponentsModule } from '@spartacus/user/profile/components';
import { UserProfileCoreModule } from '@spartacus/user/profile/core';
import { UserProfileOccModule } from '@spartacus/user/profile/occ';
import { CaptchaModule } from '@spartacus/storefront';

@NgModule({
imports: [
UserProfileCoreModule,
UserProfileOccModule,
UserProfileComponentsModule,
CaptchaModule,
],
})
export class UserProfileModule {}
6 changes: 6 additions & 0 deletions projects/core/src/model/misc.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,10 @@ export interface BaseSite {
baseStore?: BaseStore;
requiresAuthentication?: boolean;
isolated?: boolean;
captchaConfig?: CaptchaConfig;
}

export interface CaptchaConfig {
enabled: boolean;
publicKey?: string;
}
1 change: 1 addition & 0 deletions projects/core/src/occ/utils/interceptor-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { HttpHeaders, HttpRequest } from '@angular/common/http';

export const USE_CLIENT_TOKEN = 'cx-use-client-token';
export const USE_CUSTOMER_SUPPORT_AGENT_TOKEN = 'cx-use-csagent-token';
export const USE_CAPTCHA_TOKEN = 'sap-commerce-cloud-captcha-token';

export class InterceptorUtil {
static createHeader<T>(
Expand Down
1 change: 1 addition & 0 deletions projects/storefrontapp-e2e-cypress/cypress.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { defineConfig } from 'cypress';
export default defineConfig({
defaultCommandTimeout: 10000,
requestTimeout: 15000,
chromeWebSecurity: false,
retries: {
runMode: 2,
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* SPDX-FileCopyrightText: 2023 SAP Spartacus team <[email protected]>
* SPDX-FileCopyrightText: 2024 SAP Spartacus team <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0
*/

import { tabbingOrderConfig as config } from '../../../../helpers/accessibility/tabbing-order.config';
import { registerWithCaptchaTabbingOrder } from '../../../../helpers/accessibility/tabbing-order/register';

describe('Tabbing order for Captcha', () => {
before(() => {
cy.window().then((win) => win.sessionStorage.clear());
});

context('Captcha', () => {
context('Register page', () => {
it('should allow to navigate to captcha with tab key', () => {
cy.intercept('GET', /\.*\/basesites\?fields=.*/, (req) => {
req.continue((res) => {
res?.body?.baseSites?.forEach((baseSite) => {
baseSite.captchaConfig = {
enabled: true,
};
});
res.send(res.body);
});
});
registerWithCaptchaTabbingOrder(config.registerWithCaptcha);
});
});
});
});
Loading

0 comments on commit 5826242

Please sign in to comment.