[CST-15074] Fixes cyclic dependency issue

This commit is contained in:
Vincenzo Mecca
2025-03-23 22:33:27 +01:00
parent c1b951b18c
commit 1316540765
15 changed files with 302 additions and 53 deletions

View File

@@ -0,0 +1,133 @@
import { TestBed } from '@angular/core/testing';
import {
Store,
StoreModule,
} from '@ngrx/store';
import {
MockStore,
provideMockStore,
} from '@ngrx/store/testing';
import { storeModuleConfig } from '../../app.reducer';
import { AuthMethodTypeComponent } from '../../shared/log-in/methods/auth-methods.type';
import { authReducer } from './auth.reducer';
import { AuthMethodsService } from './auth-methods.service';
import { AuthMethod } from './models/auth.method';
import { AuthMethodType } from './models/auth.method-type';
describe('AuthMethodsService', () => {
let service: AuthMethodsService;
let store: MockStore;
let mockAuthMethods: Map<AuthMethodType, AuthMethodTypeComponent>;
let mockAuthMethodsArray: AuthMethod[] = [
{ id: 'password', authMethodType: AuthMethodType.Password, position: 2 } as AuthMethod,
{ id: 'shibboleth', authMethodType: AuthMethodType.Shibboleth, position: 1 } as AuthMethod,
{ id: 'oidc', authMethodType: AuthMethodType.Oidc, position: 3 } as AuthMethod,
{ id: 'ip', authMethodType: AuthMethodType.Ip, position: 4 } as AuthMethod,
];
const initialState = {
core: {
auth: {
authMethods: mockAuthMethodsArray,
},
},
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
StoreModule.forRoot(authReducer, storeModuleConfig),
],
providers: [
AuthMethodsService,
provideMockStore({ initialState }),
],
});
service = TestBed.inject(AuthMethodsService);
store = TestBed.inject(Store) as MockStore;
// Setup mock auth methods map
mockAuthMethods = new Map<AuthMethodType, AuthMethodTypeComponent>();
mockAuthMethods.set(AuthMethodType.Password, {} as AuthMethodTypeComponent);
mockAuthMethods.set(AuthMethodType.Shibboleth, {} as AuthMethodTypeComponent);
mockAuthMethods.set(AuthMethodType.Oidc, {} as AuthMethodTypeComponent);
mockAuthMethods.set(AuthMethodType.Ip, {} as AuthMethodTypeComponent);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('getAuthMethods', () => {
it('should return auth methods sorted by position', () => {
// Expected result after sorting and filtering IP auth
const expected = [
{ id: 'shibboleth', authMethodType: AuthMethodType.Shibboleth, position: 1 },
{ id: 'password', authMethodType: AuthMethodType.Password, position: 2 },
{ id: 'oidc', authMethodType: AuthMethodType.Oidc, position: 3 },
];
service.getAuthMethods(mockAuthMethods).subscribe(result => {
expect(result.length).toBe(3);
expect(result).toEqual(expected);
});
});
it('should exclude specified auth method type', () => {
// Expected result after excluding Password auth and filtering IP auth
const expected = [
{ id: 'shibboleth', authMethodType: AuthMethodType.Shibboleth, position: 1 },
{ id: 'oidc', authMethodType: AuthMethodType.Oidc, position: 3 },
];
service.getAuthMethods(mockAuthMethods, AuthMethodType.Password).subscribe(result => {
expect(result.length).toBe(2);
expect(result).toEqual(expected);
});
});
it('should always filter out IP authentication method', () => {
// Add IP auth to the mock methods map
mockAuthMethods.set(AuthMethodType.Ip, {} as AuthMethodTypeComponent);
service.getAuthMethods(mockAuthMethods).subscribe(result => {
expect(result.length).toBe(3);
expect(result.find(method => method.authMethodType === AuthMethodType.Ip)).toBeUndefined();
});
});
it('should handle empty auth methods array', () => {
const authMethods = new Map<AuthMethodType, AuthMethodTypeComponent>();
service.getAuthMethods(authMethods).subscribe(result => {
expect(result.length).toBe(0);
expect(result).toEqual([]);
});
});
it('should handle duplicate auth method types and keep only unique ones', () => {
// Arrange
const duplicateMethodsArray = [
...mockAuthMethodsArray,
{ id: 'password2', authMethodType: AuthMethodType.Password, position: 5 } as AuthMethod,
];
service.getAuthMethods(mockAuthMethods).subscribe(result => {
expect(result.length).toBe(3);
// Check that we only have one Password auth method
const passwordMethods = result.filter(method => method.authMethodType === AuthMethodType.Password);
expect(passwordMethods.length).toBe(1);
});
});
});
});

View File

@@ -0,0 +1,51 @@
import { Injectable } from '@angular/core';
import {
select,
Store,
} from '@ngrx/store';
import uniqBy from 'lodash/uniqBy';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { AppState } from '../../app.reducer';
import { AuthMethodTypeComponent } from '../../shared/log-in/methods/auth-methods.type';
import { rendersAuthMethodType } from '../../shared/log-in/methods/log-in.methods-decorator.utils';
import { AuthMethod } from './models/auth.method';
import { AuthMethodType } from './models/auth.method-type';
import { getAuthenticationMethods } from './selectors';
@Injectable({
providedIn: 'root',
})
/**
* Service responsible for managing and filtering authentication methods.
* Provides methods to retrieve and process authentication methods from the application store.
*/
export class AuthMethodsService {
constructor(protected store: Store<AppState>) {
}
/**
* Retrieves and processes authentication methods from the store.
*
* @param authMethods A map of authentication method types to their corresponding components
* @param excludedAuthMethod Optional authentication method type to exclude from the results
* @returns An Observable of filtered and sorted authentication methods
*/
public getAuthMethods(
authMethods: Map<AuthMethodType, AuthMethodTypeComponent>,
excludedAuthMethod?: AuthMethodType,
): Observable<AuthMethod[]> {
return this.store.pipe(
select(getAuthenticationMethods),
map((methods: AuthMethod[]) => methods
// ignore the given auth method if it should be excluded
.filter((authMethod: AuthMethod) => excludedAuthMethod == null || authMethod.authMethodType !== excludedAuthMethod)
.filter((authMethod: AuthMethod) => rendersAuthMethodType(authMethods, authMethod.authMethodType) !== undefined)
.sort((method1: AuthMethod, method2: AuthMethod) => method1.position - method2.position),
),
// ignore the ip authentication method when it's returned by the backend
map((methods: AuthMethod[]) => uniqBy(methods.filter(a => a.authMethodType !== AuthMethodType.Ip), 'authMethodType')),
);
}
}

View File

@@ -11,7 +11,6 @@ import {
} from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { CookieAttributes } from 'js-cookie';
import uniqBy from 'lodash/uniqBy';
import {
Observable,
of as observableOf,
@@ -39,7 +38,6 @@ import {
isNotNull,
isNotUndefined,
} from '../../shared/empty.util';
import { rendersAuthMethodType } from '../../shared/log-in/methods/log-in.methods-decorator';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { followLink } from '../../shared/utils/follow-link-config.model';
@@ -76,7 +74,6 @@ import {
} from './auth.actions';
import { AuthRequestService } from './auth-request.service';
import { AuthMethod } from './models/auth.method';
import { AuthMethodType } from './models/auth.method-type';
import { AuthStatus } from './models/auth-status.model';
import {
AuthTokenInfo,
@@ -84,7 +81,6 @@ import {
} from './models/auth-token-info.model';
import {
getAuthenticatedUserId,
getAuthenticationMethods,
getAuthenticationToken,
getExternalAuthCookieStatus,
getRedirectUrl,
@@ -283,7 +279,7 @@ export class AuthService {
if (status.hasSucceeded) {
return status.payload.specialGroups;
} else {
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(),[]));
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), []));
}
}),
);
@@ -693,19 +689,4 @@ export class AuthService {
this.store.dispatch(new UnsetUserAsIdleAction());
}
}
public getAuthMethods(excludedAuthMethod?: AuthMethodType): Observable<AuthMethod[]> {
return this.store.pipe(
select(getAuthenticationMethods),
map((methods: AuthMethod[]) => methods
// ignore the given auth method if it should be excluded
.filter((authMethod: AuthMethod) => excludedAuthMethod == null || authMethod.authMethodType !== excludedAuthMethod)
.filter((authMethod: AuthMethod) => rendersAuthMethodType(authMethod.authMethodType) !== undefined)
.sort((method1: AuthMethod, method2: AuthMethod) => method1.position - method2.position),
),
// ignore the ip authentication method when it's returned by the backend
map((authMethods: AuthMethod[]) => uniqBy(authMethods.filter(a => a.authMethodType !== AuthMethodType.Ip), 'authMethodType')),
);
}
}

View File

@@ -22,7 +22,7 @@
<h4 class="mt-2">{{ 'external-login.component.or' | translate }}</h4>
</div>
<div class="col d-flex justify-content-center align-items-center">
<button class="btn block btn-lg btn-primary" (click)="openLoginModal(loginModal)">
<button data-test="open-modal" class="btn block btn-lg btn-primary" (click)="openLoginModal(loginModal)">
{{ 'external-login.connect-to-existing-account.label' | translate }}
</button>
</div>

View File

@@ -8,16 +8,24 @@ import { FormBuilder } from '@angular/forms';
import { By } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { StoreModule } from '@ngrx/store';
import { provideMockStore } from '@ngrx/store/testing';
import {
TranslateModule,
TranslateService,
} from '@ngx-translate/core';
import { of as observableOf } from 'rxjs';
import { storeModuleConfig } from '../../app.reducer';
import { authReducer } from '../../core/auth/auth.reducer';
import { AuthService } from '../../core/auth/auth.service';
import { AuthMethodsService } from '../../core/auth/auth-methods.service';
import { AuthMethod } from '../../core/auth/models/auth.method';
import { AuthMethodType } from '../../core/auth/models/auth.method-type';
import { AuthRegistrationType } from '../../core/auth/models/auth.registration-type';
import { MetadataValue } from '../../core/shared/metadata.models';
import { Registration } from '../../core/shared/registration.model';
import { AuthMethodTypeComponent } from '../../shared/log-in/methods/auth-methods.type';
import { AuthServiceMock } from '../../shared/mocks/auth.service.mock';
import { BrowserOnlyPipe } from '../../shared/utils/browser-only.pipe';
import { ConfirmEmailComponent } from '../email-confirmation/confirm-email/confirm-email.component';
@@ -28,6 +36,22 @@ describe('ExternalLogInComponent', () => {
let component: ExternalLogInComponent;
let fixture: ComponentFixture<ExternalLogInComponent>;
let modalService: NgbModal = jasmine.createSpyObj('modalService', ['open']);
let authServiceStub: jasmine.SpyObj<AuthService>;
let authMethodsServiceStub: jasmine.SpyObj<AuthMethodsService>;
let mockAuthMethodsArray: AuthMethod[] = [
{ id: 'password', authMethodType: AuthMethodType.Password, position: 2 } as AuthMethod,
{ id: 'shibboleth', authMethodType: AuthMethodType.Shibboleth, position: 1 } as AuthMethod,
{ id: 'oidc', authMethodType: AuthMethodType.Oidc, position: 3 } as AuthMethod,
{ id: 'ip', authMethodType: AuthMethodType.Ip, position: 4 } as AuthMethod,
];
const initialState = {
core: {
auth: {
authMethods: mockAuthMethodsArray,
},
},
};
const registrationDataMock = {
id: '3',
@@ -55,14 +79,26 @@ describe('ExternalLogInComponent', () => {
onDefaultLangChange: new EventEmitter(),
};
beforeEach(() =>
TestBed.configureTestingModule({
imports: [CommonModule, TranslateModule.forRoot({}), BrowserOnlyPipe, ExternalLogInComponent, OrcidConfirmationComponent, BrowserAnimationsModule],
beforeEach(async () => {
authServiceStub = jasmine.createSpyObj('AuthService', ['getAuthenticationMethods']);
authMethodsServiceStub = jasmine.createSpyObj('AuthMethodsService', ['getAuthMethods']);
await TestBed.configureTestingModule({
imports: [
CommonModule,
TranslateModule.forRoot({}),
BrowserOnlyPipe,
ExternalLogInComponent,
OrcidConfirmationComponent,
BrowserAnimationsModule,
StoreModule.forRoot(authReducer, storeModuleConfig),
],
providers: [
{ provide: TranslateService, useValue: translateServiceStub },
{ provide: AuthService, useValue: new AuthServiceMock() },
{ provide: NgbModal, useValue: modalService },
FormBuilder,
provideMockStore({ initialState }),
],
})
.overrideComponent(ExternalLogInComponent, {
@@ -70,23 +106,31 @@ describe('ExternalLogInComponent', () => {
imports: [ConfirmEmailComponent],
},
})
.compileComponents(),
);
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ExternalLogInComponent);
component = fixture.componentInstance;
component.registrationData = Object.assign(new Registration(), registrationDataMock);
component.registrationType = registrationDataMock.registrationType;
let mockAuthMethods = new Map<AuthMethodType, AuthMethodTypeComponent>();
mockAuthMethods.set(AuthMethodType.Password, {} as AuthMethodTypeComponent);
mockAuthMethods.set(AuthMethodType.Shibboleth, {} as AuthMethodTypeComponent);
mockAuthMethods.set(AuthMethodType.Oidc, {} as AuthMethodTypeComponent);
mockAuthMethods.set(AuthMethodType.Ip, {} as AuthMethodTypeComponent);
component.authMethods = mockAuthMethods;
fixture.detectChanges();
});
it('should create', () => {
fixture.detectChanges();
expect(component).toBeTruthy();
});
beforeEach(() => {
component.registrationData = Object.assign(new Registration(), registrationDataMock, { email: 'user@institution.edu' });
fixture.detectChanges();
});
@@ -103,8 +147,11 @@ describe('ExternalLogInComponent', () => {
});
it('should display login modal when connect to existing account button is clicked', () => {
const button = fixture.nativeElement.querySelector('button.btn-primary');
button.click();
const button = fixture.debugElement.query(By.css('[data-test="open-modal"]'));
expect(button).not.toBeNull('Connect to existing account button should be in the DOM');
button.nativeElement.click();
expect(modalService.open).toHaveBeenCalled();
});

View File

@@ -22,6 +22,7 @@ import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { AuthService } from '../../core/auth/auth.service';
import { AuthMethodsService } from '../../core/auth/auth-methods.service';
import { AuthMethodType } from '../../core/auth/models/auth.method-type';
import { AuthRegistrationType } from '../../core/auth/models/auth.registration-type';
import { Registration } from '../../core/shared/registration.model';
@@ -31,6 +32,7 @@ import {
hasValue,
isEmpty,
} from '../../shared/empty.util';
import { AuthMethodTypeComponent } from '../../shared/log-in/methods/auth-methods.type';
import { ThemedLogInComponent } from '../../shared/log-in/themed-log-in.component';
import {
ExternalLoginTypeComponent,
@@ -78,6 +80,11 @@ export class ExternalLogInComponent implements OnInit, OnDestroy {
* @memberof ExternalLogInComponent
*/
@Input() token: string;
/**
* The authMethods taken from the configuration
* @memberof ExternalLogInComponent
*/
@Input() authMethods: Map<AuthMethodType, AuthMethodTypeComponent>;
/**
* The information text to be displayed,
* depending on the registration type and the presence of an email
@@ -109,6 +116,7 @@ export class ExternalLogInComponent implements OnInit, OnDestroy {
private translate: TranslateService,
private modalService: NgbModal,
private authService: AuthService,
private authMethodsService: AuthMethodsService,
) {
}
@@ -128,11 +136,14 @@ export class ExternalLogInComponent implements OnInit, OnDestroy {
parent: this.injector,
});
this.registrationType = this.registrationData?.registrationType ?? null;
this.relatedAuthMethod = isEmpty(this.registrationType) ? null : this.registrationType.replace('VALIDATION_', '').toLocaleLowerCase() as AuthMethodType;
this.relatedAuthMethod = isEmpty(this.registrationType) ? null :
this.registrationType.replace('VALIDATION_', '').toLocaleLowerCase() as AuthMethodType;
this.informationText = hasValue(this.registrationData?.email)
? this.generateInformationTextWhenEmail(this.registrationType)
: this.generateInformationTextWhenNOEmail(this.registrationType);
this.hasAuthMethodTypes = this.authService.getAuthMethods(this.relatedAuthMethod).pipe(map(methods => methods.length > 0));
this.hasAuthMethodTypes =
this.authMethodsService.getAuthMethods(this.authMethods, this.relatedAuthMethod)
.pipe(map(methods => methods.length > 0));
}
/**

View File

@@ -23,7 +23,7 @@ import {
} from '../../shared/remote-data.utils';
import { registrationTokenGuard } from './registration-token-guard';
fdescribe('RegistrationTokenGuard',
describe('RegistrationTokenGuard',
() => {
const route = new RouterMock();
const registrationWithGroups = Object.assign(new Registration(),

View File

@@ -1,6 +1,10 @@
<div class="container">
@if (registrationData$ | async; as registrationData) {
<ds-external-log-in [registrationData]="registrationData" [token]="token"></ds-external-log-in>
<ds-external-log-in
[authMethods]="authMethods"
[registrationData]="registrationData"
[token]="token"
></ds-external-log-in>
}
@if (hasErrors) {

View File

@@ -12,12 +12,15 @@ import {
tap,
} from 'rxjs';
import { AuthMethodType } from '../core/auth/models/auth.method-type';
import { RemoteData } from '../core/data/remote-data';
import { Registration } from '../core/shared/registration.model';
import { ExternalLogInComponent } from '../external-log-in/external-log-in/external-log-in.component';
import { AlertComponent } from '../shared/alert/alert.component';
import { AlertType } from '../shared/alert/alert-type';
import { hasNoValue } from '../shared/empty.util';
import { AuthMethodTypeComponent } from '../shared/log-in/methods/auth-methods.type';
import { AUTH_METHOD_FOR_DECORATOR_MAP } from '../shared/log-in/methods/log-in.methods-decorator';
@Component({
templateUrl: './external-login-page.component.html',
@@ -53,11 +56,14 @@ export class ExternalLoginPageComponent implements OnInit {
*/
public hasErrors = false;
public authMethods: Map<AuthMethodType, AuthMethodTypeComponent>;
constructor(
private arouter: ActivatedRoute,
) {
this.token = this.arouter.snapshot.params.token;
this.hasErrors = hasNoValue(this.arouter.snapshot.params.token);
this.authMethods = AUTH_METHOD_FOR_DECORATOR_MAP;
}
ngOnInit(): void {

View File

@@ -7,10 +7,9 @@ import {
} from '@angular/core';
import { AuthMethod } from '../../../core/auth/models/auth.method';
import {
AuthMethodTypeComponent,
rendersAuthMethodType,
} from '../methods/log-in.methods-decorator';
import { AuthMethodTypeComponent } from '../methods/auth-methods.type';
import { AUTH_METHOD_FOR_DECORATOR_MAP } from '../methods/log-in.methods-decorator';
import { rendersAuthMethodType } from '../methods/log-in.methods-decorator.utils';
/**
* This component represents a component container for log-in methods available.
@@ -60,7 +59,7 @@ export class LogInContainerComponent implements OnInit {
* Find the correct component based on the AuthMethod's type
*/
getAuthMethodContent(): AuthMethodTypeComponent {
return rendersAuthMethodType(this.authMethod.authMethodType);
return rendersAuthMethodType(AUTH_METHOD_FOR_DECORATOR_MAP, this.authMethod.authMethodType);
}
}

View File

@@ -12,6 +12,7 @@ import {
import { Observable } from 'rxjs';
import { AuthService } from '../../core/auth/auth.service';
import { AuthMethodsService } from '../../core/auth/auth-methods.service';
import { AuthMethod } from '../../core/auth/models/auth.method';
import { AuthMethodType } from '../../core/auth/models/auth.method-type';
import {
@@ -23,6 +24,7 @@ import { CoreState } from '../../core/core-state.model';
import { hasValue } from '../empty.util';
import { ThemedLoadingComponent } from '../loading/themed-loading.component';
import { LogInContainerComponent } from './container/log-in-container.component';
import { AUTH_METHOD_FOR_DECORATOR_MAP } from './methods/log-in.methods-decorator';
@Component({
selector: 'ds-base-log-in',
@@ -69,11 +71,12 @@ export class LogInComponent implements OnInit {
constructor(private store: Store<CoreState>,
private authService: AuthService,
private authMethodsService: AuthMethodsService,
) {
}
ngOnInit(): void {
this.authMethods = this.authService.getAuthMethods(this.excludedAuthMethod);
this.authMethods = this.authMethodsService.getAuthMethods(AUTH_METHOD_FOR_DECORATOR_MAP, this.excludedAuthMethod);
// set loading
this.loading = this.store.pipe(select(isAuthenticationLoading));

View File

@@ -0,0 +1,6 @@
import { LogInExternalProviderComponent } from './log-in-external-provider/log-in-external-provider.component';
import { LogInPasswordComponent } from './password/log-in-password.component';
export type AuthMethodTypeComponent =
typeof LogInPasswordComponent |
typeof LogInExternalProviderComponent;

View File

@@ -1,11 +1,8 @@
import { AuthMethodType } from '../../../core/auth/models/auth.method-type';
import { AuthMethodTypeComponent } from './auth-methods.type';
import { LogInExternalProviderComponent } from './log-in-external-provider/log-in-external-provider.component';
import { LogInPasswordComponent } from './password/log-in-password.component';
export type AuthMethodTypeComponent =
typeof LogInPasswordComponent |
typeof LogInExternalProviderComponent;
export const AUTH_METHOD_FOR_DECORATOR_MAP = new Map<AuthMethodType, AuthMethodTypeComponent>([
[AuthMethodType.Password, LogInPasswordComponent],
[AuthMethodType.Shibboleth, LogInExternalProviderComponent],
@@ -25,7 +22,3 @@ export function renderAuthMethodFor(authMethodType: AuthMethodType) {
AUTH_METHOD_FOR_DECORATOR_MAP.set(authMethodType, objectElement);
};
}
export function rendersAuthMethodType(authMethodType: AuthMethodType) {
return AUTH_METHOD_FOR_DECORATOR_MAP.get(authMethodType);
}

View File

@@ -0,0 +1,15 @@
import { AuthMethodType } from '../../../core/auth/models/auth.method-type';
import { AuthMethodTypeComponent } from './auth-methods.type';
/**
* Retrieves the authentication method component for a specific authentication method type.
* @param authMethods A map of authentication method types to their corresponding components
* @param authMethodType The specific authentication method type to retrieve
* @returns The component associated with the given authentication method type, or undefined if not found
*/
export function rendersAuthMethodType(
authMethods: Map<AuthMethodType, AuthMethodTypeComponent>,
authMethodType: AuthMethodType,
) {
return authMethods.get(authMethodType);
}