diff --git a/alfa-client/apps/admin/src/app/app.component.spec.ts b/alfa-client/apps/admin/src/app/app.component.spec.ts index 54c0b68df01382aeda9158d6b8b348db958f1360..86fda2c791f984af8fbb569cb101d7d7a5911151 100644 --- a/alfa-client/apps/admin/src/app/app.component.spec.ts +++ b/alfa-client/apps/admin/src/app/app.component.spec.ts @@ -21,10 +21,11 @@ * Die sprachspezifischen Genehmigungen und Beschränkungen * unter der Lizenz sind dem Lizenztext zu entnehmen. */ +import { ConfigurationLinkRel, ConfigurationResource, ConfigurationService } from '@admin-client/configuration-shared'; import { ApiRootLinkRel, ApiRootResource, ApiRootService } from '@alfa-client/api-root-shared'; import { BuildInfoComponent } from '@alfa-client/common'; import { HasLinkPipe, createEmptyStateResource, createStateResource } from '@alfa-client/tech-shared'; -import { Mock, dispatchEventFromFixture, existsAsHtmlElement, mock, notExistsAsHtmlElement } from '@alfa-client/test-utils'; +import { Mock, existsAsHtmlElement, mock, notExistsAsHtmlElement } from '@alfa-client/test-utils'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router, RouterOutlet } from '@angular/router'; import { AuthenticationService } from '@authentication'; @@ -36,11 +37,12 @@ import { OrgaUnitIconComponent, UsersIconComponent, } from '@ods/system'; +import { createConfigurationResource } from 'libs/admin/configuration-shared/test/configuration'; import { MenuContainerComponent } from 'libs/admin/configuration/src/lib/menu-container/menu-container.component'; import { createApiRootResource } from 'libs/api-root-shared/test/api-root'; import { getDataTestIdOf } from 'libs/tech-shared/test/data-test'; import { MockComponent, MockDirective } from 'ng-mocks'; -import { of } from 'rxjs'; +import { Subscription, of } from 'rxjs'; import { UserProfileButtonContainerComponent } from '../common/user-profile-button-container/user-profile.button-container.component'; import { UnavailablePageComponent } from '../pages/unavailable/unavailable-page/unavailable-page.component'; import { AppComponent } from './app.component'; @@ -55,7 +57,6 @@ describe('AppComponent', () => { const usersRolesNavigationSelector: string = getDataTestIdOf('users-roles-navigation'); const organisationsEinheitenNavigationSelector: string = getDataTestIdOf('organisations-einheiten-navigation'); - const logoLink: string = getDataTestIdOf('logo-link'); const routerOutletSelector: string = getDataTestIdOf('router-outlet'); const menuContainer: string = getDataTestIdOf('menu-container'); @@ -78,8 +79,11 @@ describe('AppComponent', () => { }; const apiRootService: Mock<ApiRootService> = mock(ApiRootService); + let configurationService: Mock<ConfigurationService>; beforeEach(async () => { + configurationService = mock(ConfigurationService); + await TestBed.configureTestingModule({ imports: [HasLinkPipe], declarations: [ @@ -105,6 +109,10 @@ describe('AppComponent', () => { provide: ApiRootService, useValue: apiRootService, }, + { + provide: ConfigurationService, + useValue: configurationService, + }, { provide: Router, useValue: router, @@ -147,26 +155,195 @@ describe('AppComponent', () => { }); describe('do after logged in', () => { + beforeEach(() => { + component.evaluateInitialNavigation = jest.fn(); + }); + it('should call apiRootService to getApiRoot', () => { component.doAfterLoggedIn(); expect(apiRootService.getApiRoot).toHaveBeenCalled(); }); - it('should call forwardWithoutAuthenticationParams', () => { - component.forwardWithoutAuthenticationParams = jest.fn(); + it('should call evaluateInitialNavigation', () => { + component.evaluateInitialNavigation = jest.fn(); component.doAfterLoggedIn(); - expect(component.forwardWithoutAuthenticationParams).toHaveBeenCalled(); + expect(component.evaluateInitialNavigation).toHaveBeenCalled(); + }); + }); + + describe('evaluate initial navigation', () => { + beforeEach(() => { + component.evaluateNavigationByApiRoot = jest.fn(); + }); + + it('should call evaluate navigation by apiRoot', () => { + const apiRootResource: ApiRootResource = createApiRootResource(); + component.apiRootStateResource$ = of(createStateResource(apiRootResource)); + + component.evaluateInitialNavigation(); + + expect(component.evaluateNavigationByApiRoot).toHaveBeenCalledWith(apiRootResource); + }); + + it('should NOT call evaluate navigation by apiRoot if resource is loading', () => { + component.apiRootStateResource$ = of(createEmptyStateResource<ApiRootResource>(true)); + component.evaluateNavigationByApiRoot = jest.fn(); + + component.evaluateInitialNavigation(); + + expect(component.evaluateNavigationByApiRoot).not.toHaveBeenCalled(); + }); + }); + + describe('evaluate navigation api root', () => { + it('should evaluate navigation by configuration if link exists', () => { + component.evaluateNavigationByConfiguration = jest.fn(); + const apiRootResource: ApiRootResource = createApiRootResource([ApiRootLinkRel.CONFIGURATION]); + + component.evaluateNavigationByApiRoot(apiRootResource); + + expect(component.evaluateNavigationByConfiguration).toHaveBeenCalled(); + }); + + it('should navigate by api root if link is missing', () => { + component.navigateByApiRoot = jest.fn(); + const apiRootResource: ApiRootResource = createApiRootResource(); + + component.evaluateNavigationByApiRoot(apiRootResource); + + expect(component.navigateByApiRoot).toHaveBeenCalledWith(apiRootResource); + }); + }); + + describe('evaluate navigation by configuration', () => { + const configurationResource: ConfigurationResource = createConfigurationResource(); + + beforeEach(() => { + configurationService.get.mockReturnValue(of(createStateResource(configurationResource))); + component.navigateByConfiguration = jest.fn(); + }); + + it('should call configuration service to get resource', () => { + component.evaluateNavigationByConfiguration(); + + expect(configurationService.get).toHaveBeenCalled(); + }); + + it('should call navigate by configuration', () => { + component.evaluateNavigationByConfiguration(); + + expect(component.navigateByConfiguration).toHaveBeenCalledWith(configurationResource); + }); + + it('should NOT call navigate by configuration if resource is loading', () => { + configurationService.get.mockReturnValue(of(createEmptyStateResource<ConfigurationResource>(true))); + + component.evaluateNavigationByConfiguration(); + + expect(component.navigateByConfiguration).not.toHaveBeenCalled(); + }); + }); + + describe('navigate by configuration', () => { + beforeEach(() => { + component.unsubscribe = jest.fn(); + }); + + it('should navigate to postfach if settings link exists', () => { + component.navigateByConfiguration(createConfigurationResource([ConfigurationLinkRel.SETTING])); + + expect(router.navigate).toHaveBeenCalledWith(['/postfach']); + }); + + it('should navigate to statistik if aggregation mapping link exists', () => { + component.navigateByConfiguration(createConfigurationResource([ConfigurationLinkRel.AGGREGATION_MAPPINGS])); + + expect(router.navigate).toHaveBeenCalledWith(['/statistik']); + }); + + it('should navigate to unavailable page if no link exists', () => { + component.navigateByConfiguration(createConfigurationResource()); + + expect(router.navigate).toHaveBeenCalledWith(['/unavailable']); + }); + + it('should call unsubscribe', () => { + component.navigateByConfiguration(createConfigurationResource()); + + expect(component.unsubscribe).toHaveBeenCalled(); + }); + }); + + describe('navigate by api root', () => { + beforeEach(() => { + component.unsubscribe = jest.fn(); + }); + + it('should navigate to benutzer und rollen if users link exists', () => { + const apiRootResource: ApiRootResource = createApiRootResource([ApiRootLinkRel.USERS]); + + component.navigateByApiRoot(apiRootResource); + + expect(router.navigate).toHaveBeenCalledWith(['/benutzer_und_rollen']); + }); + + it('should navigate to unavailable page if no link exists', () => { + component.navigateByApiRoot(createApiRootResource()); + + expect(router.navigate).toHaveBeenCalledWith(['/unavailable']); + }); + + it('should call unsubscribe', () => { + component.navigateByApiRoot(createApiRootResource()); + + expect(component.unsubscribe).toHaveBeenCalled(); }); }); - describe('forward without authentication params', () => { - it('should navigate to same route without authentication params', () => { - component.forwardWithoutAuthenticationParams(); + describe('unsubscribe', () => { + describe('apiRoot subscription', () => { + it('should unsubscribe if exists', () => { + component.apiRootSubscription = <any>mock(Subscription); + component.apiRootSubscription.unsubscribe = jest.fn(); - expect(router.navigate).toHaveBeenCalledWith([], { queryParams: {} }); + component.unsubscribe(); + + expect(component.apiRootSubscription.unsubscribe).toHaveBeenCalled(); + }); + + it('should do nothing if not exists', () => { + component.apiRootSubscription = undefined; + const unsubscribeSpy: jest.SpyInstance = jest.spyOn(Subscription.prototype, 'unsubscribe'); + + component.unsubscribe(); + + expect(unsubscribeSpy).not.toHaveBeenCalled(); + unsubscribeSpy.mockRestore(); + }); + }); + + describe('configuration subscription', () => { + it('should unsubscribe if exists', () => { + component.configurationSubscription = <any>mock(Subscription); + component.configurationSubscription.unsubscribe = jest.fn(); + + component.unsubscribe(); + + expect(component.configurationSubscription.unsubscribe).toHaveBeenCalled(); + }); + + it('should do nothing if not exists', () => { + component.apiRootSubscription = undefined; + const unsubscribeSpy: jest.SpyInstance = jest.spyOn(Subscription.prototype, 'unsubscribe'); + + component.unsubscribe(); + + expect(unsubscribeSpy).not.toHaveBeenCalled(); + unsubscribeSpy.mockRestore(); + }); }); }); }); @@ -190,21 +367,6 @@ describe('AppComponent', () => { }); }); - describe('administration logo', () => { - const apiResource: ApiRootResource = createApiRootResource(); - - beforeEach(() => { - component.apiRootStateResource$ = of(createStateResource(apiResource)); - fixture.detectChanges(); - }); - - it('should navigate to start page on click', () => { - dispatchEventFromFixture(fixture, logoLink, 'click'); - - expect(router.navigate).toHaveBeenCalledWith([], { queryParams: {} }); - }); - }); - describe('navigation', () => { describe('user and roles', () => { it('should show if users link is present', () => { diff --git a/alfa-client/apps/admin/src/app/app.component.ts b/alfa-client/apps/admin/src/app/app.component.ts index 77e7d9b5bbf53858ac3024ac65c3a40d82c8c46f..c9d3c4f536cf6cee9f74f43f206bf02bdc01f397 100644 --- a/alfa-client/apps/admin/src/app/app.component.ts +++ b/alfa-client/apps/admin/src/app/app.component.ts @@ -22,13 +22,16 @@ * unter der Lizenz sind dem Lizenztext zu entnehmen. */ import { MenuContainerComponent } from '@admin-client/configuration'; +import { ConfigurationLinkRel, ConfigurationResource, ConfigurationService } from '@admin-client/configuration-shared'; +import { ROUTES } from '@admin-client/shared'; import { ApiRootLinkRel, ApiRootResource, ApiRootService } from '@alfa-client/api-root-shared'; import { BuildInfoComponent } from '@alfa-client/common'; -import { StateResource, TechSharedModule } from '@alfa-client/tech-shared'; +import { isLoaded, isNotUndefined, mapToResource, StateResource, TechSharedModule } from '@alfa-client/tech-shared'; import { CommonModule } from '@angular/common'; -import { Component, OnInit } from '@angular/core'; -import { ActivatedRoute, Params, Router, RouterLink, RouterOutlet } from '@angular/router'; +import { Component, inject, OnInit } from '@angular/core'; +import { Router, RouterLink, RouterOutlet } from '@angular/router'; import { AuthenticationService } from '@authentication'; +import { hasLink } from '@ngxp/rest'; import { AdminLogoIconComponent, NavbarComponent, @@ -36,7 +39,7 @@ import { OrgaUnitIconComponent, UsersIconComponent, } from '@ods/system'; -import { Observable } from 'rxjs'; +import { filter, Observable, Subscription } from 'rxjs'; import { UserProfileButtonContainerComponent } from '../common/user-profile-button-container/user-profile.button-container.component'; import { UnavailablePageComponent } from '../pages/unavailable/unavailable-page/unavailable-page.component'; @@ -64,16 +67,17 @@ import { UnavailablePageComponent } from '../pages/unavailable/unavailable-page/ export class AppComponent implements OnInit { readonly title: string = 'admin'; + private readonly authenticationService = inject(AuthenticationService); + private readonly apiRootService = inject(ApiRootService); + private readonly router = inject(Router); + private readonly configurationService = inject(ConfigurationService); + public apiRootStateResource$: Observable<StateResource<ApiRootResource>>; - public readonly apiRootLinkRel = ApiRootLinkRel; + apiRootSubscription: Subscription; + configurationSubscription: Subscription; - constructor( - public authenticationService: AuthenticationService, - private apiRootService: ApiRootService, - private router: Router, - private route: ActivatedRoute, - ) {} + public readonly apiRootLinkRel = ApiRootLinkRel; ngOnInit(): void { this.authenticationService.login().then(() => this.doAfterLoggedIn()); @@ -81,16 +85,56 @@ export class AppComponent implements OnInit { doAfterLoggedIn(): void { this.apiRootStateResource$ = this.apiRootService.getApiRoot(); - this.forwardWithoutAuthenticationParams(); + this.evaluateInitialNavigation(); + } + + evaluateInitialNavigation(): void { + this.apiRootSubscription = this.apiRootStateResource$ + .pipe(filter(isLoaded), mapToResource<ApiRootResource>()) + .subscribe((apiRootResource: ApiRootResource) => this.evaluateNavigationByApiRoot(apiRootResource)); + } + + evaluateNavigationByApiRoot(apiRootResource: ApiRootResource): void { + if (hasLink(apiRootResource, ApiRootLinkRel.CONFIGURATION)) { + this.evaluateNavigationByConfiguration(); + } else { + this.navigateByApiRoot(apiRootResource); + } + } + + evaluateNavigationByConfiguration(): void { + this.configurationSubscription = this.configurationService + .get() + .pipe(filter(isLoaded), mapToResource<ApiRootResource>()) + .subscribe((configurationResource: ConfigurationResource) => this.navigateByConfiguration(configurationResource)); + } + + navigateByConfiguration(configurationResource: ConfigurationResource): void { + if (hasLink(configurationResource, ConfigurationLinkRel.SETTING)) { + this.navigate(ROUTES.POSTFACH); + } else if (hasLink(configurationResource, ConfigurationLinkRel.AGGREGATION_MAPPINGS)) { + this.navigate(ROUTES.STATISTIK); + } else { + this.navigate(ROUTES.UNAVAILABLE); + } + this.unsubscribe(); + } + + navigateByApiRoot(apiRootResource: ApiRootResource): void { + if (hasLink(apiRootResource, ApiRootLinkRel.USERS)) { + this.navigate(ROUTES.BENUTZER_UND_ROLLEN); + } else { + this.navigate(ROUTES.UNAVAILABLE); + } + this.unsubscribe(); } - forwardWithoutAuthenticationParams(): void { - const queryParams: Params = this.getQueryParamsWithoutAuthentication(); - this.router.navigate([], { queryParams }); + private navigate(routePath: string): void { + this.router.navigate(['/' + routePath]); } - private getQueryParamsWithoutAuthentication(): Params { - const { iss, state, session_state, code, ...queryParams } = this.route.snapshot.queryParams; - return queryParams; + unsubscribe(): void { + if (isNotUndefined(this.apiRootSubscription)) this.apiRootSubscription.unsubscribe(); + if (isNotUndefined(this.configurationSubscription)) this.configurationSubscription.unsubscribe(); } } diff --git a/alfa-client/apps/admin/src/app/app.routes.ts b/alfa-client/apps/admin/src/app/app.routes.ts index a6321aee888738c2237430bb27804eb17e4713c6..a29db70a419d27e3aac9637e37d9230d1013a6e6 100644 --- a/alfa-client/apps/admin/src/app/app.routes.ts +++ b/alfa-client/apps/admin/src/app/app.routes.ts @@ -40,11 +40,6 @@ export interface GuardData { } export const appRoutes: Route[] = [ - { - path: '', - redirectTo: ROUTES.POSTFACH, - pathMatch: 'full', - }, { path: ROUTES.POSTFACH, component: PostfachPageComponent, diff --git a/alfa-client/libs/authentication/src/lib/authentication.service.spec.ts b/alfa-client/libs/authentication/src/lib/authentication.service.spec.ts index bd176bb9141d31dce01662b6431a1222be6a2506..486415f425de82475e9733ec3f5800cb47128c92 100644 --- a/alfa-client/libs/authentication/src/lib/authentication.service.spec.ts +++ b/alfa-client/libs/authentication/src/lib/authentication.service.spec.ts @@ -23,30 +23,39 @@ */ import { Mock, mock, useFromMock } from '@alfa-client/test-utils'; import { UserProfileResource } from '@alfa-client/user-profile-shared'; +import { AuthConfig, OAuthEvent, OAuthService } from 'angular-oauth2-oidc'; +import { Environment } from 'libs/environment-shared/src/lib/environment.model'; import { createUserProfileResource } from 'libs/user-profile-shared/test/user-profile'; -import { AuthConfig, OAuthService } from 'angular-oauth2-oidc'; -import { AuthenticationService } from './authentication.service'; -import { createAuthConfig } from '../../test/authentication'; +import { Subject } from 'rxjs'; import { createEnvironment } from '../../../environment-shared/test/environment'; -import { Environment } from 'libs/environment-shared/src/lib/environment.model'; +import { createAuthConfig, createOAuthEvent } from '../../test/authentication'; +import { AuthenticationService } from './authentication.service'; describe('AuthenticationService', () => { let service: AuthenticationService; let oAuthService: Mock<OAuthService>; let environmentConfig: Environment; + let eventsSubject: Subject<OAuthEvent>; + beforeEach(() => { + eventsSubject = new Subject<OAuthEvent>(); + oAuthService = <any>{ ...mock(OAuthService), loadDiscoveryDocumentAndLogin: jest.fn().mockResolvedValue(() => Promise.resolve()), + hasValidAccessToken: jest.fn(), + hasValidIdToken: jest.fn(), }; + Object.defineProperty(oAuthService, 'events', { get: () => eventsSubject }); + environmentConfig = createEnvironment(); service = new AuthenticationService(useFromMock(oAuthService), environmentConfig); }); describe('login', () => { beforeEach(() => { - service.setCurrentUser = jest.fn(); + service.buildAuthEventPromise = jest.fn(); }); it('should configure service with authConfig', async () => { @@ -72,16 +81,227 @@ describe('AuthenticationService', () => { expect(oAuthService.tokenValidationHandler).not.toBeNull(); }); + it('should build auth event promise', async () => { + service.buildAuthEventPromise = jest.fn().mockResolvedValue(() => Promise.resolve()); + + await service.login(); + + expect(service.buildAuthEventPromise).toHaveBeenCalled(); + }); + it('should load discovery document and login', () => { service.login(); expect(oAuthService.loadDiscoveryDocumentAndLogin).toHaveBeenCalled(); }); - it('should set current user', async () => { - await service.login(); + it('should return eventPromise', async () => { + const promise: Promise<void> = Promise.resolve(); + service.buildAuthEventPromise = jest.fn().mockResolvedValue(promise); + + const returnPromise: Promise<void> = service.login(); + + await expect(returnPromise).resolves.toBeUndefined(); + }); + }); + + describe('build auth event promise', () => { + const event: OAuthEvent = createOAuthEvent(); + + beforeEach(() => { + service.shouldProceedByAuthEvent = jest.fn().mockReturnValue(true); + service.setCurrentUser = jest.fn(); + service.unsubscribeEvents = jest.fn(); + }); + + it('should call shouldProceedByAuthEvent on event trigger', () => { + service.buildAuthEventPromise(); + eventsSubject.next(event); + + expect(service.shouldProceedByAuthEvent).toHaveBeenCalledWith(event); + }); + + describe('on next', () => { + it('should set current user', () => { + service.buildAuthEventPromise(); + eventsSubject.next(event); + + expect(service.setCurrentUser).toHaveBeenCalled(); + }); + + it('should unsubscribe event', () => { + service.buildAuthEventPromise(); + eventsSubject.next(event); + + expect(service.unsubscribeEvents).toHaveBeenCalled(); + }); + + it('should resolved promise with a valid event', async () => { + const promise: Promise<void> = service.buildAuthEventPromise(); + eventsSubject.next(event); + + await expect(promise).resolves.toBeUndefined(); + }); + }); + + describe('on error', () => { + const errorMessage: string = 'Test Error'; + const error: Error = new Error(errorMessage); + + it('should unsubscribe event', () => { + service.buildAuthEventPromise(); + eventsSubject.error(error); + + expect(service.unsubscribeEvents).toHaveBeenCalled(); + }); + + it('should reject the promise with an error', async () => { + const promise: Promise<void> = service.buildAuthEventPromise(); + + eventsSubject.error(error); + + await expect(promise).rejects.toThrow(errorMessage); + }); + }); + }); + + describe('should proceed by auth event', () => { + const event: OAuthEvent = createOAuthEvent(); + + it('should call considered as login event', () => { + service.consideredAsLoginEvent = jest.fn(); + + service.shouldProceedByAuthEvent(event); + + expect(service.consideredAsLoginEvent).toHaveBeenCalledWith(event.type); + }); + + it('should return true on login event', () => { + service.consideredAsLoginEvent = jest.fn().mockReturnValue(true); + + const proceed: boolean = service.shouldProceedByAuthEvent(event); + + expect(proceed).toBeTruthy(); + }); + + it('should call considered as page reload event', () => { + service.consideredAsLoginEvent = jest.fn().mockReturnValue(false); + service.consideredAsPageReloadEvent = jest.fn(); + + service.shouldProceedByAuthEvent(event); + + expect(service.consideredAsPageReloadEvent).toHaveBeenCalledWith(event.type); + }); + + it('should return true on page reload event', () => { + service.consideredAsLoginEvent = jest.fn().mockReturnValue(false); + service.consideredAsPageReloadEvent = jest.fn().mockReturnValue(true); + + const proceed: boolean = service.shouldProceedByAuthEvent(event); + + expect(proceed).toBeTruthy(); + }); + + it('should return false on non login or page reload event', () => { + service.consideredAsLoginEvent = jest.fn().mockReturnValue(false); + service.consideredAsPageReloadEvent = jest.fn().mockReturnValue(false); + + const proceed: boolean = service.shouldProceedByAuthEvent(event); + + expect(proceed).toBeFalsy(); + }); + }); + + describe('consideredAsLoginEvent', () => { + it('should return true if event is "token_received"', () => { + const event: string = 'token_received'; + + const result: boolean = service.consideredAsLoginEvent(event); + + expect(result).toBeTruthy(); + }); + + it('should return false if event is not "token_received"', () => { + const event: string = 'something_else'; + + const result: boolean = service.consideredAsLoginEvent(event); + + expect(result).toBeFalsy(); + }); + }); + + describe('consideredAsPageReloadEvent', () => { + it('should return true if event is "discovery_document_loaded" and tokens are valid', () => { + service.hasValidToken = jest.fn().mockReturnValue(true); + const event: string = 'discovery_document_loaded'; + + const result: boolean = service.consideredAsPageReloadEvent(event); + + expect(result).toBeTruthy(); + }); + + it('should return false if event is "discovery_document_loaded" and tokens are invalid', () => { + service.hasValidToken = jest.fn().mockReturnValue(false); + const event: string = 'discovery_document_loaded'; + + const result: boolean = service.consideredAsPageReloadEvent(event); + + expect(result).toBeFalsy(); + }); + + it('should return false if event is not "discovery_document_loaded" and tokens are valid', () => { + service.hasValidToken = jest.fn().mockReturnValue(true); + const event: string = 'something_else'; + + const result: boolean = service.consideredAsPageReloadEvent(event); + + expect(result).toBeFalsy(); + }); + + it('should return false if event is not "discovery_document_loaded" and tokens are invalid', () => { + service.hasValidToken = jest.fn().mockReturnValue(false); + const event: string = 'something_else'; + + const result: boolean = service.consideredAsPageReloadEvent(event); + + expect(result).toBeFalsy(); + }); + }); + + describe('hasValidToken', () => { + it('should return true if both tokens are valid', () => { + oAuthService.hasValidAccessToken.mockReturnValue(true); + oAuthService.hasValidIdToken.mockReturnValue(true); + + const result: boolean = service.hasValidToken(); + + expect(result).toBeTruthy(); + }); + + it('should return false if access token is invalid', () => { + oAuthService.hasValidAccessToken.mockReturnValue(false); + + const result: boolean = service.hasValidToken(); + + expect(result).toBeFalsy(); + }); + + it('should return false if id token is invalid', () => { + oAuthService.hasValidAccessToken.mockReturnValue(true); + oAuthService.hasValidIdToken.mockReturnValue(false); + + const result: boolean = service.hasValidToken(); + + expect(result).toBeFalsy(); + }); + + it('should return false if both tokens are invalid', () => { + oAuthService.hasValidAccessToken.mockReturnValue(false); + oAuthService.hasValidIdToken.mockReturnValue(false); + + const result: boolean = service.hasValidToken(); - expect(service.setCurrentUser).toHaveBeenCalled(); + expect(result).toBeFalsy(); }); }); diff --git a/alfa-client/libs/authentication/src/lib/authentication.service.ts b/alfa-client/libs/authentication/src/lib/authentication.service.ts index 9b094a2ba2b953f715c8389735f6f40e21196ffd..e350dcafe7cb9312799db97db5eb50c0d25c8319 100644 --- a/alfa-client/libs/authentication/src/lib/authentication.service.ts +++ b/alfa-client/libs/authentication/src/lib/authentication.service.ts @@ -23,15 +23,18 @@ */ import { Environment, ENVIRONMENT_CONFIG } from '@alfa-client/environment-shared'; import { Inject, Injectable } from '@angular/core'; -import { AuthConfig, OAuthService } from 'angular-oauth2-oidc'; +import { AuthConfig, OAuthEvent, OAuthService } from 'angular-oauth2-oidc'; import { JwksValidationHandler } from 'angular-oauth2-oidc-jwks'; import { UserProfileResource } from 'libs/user-profile-shared/src/lib/user-profile.model'; import { getUserNameInitials } from 'libs/user-profile-shared/src/lib/user-profile.util'; +import { filter, Subscription } from 'rxjs'; @Injectable({ providedIn: 'root' }) export class AuthenticationService { currentUserResource: UserProfileResource; + private eventSubscription: Subscription; + constructor( private oAuthService: OAuthService, @Inject(ENVIRONMENT_CONFIG) private envConfig: Environment, @@ -41,18 +44,52 @@ export class AuthenticationService { this.oAuthService.configure(this.buildConfiguration()); this.oAuthService.setupAutomaticSilentRefresh(); this.oAuthService.tokenValidationHandler = new JwksValidationHandler(); + + const eventPromise: Promise<void> = this.buildAuthEventPromise(); await this.oAuthService.loadDiscoveryDocumentAndLogin(); - this.setCurrentUser(); + return eventPromise; + } + + buildAuthEventPromise(): Promise<void> { + return new Promise<void>((resolve, reject) => this.handleAuthEventsForPromise(resolve, reject)); + } + + private handleAuthEventsForPromise(resolve: (value: void | PromiseLike<void>) => void, reject: (reason?: any) => void): void { + this.eventSubscription = this.oAuthService.events + .pipe(filter((event: OAuthEvent) => this.shouldProceedByAuthEvent(event))) + .subscribe({ + next: () => { + this.setCurrentUser(); + this.unsubscribeEvents(); + resolve(); + }, + error: (error: any) => { + this.unsubscribeEvents(); + reject(error); + }, + }); + } + + shouldProceedByAuthEvent(event: OAuthEvent): boolean { + return this.consideredAsLoginEvent(event.type) || this.consideredAsPageReloadEvent(event.type); + } + + consideredAsLoginEvent(eventType: string): boolean { + return eventType === 'token_received'; + } + + consideredAsPageReloadEvent(eventType: string): boolean { + return eventType === 'discovery_document_loaded' && this.hasValidToken(); + } + + hasValidToken(): boolean { + return this.oAuthService.hasValidAccessToken() && this.oAuthService.hasValidIdToken(); } buildConfiguration(): AuthConfig { return { issuer: this.envConfig.authServer + '/realms/' + this.envConfig.realm, - tokenEndpoint: - this.envConfig.authServer + - '/realms/' + - this.envConfig.realm + - '/protocol/openid-connect/token', + tokenEndpoint: this.envConfig.authServer + '/realms/' + this.envConfig.realm + '/protocol/openid-connect/token', redirectUri: window.location.origin + '/', clientId: this.envConfig.clientId, scope: 'openid profile', @@ -71,6 +108,10 @@ export class AuthenticationService { this.currentUserResource = userResource; } + unsubscribeEvents(): void { + this.eventSubscription.unsubscribe(); + } + public getCurrentUserInitials(): string { return getUserNameInitials(this.currentUserResource); } diff --git a/alfa-client/libs/authentication/test/authentication.ts b/alfa-client/libs/authentication/test/authentication.ts index 34223905913df6f0a941a02f0b8a63723a959b50..3cf143c030717a12d98d8d6c75e0188e586b34b7 100644 --- a/alfa-client/libs/authentication/test/authentication.ts +++ b/alfa-client/libs/authentication/test/authentication.ts @@ -21,8 +21,13 @@ * Die sprachspezifischen Genehmigungen und Beschränkungen * unter der Lizenz sind dem Lizenztext zu entnehmen. */ -import { AuthConfig } from 'angular-oauth2-oidc'; +import { faker } from '@faker-js/faker'; +import { AuthConfig, OAuthEvent } from 'angular-oauth2-oidc'; export function createAuthConfig(): AuthConfig { return {}; } + +export function createOAuthEvent(): OAuthEvent { + return { type: <any>faker.lorem.word() }; +} diff --git a/alfa-client/libs/design-system/src/lib/dropdown-menu/dropdown-menu-button-item/dropdown-menu-button-item.component.ts b/alfa-client/libs/design-system/src/lib/dropdown-menu/dropdown-menu-button-item/dropdown-menu-button-item.component.ts index 842ff45f4505e999956906e19075ad03fc0edcbc..2c1b5427927141d6b7ca40515ee64432d9d50e2e 100644 --- a/alfa-client/libs/design-system/src/lib/dropdown-menu/dropdown-menu-button-item/dropdown-menu-button-item.component.ts +++ b/alfa-client/libs/design-system/src/lib/dropdown-menu/dropdown-menu-button-item/dropdown-menu-button-item.component.ts @@ -32,7 +32,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; class="flex min-h-12 w-full items-center gap-4 border-2 border-transparent px-4 py-3 text-start outline-none hover:border-primary focus-visible:border-focus" role="menuitem" (click)="itemClicked.emit()" - [attr.data-test-id]="buttonTestId" + [attr.data-test-id]="dataTestId" > <ng-content select="[icon]" /> <p class="text-text">{{ caption }}</p> @@ -40,7 +40,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; }) export class DropdownMenuButtonItemComponent { @Input({ required: true }) caption!: string; - @Input() buttonTestId: string; + @Input() dataTestId: string; @Output() itemClicked: EventEmitter<MouseEvent> = new EventEmitter(); } diff --git a/alfa-client/libs/user-profile/src/lib/user-profile-in-header-container/user-profile-in-header/user-profile-in-header.component.html b/alfa-client/libs/user-profile/src/lib/user-profile-in-header-container/user-profile-in-header/user-profile-in-header.component.html index c73890ac91f82dd5182190f913829129deb67bad..a2aac078aaf9d5fc6783864da5b363cf460d9bee 100644 --- a/alfa-client/libs/user-profile/src/lib/user-profile-in-header-container/user-profile-in-header/user-profile-in-header.component.html +++ b/alfa-client/libs/user-profile/src/lib/user-profile-in-header-container/user-profile-in-header/user-profile-in-header.component.html @@ -36,7 +36,7 @@ class="user-profile-icon" > </alfa-user-icon> - <ods-dropdown-menu-button-item caption="Abmelden" (itemClicked)="logoutEmitter.emit()" buttonTestId="logout-button"> + <ods-dropdown-menu-button-item caption="Abmelden" (itemClicked)="logoutEmitter.emit()" dataTestId="logout-button"> <ods-logout-icon icon /> </ods-dropdown-menu-button-item> </ods-dropdown-menu> diff --git a/alfa-client/libs/user-profile/src/lib/user-profile-in-header-container/user-profile-in-header/user-profile-in-header.component.spec.ts b/alfa-client/libs/user-profile/src/lib/user-profile-in-header-container/user-profile-in-header/user-profile-in-header.component.spec.ts index 536a5722d27b6afced3eb3d91b8c1bfc0fc163db..1f91ba4533ee9c3ee1b09102a7df8deb9de9b849 100644 --- a/alfa-client/libs/user-profile/src/lib/user-profile-in-header-container/user-profile-in-header/user-profile-in-header.component.spec.ts +++ b/alfa-client/libs/user-profile/src/lib/user-profile-in-header-container/user-profile-in-header/user-profile-in-header.component.spec.ts @@ -30,7 +30,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MatIcon } from '@angular/material/icon'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { DropdownMenuButtonItemComponent, DropdownMenuComponent, LogoutIconComponent } from '@ods/system'; -import { getDataTestIdOf } from 'libs/tech-shared/test/data-test'; +import { getDataTestIdAttributeOf, getDataTestIdOf } from 'libs/tech-shared/test/data-test'; import { createUserProfileResource } from 'libs/user-profile-shared/test/user-profile'; import { MockComponent } from 'ng-mocks'; import { UserProfileInHeaderComponent } from './user-profile-in-header.component'; @@ -39,7 +39,7 @@ describe('UserProfileInHeaderComponent', () => { let component: UserProfileInHeaderComponent; let fixture: ComponentFixture<UserProfileInHeaderComponent>; - const logoutButton: string = getDataTestIdOf('logout-button'); + const logoutButton: string = getDataTestIdAttributeOf('logout-button'); const userIconDropDownMenu: string = getDataTestIdOf('user-icon-dropdown-menu'); const userProfile: UserProfileResource = createUserProfileResource();