diff --git a/goofy-client/apps/goofy-e2e/src/plugins/index.js b/goofy-client/apps/goofy-e2e/src/plugins/index.js index 750ba9bd68d8ce8cd21b14cfa739307633d989ab..9067346ec0edddf16dbf03231458902863ac2cb2 100644 --- a/goofy-client/apps/goofy-e2e/src/plugins/index.js +++ b/goofy-client/apps/goofy-e2e/src/plugins/index.js @@ -88,13 +88,21 @@ module.exports = (on, config) => { }); // Workaround für Angular 13 und Cypress mit Webpack 4, - // siehe https://github.com/cypress-io/cypress/issues/19066#issuecomment-1012055705 + // Siehe https://github.com/cypress-io/cypress/issues/19066#issuecomment-1012055705 // Entfernen, sobald Cypress Webpack 5 nutzt - https://github.com/cypress-io/cypress/issues/19555 + // Ursache: Angular linker needed to link partial-ivy code, + // see https://angular.io/guide/creating-libraries#consuming-partial-ivy-code-outside-the-angular-cli + // Fehlerbild: + // - Anwendung läuft im Browser, aber nicht in Cypress. + // - Fehlermeldung in Cypress: The injectable 'SystemDateTimeProvider' needs to be compiled using the JIT compiler, but '@angular/compiler' is not available. + // Lösung: + // - NPM-Paket identifizieren, dass "SystemDateTimeProvider" enthält. + // - NPM-Paket im "test" Attribut unten hinzufügen. const webpackPreprocessor = require('@cypress/webpack-batteries-included-preprocessor'); const webpackOptions = webpackPreprocessor.defaultOptions.webpackOptions; webpackOptions.module.rules.unshift({ - test: /[/\\](@angular|@ngxp)[/\\].+\.m?js$/, + test: /[/\\](@angular|@ngxp|angular-oauth2-oidc)[/\\].+\.m?js$/, resolve: { fullySpecified: false, }, diff --git a/goofy-client/apps/goofy/src/app/app.component.spec.ts b/goofy-client/apps/goofy/src/app/app.component.spec.ts index 6b8a7ebf27b6c115bbd597134d3ea183305acb59..0d187520b0144e7ed7deac498aa963255e6d59db 100644 --- a/goofy-client/apps/goofy/src/app/app.component.spec.ts +++ b/goofy-client/apps/goofy/src/app/app.component.spec.ts @@ -25,26 +25,24 @@ import { registerLocaleData } from '@angular/common'; import localeDe from '@angular/common/locales/de'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; -import { ApiRootFacade, ApiRootResource } from '@goofy-client/api-root-shared'; +import { ApiRootFacade } from '@goofy-client/api-root-shared'; import { ENVIRONMENT_CONFIG } from '@goofy-client/environment-shared'; import { NavigationService } from '@goofy-client/navigation-shared'; import { createEmptyStateResource, createStateResource } from '@goofy-client/tech-shared'; import { mock, notExistsAsHtmlElement } from '@goofy-client/test-utils'; import { IconService, SpinnerComponent } from '@goofy-client/ui'; -import { AuthConfig, OAuthService } from 'angular-oauth2-oidc'; +import { AuthConfig, OAuthEvent, OAuthService } from 'angular-oauth2-oidc'; +import { createApiRootResource } from 'libs/api-root-shared/test/api-root'; import { BuildInfoComponent } from 'libs/navigation/src/lib/build-info/build-info.component'; import { HeaderContainerComponent } from 'libs/navigation/src/lib/header-container/header-container.component'; import { getDataTestIdOf } from 'libs/tech-shared/test/data-test'; +import { setWindowLocationUrl } from 'libs/tech-shared/test/window'; import { MockComponent } from 'ng-mocks'; -import { first, of } from 'rxjs'; -import { createApiRootResource } from '../../../../libs/api-root-shared/test/api-root'; +import { of } from 'rxjs'; import { AppComponent } from './app.component'; -import * as NgxpRest from '@ngxp/rest'; -import * as Storage from 'libs/app-shared/src/storage/storage'; import * as VorgangNavigationUtil from 'libs/vorgang-shared/src/lib/vorgang-navigation.util'; - registerLocaleData(localeDe); describe('AppComponent', () => { @@ -56,7 +54,7 @@ describe('AppComponent', () => { const iconService = mock(IconService); const login = Promise.resolve(); - const authService = { ...mock(OAuthService), loadDiscoveryDocumentAndLogin: () => login }; + const authService = { ...mock(OAuthService), loadDiscoveryDocumentAndLogin: () => login, events: of(<OAuthEvent>{}) }; const navigationService = mock(NavigationService); @@ -147,12 +145,30 @@ describe('AppComponent', () => { }) describe('ngOnInit', () => { + it('should call proceedWithLogin', () => { + component.proceedWithLogin = jest.fn(); + + component.ngOnInit(); + + expect(component.proceedWithLogin).toHaveBeenCalled(); + }) + + it('should call listenToOAuthEvents', () => { + component.listenToOAuthEvents = jest.fn(); + + component.ngOnInit(); + + expect(component.listenToOAuthEvents).toHaveBeenCalled(); + }) + }) + + describe('proceedWithLogin', () => { it('should configure oAuthService', () => { const configuration: AuthConfig = {}; component.buildConfiguration = jest.fn().mockReturnValue(configuration); - component.ngOnInit(); + component.proceedWithLogin(); expect(authService.configure).toHaveBeenCalledWith(configuration); }) @@ -160,148 +176,267 @@ describe('AppComponent', () => { it('should build configuration', () => { component.buildConfiguration = jest.fn(); - component.ngOnInit(); + component.proceedWithLogin(); expect(component.buildConfiguration).toHaveBeenCalled(); }) it('should setup automatic silent refresh', () => { - component.ngOnInit(); + component.proceedWithLogin(); expect(authService.setupAutomaticSilentRefresh).toHaveBeenCalled(); }) - describe('after login', () => { + it('should call loadDiscoveryDocumentAndLogin', () => { + const spy = jest.spyOn(authService, 'loadDiscoveryDocumentAndLogin'); - it('should call doAfterLoggedIn', async () => { - component.doAfterLoggedIn = jest.fn(); + component.proceedWithLogin(); - component.ngOnInit(); + expect(spy).toHaveBeenCalled(); + }) + }) - await login; + describe('listenToOAuthEvents', () => { + it('should call consideredAsLoginEvent', () => { + component.consideredAsLoginEvent = jest.fn(); - expect(component.doAfterLoggedIn).toHaveBeenCalled(); - }) + component.listenToOAuthEvents(); + + expect(component.consideredAsLoginEvent).toHaveBeenCalled(); }) - }) - describe('doAfterLoggedIn', () => { + it('should call consideredAsPageReloadEvent', () => { + component.consideredAsPageReloadEvent = jest.fn(); - it('should call getApiRoot to get apiRoot', () => { - component.getApiRoot = jest.fn(); + component.listenToOAuthEvents(); - component.doAfterLoggedIn(); + expect(component.consideredAsPageReloadEvent).toHaveBeenCalled(); - expect(component.getApiRoot).toHaveBeenCalled(); }) - }) - describe('getApiRoot', () => { + it('should call loadApiRootAndNavigate at event "token_received"', () => { + component.loadApiRootAndNavigate = jest.fn(); + authService.events = of({type: 'token_received'}); + + component.listenToOAuthEvents(); - beforeEach(() => { - component.handleDefaultNavigation = jest.fn(); + expect(component.loadApiRootAndNavigate).toHaveBeenCalled(); }) - it('should call handleDefaultNavigation', () => { - apiRootFacade.getApiRoot.mockReturnValue(of(createStateResource(createApiRootResource()))); + it('should call loadApiRootAndNavigate at event "discovery_document_loaded"', () => { + component.loadApiRootAndNavigate = jest.fn(); + authService.events = of({type: 'discovery_document_loaded'}); + jest.spyOn(authService, 'hasValidAccessToken').mockReturnValue(true); + jest.spyOn(authService, 'hasValidIdToken').mockReturnValue(true); - component.getApiRoot().pipe(first()).subscribe(); + component.listenToOAuthEvents(); - expect(component.handleDefaultNavigation).toHaveBeenCalled(); + expect(component.loadApiRootAndNavigate).toHaveBeenCalled(); }) - it('should not call handleDefaultNavigation if resource is empty', () => { - apiRootFacade.getApiRoot.mockReturnValue(of(createEmptyStateResource())); + it('should not call loadApiRootAndNavigate at other event', () => { + component.loadApiRootAndNavigate = jest.fn(); + authService.events = of({type: 'token_error'}); - component.getApiRoot().pipe(first()).subscribe(); + component.listenToOAuthEvents(); - expect(component.handleDefaultNavigation).not.toHaveBeenCalled(); + expect(component.loadApiRootAndNavigate).not.toHaveBeenCalled(); }) }) - describe('handleDefaultNavigation', () => { - const apiRoot: ApiRootResource = createApiRootResource(); + describe('consideredAsLoginEvent', () => { + it('should return true if event is "token_received"', () => { + const event: string = 'token_received'; + + const result: boolean = component.consideredAsLoginEvent(event); - it('should call buildPathSegmentsFromLocalStorage', () => { - const spy = jest.spyOn(VorgangNavigationUtil, 'buildPathSegmentsFromLocalStorage'); + expect(result).toBeTruthy(); + }) - component.handleDefaultNavigation(apiRoot); + it('should return false if event is not "token_received"', () => { + const event: string = 'something_else'; - expect(spy).toHaveBeenCalled(); + const result: boolean = component.consideredAsLoginEvent(event); + + expect(result).toBeFalsy(); }) + }) - it('should call navigationService.isNotRootPath', () => { - navigationService.isNotRootPath = jest.fn(); + describe('consideredAsPageReloadEvent', () => { + it('should return true if event is "discovery_document_loaded" and tokens are valid', () => { + component.hasValidToken = jest.fn().mockReturnValue(true); + const event: string = 'discovery_document_loaded'; - component.handleDefaultNavigation(apiRoot); + const result: boolean = component.consideredAsPageReloadEvent(event); - expect(navigationService.isNotRootPath).toHaveBeenCalled(); + expect(result).toBeTruthy(); }) - it('should call navigationService.navigateByPathSegments if navigation to pathSegements is allowed', () => { - component.canNavigateToPathSegements = jest.fn().mockReturnValue(true); - navigationService.navigateByPathSegments = jest.fn(); + it('should return false if event is "discovery_document_loaded" and tokens are invalid', () => { + component.hasValidToken = jest.fn().mockReturnValue(false); + const event: string = 'discovery_document_loaded'; - component.handleDefaultNavigation(apiRoot); + const result: boolean = component.consideredAsPageReloadEvent(event); - expect(navigationService.navigateByPathSegments).toHaveBeenCalled(); + expect(result).toBeFalsy(); }) - it('should call navigateToDefaultPath if navigation to pathSegements is not allowed', () => { - component.canNavigateToPathSegements = jest.fn().mockReturnValue(false); - component.navigateToDefaultPath = jest.fn(); + it('should return false if event is not "discovery_document_loaded" and tokens are valid', () => { + component.hasValidToken = jest.fn().mockReturnValue(true); + const event: string = 'something_else'; - component.handleDefaultNavigation(apiRoot); + const result: boolean = component.consideredAsPageReloadEvent(event); - expect(component.navigateToDefaultPath).toHaveBeenCalled(); + expect(result).toBeFalsy(); }) + it('should return false if event is not "discovery_document_loaded" and tokens are invalid', () => { + component.hasValidToken = jest.fn().mockReturnValue(false); + const event: string = 'something_else'; + + const result: boolean = component.consideredAsPageReloadEvent(event); + + expect(result).toBeFalsy(); + }) }) - describe('canNavigateToPathSegements', () => { - const pathSegments: string[] = ['alle']; - const apiRoot: ApiRootResource = createApiRootResource(); + describe('hasValidToken', () => { + it('should return true if both tokens are valid', () => { + jest.spyOn(authService, 'hasValidAccessToken').mockReturnValue(true); + jest.spyOn(authService, 'hasValidIdToken').mockReturnValue(true); + + const result: boolean = component.hasValidToken(); + + expect(result).toBeTruthy(); + }) - it('should call buildLinkRelFromPathSegments with pathSegments', () => { - const spy = jest.spyOn(VorgangNavigationUtil, 'buildLinkRelFromPathSegments'); + it('should return false if access token is invalid', () => { + jest.spyOn(authService, 'hasValidAccessToken').mockReturnValue(false); + jest.spyOn(authService, 'hasValidIdToken').mockReturnValue(true); - component.canNavigateToPathSegements(pathSegments, apiRoot); + const result: boolean = component.hasValidToken(); - expect(spy).toHaveBeenCalledWith(pathSegments); + expect(result).toBeFalsy(); }) - it('should call hasLink', () => { - const spy = jest.spyOn(NgxpRest, 'hasLink'); + it('should return false if id token is invalid', () => { + jest.spyOn(authService, 'hasValidAccessToken').mockReturnValue(true); + jest.spyOn(authService, 'hasValidIdToken').mockReturnValue(false); - component.canNavigateToPathSegements(pathSegments, apiRoot); + const result: boolean = component.hasValidToken(); - expect(spy).toHaveBeenCalled(); + expect(result).toBeFalsy(); + }) + + it('should return false if both tokens are invalid', () => { + jest.spyOn(authService, 'hasValidAccessToken').mockReturnValue(false); + jest.spyOn(authService, 'hasValidIdToken').mockReturnValue(false); + + const result: boolean = component.hasValidToken(); + + expect(result).toBeFalsy(); }) }) - describe('navigateToDefaultPath', () => { - it('should call removeLocalStorageView', () => { - const spy = jest.spyOn(Storage, 'removeLocalStorageView'); + describe('loadApiRootAndNavigate', () => { - component.navigateToDefaultPath(); + describe('apiRootResource loaded', () => { + const apiRootStateResource = createStateResource(createApiRootResource()); + + beforeEach(() => { + apiRootFacade.getApiRoot.mockReturnValue(of(apiRootStateResource)); + }) + + it('should call apiRootFacade.getApiRoot to get the apiRoot', () => { + component.loadApiRootAndNavigate(); + + expect(apiRootFacade.getApiRoot).toHaveBeenCalled(); + }) + + it('should call navigationService.navigate', (done) => { + const spy = jest.spyOn(navigationService, 'navigate'); + + component.loadApiRootAndNavigate().subscribe(() => { + expect(spy).toHaveBeenCalled(); + done(); + }); + }) + + it('should call buildInitialPath', (done) => { + component.buildInitialPath = jest.fn(); + + component.loadApiRootAndNavigate().subscribe(() => { + expect(component.buildInitialPath).toHaveBeenCalled(); + done(); + }); + }) - expect(spy).toHaveBeenCalled(); }) - it('should call removeLocalStorageFilter', () => { - const spy = jest.spyOn(Storage, 'removeLocalStorageFilter'); + describe('apiRoot.resource is null', () => { + it.skip('should not call navigationService.navigate', (done) => { + apiRootFacade.getApiRoot.mockReturnValue(of(createEmptyStateResource())); + const spy = jest.spyOn(navigationService, 'navigate'); - component.navigateToDefaultPath(); + component.loadApiRootAndNavigate().subscribe(() => { + expect(spy).not.toHaveBeenCalled(); + done(); + }); + }) + }) - expect(spy).toHaveBeenCalled(); + }) + + describe('buildInitialPath', () => { + + describe('with existing window.location.pathname', () => { + const path: string = '/vorgang/xyz'; + const url: string = 'http://localhost' + path; + + beforeEach(() => { + setWindowLocationUrl(url); + }) + + it('should not call buildPathSegmentsFromLocalStorage', () => { + const spy = jest.spyOn(VorgangNavigationUtil, 'buildPathSegmentsFromLocalStorage'); + + component.buildInitialPath(); + + expect(spy).not.toHaveBeenCalled(); + }) + + + it('should return window.location.pathname', () => { + const redirectPath: string = component.buildInitialPath(); + + expect(redirectPath).toBe(path); + }) }) - it('should call navigationService.navigateToVorgangList', () => { - navigationService.navigateByPathSegments = jest.fn(); + describe('with empty window.location.pathname', () => { + const url: string = 'http://localhost/'; - component.navigateToDefaultPath(); + beforeEach(() => { + setWindowLocationUrl(url); + }) + + it('should call buildPathSegmentsFromLocalStorage', () => { + const spy = jest.spyOn(VorgangNavigationUtil, 'buildPathSegmentsFromLocalStorage'); + + component.buildInitialPath(); + + expect(spy).toHaveBeenCalled(); + }) - expect(navigationService.navigateByPathSegments).toHaveBeenCalled(); + it('should return path segments from localStorage', () => { + const pathSegments: string[] = ['alle', 'angenommen']; + jest.spyOn(VorgangNavigationUtil, 'buildPathSegmentsFromLocalStorage').mockReturnValue(pathSegments); + + const redirectUri: string = component.buildInitialPath(); + + expect(redirectUri).toBe('/alle/angenommen'); + }) }) + }) }); diff --git a/goofy-client/apps/goofy/src/app/app.component.ts b/goofy-client/apps/goofy/src/app/app.component.ts index 86060ae9807f9f2d4e4d1cbae47b5896abbb0474..e1964782d1d17544b5243a15fb50433dd6916c60 100644 --- a/goofy-client/apps/goofy/src/app/app.component.ts +++ b/goofy-client/apps/goofy/src/app/app.component.ts @@ -24,17 +24,17 @@ import { Component, Inject, OnInit } from '@angular/core'; import { Title } from '@angular/platform-browser'; import { ApiRootFacade, ApiRootResource } from '@goofy-client/api-root-shared'; -import { removeLocalStorageFilter, removeLocalStorageView } from '@goofy-client/app-shared'; import { ENVIRONMENT_CONFIG } from '@goofy-client/environment-shared'; import { NavigationService } from '@goofy-client/navigation-shared'; import { StateResource, isNotNull } from '@goofy-client/tech-shared'; import { IconService } from '@goofy-client/ui'; -import { buildLinkRelFromPathSegments, buildPathSegmentsFromLocalStorage } from '@goofy-client/vorgang-shared'; -import { hasLink } from '@ngxp/rest'; -import { AuthConfig, OAuthService } from 'angular-oauth2-oidc'; +import { buildPathSegmentsFromLocalStorage } from '@goofy-client/vorgang-shared'; +import { AuthConfig, OAuthEvent, OAuthService } from 'angular-oauth2-oidc'; import { JwksValidationHandler } from 'angular-oauth2-oidc-jwks'; import { Environment } from 'libs/environment-shared/src/lib/environment.model'; -import { Observable, filter, tap } from 'rxjs'; +import { Observable, Subscription, filter, tap } from 'rxjs'; + +// import * as VorgangNavigationUtil from 'libs/vorgang-shared/src/lib/vorgang-navigation.util'; @Component({ selector: 'goofy-client-root', @@ -46,6 +46,7 @@ export class AppComponent implements OnInit { readonly title: string = 'goofy'; public apiRoot$: Observable<StateResource<ApiRootResource>>; + private oAuthEventSubscription: Subscription; constructor( @Inject(ENVIRONMENT_CONFIG) private envConfig: Environment, @@ -60,50 +61,14 @@ export class AppComponent implements OnInit { ngOnInit(): void { this.proceedWithLogin(); + this.listenToOAuthEvents(); } - private proceedWithLogin() { + proceedWithLogin(): void { this.oAuthService.configure(this.buildConfiguration()); this.oAuthService.setupAutomaticSilentRefresh(); this.oAuthService.tokenValidationHandler = new JwksValidationHandler(); - this.oAuthService.loadDiscoveryDocumentAndLogin().then(() => this.doAfterLoggedIn()); - } - - doAfterLoggedIn(): void { - this.apiRoot$ = this.getApiRoot(); - } - - getApiRoot(): Observable<StateResource<ApiRootResource>> { - return this.apiRootFacade.getApiRoot().pipe( - filter(apiRoot => isNotNull(apiRoot.resource)), - tap(apiRoot => this.handleDefaultNavigation(apiRoot.resource)) - ); - } - - handleDefaultNavigation(apiRootResource: ApiRootResource): void { - if (this.navigationService.isNotRootPath()) { - return; - } - - const pathSegments: string[] = buildPathSegmentsFromLocalStorage(); - - if (this.canNavigateToPathSegements(pathSegments, apiRootResource)) { - this.navigationService.navigateByPathSegments(pathSegments); - } - else { - this.navigateToDefaultPath(); - } - } - - canNavigateToPathSegements(pathSegments: string[], apiRootResource: ApiRootResource): boolean { - return hasLink(apiRootResource, buildLinkRelFromPathSegments(pathSegments)); - } - - navigateToDefaultPath(): void { - removeLocalStorageFilter(); - removeLocalStorageView(); - - this.navigationService.navigateByPathSegments([NavigationService.URL_PARAM_ALLE]); + this.oAuthService.loadDiscoveryDocumentAndLogin(); } buildConfiguration(): AuthConfig { @@ -112,7 +77,8 @@ export class AppComponent implements OnInit { issuer: this.envConfig.authServer + '/realms/' + this.envConfig.realm, // URL of the SPA to redirect the user to after login - redirectUri: window.location.origin + window.location.pathname, + redirectUri: window.location.origin + this.buildInitialPath(), + postLogoutRedirectUri: window.location.origin + '/', // URL of the SPA to redirect the user after silent refresh silentRefreshRedirectUri: window.location.origin + '/silent-refresh.html', @@ -124,12 +90,44 @@ export class AppComponent implements OnInit { // set the scope for the permissions the client should request scope: 'openid profile email', - requireHttps: false, - - waitForTokenInMsec: 250 + requireHttps: false }; } + buildInitialPath(): string { + return window.location.pathname === '/' + ? '/' + buildPathSegmentsFromLocalStorage().join('/') + : window.location.pathname; + } + + listenToOAuthEvents(): void { + this.oAuthEventSubscription = this.oAuthService.events.pipe( + filter((event: OAuthEvent) => this.consideredAsLoginEvent(event.type) || this.consideredAsPageReloadEvent(event.type)) + ).subscribe(() => this.apiRoot$ = this.loadApiRootAndNavigate()); + } + + 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(); + } + + loadApiRootAndNavigate(): Observable<StateResource<ApiRootResource>> { + return this.apiRootFacade.getApiRoot().pipe( + filter(apiRoot => isNotNull(apiRoot.resource)), + tap(() => { + this.navigationService.navigate(this.buildInitialPath()); + this.oAuthEventSubscription.unsubscribe(); + }) + ); + } + //TOCHECK Wird die genutzt? public setTitle(newTitle: string) { this.titleService.setTitle(newTitle); diff --git a/goofy-client/apps/goofy/src/app/app.module.ts b/goofy-client/apps/goofy/src/app/app.module.ts index 8200968307ade93bcef060e287ea66598eae67c0..77939744f3acca9684b129ec2c56e93e31e8e659 100644 --- a/goofy-client/apps/goofy/src/app/app.module.ts +++ b/goofy-client/apps/goofy/src/app/app.module.ts @@ -25,7 +25,7 @@ import { registerLocaleData } from '@angular/common'; import { HttpClientModule } from '@angular/common/http'; import localeDe from '@angular/common/locales/de'; import { LOCALE_ID, NgModule } from '@angular/core'; -import { MatLegacyTooltipDefaultOptions as MatTooltipDefaultOptions, MAT_LEGACY_TOOLTIP_DEFAULT_OPTIONS as MAT_TOOLTIP_DEFAULT_OPTIONS } from '@angular/material/legacy-tooltip'; +import { MAT_LEGACY_TOOLTIP_DEFAULT_OPTIONS as MAT_TOOLTIP_DEFAULT_OPTIONS, MatLegacyTooltipDefaultOptions as MatTooltipDefaultOptions } from '@angular/material/legacy-tooltip'; import { BrowserModule } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { RouterModule, Routes } from '@angular/router'; @@ -66,7 +66,7 @@ const tooltipDefaults: MatTooltipDefaultOptions = { HttpClientModule, BrowserAnimationsModule, RouterModule.forRoot(routes, { - initialNavigation: 'enabledNonBlocking', + initialNavigation: 'disabled', onSameUrlNavigation: 'reload', }), diff --git a/goofy-client/libs/tech-shared/src/index.ts b/goofy-client/libs/tech-shared/src/index.ts index 44e06468c1c69a0857bbd5682e841921d87fd6da..5966f720ca202559393ee400f43fae045e6a75f3 100644 --- a/goofy-client/libs/tech-shared/src/index.ts +++ b/goofy-client/libs/tech-shared/src/index.ts @@ -43,8 +43,10 @@ export * from './lib/pipe/to-resource-uri.pipe'; export * from './lib/pipe/to-traffic-light-tooltip.pipe'; export * from './lib/pipe/to-traffic-light.pipe'; export * from './lib/resource/resource.util'; +export * from './lib/service/auth.service'; export * from './lib/service/formservice.abstract'; export * from './lib/tech-shared.module'; export * from './lib/tech.model'; export * from './lib/tech.util'; export * from './lib/validation/tech.validation.util'; + diff --git a/goofy-client/libs/tech-shared/src/lib/service/auth.service.spec.ts b/goofy-client/libs/tech-shared/src/lib/service/auth.service.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..867912ac67e617839097577086629e1663a9ac4d --- /dev/null +++ b/goofy-client/libs/tech-shared/src/lib/service/auth.service.spec.ts @@ -0,0 +1,71 @@ +import { TestBed } from '@angular/core/testing'; +import { mock } from '@goofy-client/test-utils'; +import { OAuthService } from 'angular-oauth2-oidc'; +import { AuthService } from './auth.service'; + +describe('AuthService', () => { + let service: AuthService; + const oAuthService = mock(OAuthService); + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { + provide: OAuthService, + useValue: oAuthService + } + ] + }); + service = TestBed.inject(AuthService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('hasNoValidToken', () => { + it('should return false if both tokens are valid', () => { + jest.spyOn(oAuthService, 'hasValidAccessToken').mockReturnValue(true); + jest.spyOn(oAuthService, 'hasValidIdToken').mockReturnValue(true); + + TestBed.runInInjectionContext(() => { + const result: boolean = service.hasNoValidToken(); + + expect(result).toBeFalsy(); + }); + }) + + it('should return true if access token is invalid', () => { + jest.spyOn(oAuthService, 'hasValidAccessToken').mockReturnValue(false); + jest.spyOn(oAuthService, 'hasValidIdToken').mockReturnValue(true); + + TestBed.runInInjectionContext(() => { + const result: boolean = service.hasNoValidToken(); + + expect(result).toBeTruthy(); + }); + }) + + it('should return true if id token is invalid', () => { + jest.spyOn(oAuthService, 'hasValidAccessToken').mockReturnValue(true); + jest.spyOn(oAuthService, 'hasValidIdToken').mockReturnValue(false); + + TestBed.runInInjectionContext(() => { + const result: boolean = service.hasNoValidToken(); + + expect(result).toBeTruthy(); + }); + }) + + it('should return true if both tokens are invalid', () => { + jest.spyOn(oAuthService, 'hasValidAccessToken').mockReturnValue(false); + jest.spyOn(oAuthService, 'hasValidIdToken').mockReturnValue(false); + + TestBed.runInInjectionContext(() => { + const result: boolean = service.hasNoValidToken(); + + expect(result).toBeTruthy(); + }); + }) + }) +}); diff --git a/goofy-client/libs/tech-shared/src/lib/service/auth.service.ts b/goofy-client/libs/tech-shared/src/lib/service/auth.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..3a82a429e9b03cf72075026be8f92c06ae649aa3 --- /dev/null +++ b/goofy-client/libs/tech-shared/src/lib/service/auth.service.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@angular/core'; +import { OAuthService } from 'angular-oauth2-oidc'; + +@Injectable({ + providedIn: 'root' +}) +export class AuthService { + + constructor(private oAuthService: OAuthService) { } + + public hasNoValidToken(): boolean { + return ! (this.oAuthService.hasValidAccessToken() && this.oAuthService.hasValidIdToken()); + } +} diff --git a/goofy-client/libs/tech-shared/test/window.ts b/goofy-client/libs/tech-shared/test/window.ts index b1bf7cb2b013add61c3b55ecbe40792222b32090..70f32e4e6609a7012db9c81590836925b41ce30c 100644 --- a/goofy-client/libs/tech-shared/test/window.ts +++ b/goofy-client/libs/tech-shared/test/window.ts @@ -1,15 +1,6 @@ -import { Mock, useFromMock } from '@goofy-client/test-utils'; - -export function createWindowLocationMock(): Mock<Location> { - return <Mock<Location>><any>{ reload: jest.fn() }; -} - -export function mockWindowLocation(): void { - delete window.location; - window.location = useFromMock(createWindowLocationMock()); -} - -export function mockWindowLocationWith(mock: Mock<Location>): void { - delete window.location; - window.location = useFromMock(mock); +export function setWindowLocationUrl(url: string): void { + Object.defineProperty(window, 'location', { + value: new URL(url), + writable: true, + }); } \ No newline at end of file diff --git a/goofy-client/libs/ui/src/lib/snackbar/snackbar.service.spec.ts b/goofy-client/libs/ui/src/lib/snackbar/snackbar.service.spec.ts index 20ab17b9c0326419e721fb34a12f90713689e578..3b521851693652fba0d05f12b1266a4761d27afe 100644 --- a/goofy-client/libs/ui/src/lib/snackbar/snackbar.service.spec.ts +++ b/goofy-client/libs/ui/src/lib/snackbar/snackbar.service.spec.ts @@ -171,4 +171,23 @@ describe('SnackBarService', () => { expect(isVisible).toBeTruthy(); }) }) + + describe('isNotVisible', () => { + + it('should return false if snackBarRef is not undefined', () => { + service.snackBarRef = undefined; + + const isVisible: boolean = service.isNotVisible(); + + expect(isVisible).toBeTruthy(); + }) + + it('should return true if snackBarRef is defined', () => { + service.snackBarRef = useFromMock(mock(MatSnackBarRef<SnackbarInfoComponent>)); + + const isVisible: boolean = service.isNotVisible(); + + expect(isVisible).toBeFalsy(); + }) + }) }) diff --git a/goofy-client/libs/ui/src/lib/snackbar/snackbar.service.ts b/goofy-client/libs/ui/src/lib/snackbar/snackbar.service.ts index 54e2cac67de18ac763a0a2ab8c35b7f21c3b3024..816e890b7ed9d921c6f3c033ab8c53f1d0b22996 100644 --- a/goofy-client/libs/ui/src/lib/snackbar/snackbar.service.ts +++ b/goofy-client/libs/ui/src/lib/snackbar/snackbar.service.ts @@ -25,6 +25,7 @@ import { Injectable } from '@angular/core'; import { MatLegacySnackBar as MatSnackBar, MatLegacySnackBarRef as MatSnackBarRef } from '@angular/material/legacy-snack-bar'; import { CommandResource, CommandStatus } from '@goofy-client/command-shared'; import { isNotUndefined } from '@goofy-client/tech-shared'; +import { isUndefined } from 'lodash-es'; import { Subscription } from 'rxjs'; import { SnackbarErrorComponent } from './snackbar-error/snackbar-error.component'; import { SnackbarInfoComponent } from './snackbar-info/snackbar-info.component'; @@ -64,6 +65,10 @@ export class SnackBarService { return isNotUndefined(this.snackBarRef); } + public isNotVisible(): boolean { + return isUndefined(this.snackBarRef); + } + closeSnackBarIfVisible() { if (isNotUndefined(this.snackBarRef)) this.closeSnackBar(); } diff --git a/goofy-client/libs/vorgang-shared/src/lib/vorgang.service.spec.ts b/goofy-client/libs/vorgang-shared/src/lib/vorgang.service.spec.ts index b509d64b4249f99c8824b513f08128f2a860ab30..bae31d79027319a04d92c40e8822281532ce4d96 100644 --- a/goofy-client/libs/vorgang-shared/src/lib/vorgang.service.spec.ts +++ b/goofy-client/libs/vorgang-shared/src/lib/vorgang.service.spec.ts @@ -22,11 +22,11 @@ * unter der Lizenz sind dem Lizenztext zu entnehmen. */ import faker from '@faker-js/faker'; -import { ApiRootResource, ApiRootService } from '@goofy-client/api-root-shared'; +import { ApiRootLinkRel, ApiRootResource, ApiRootService } from '@goofy-client/api-root-shared'; import { BinaryFileListResource } from '@goofy-client/binary-file-shared'; import { CommandResource } from '@goofy-client/command-shared'; import { NavigationService } from '@goofy-client/navigation-shared'; -import { StateResource, createEmptyStateResource, createStateResource } from '@goofy-client/tech-shared'; +import { EMPTY_STRING, StateResource, createEmptyStateResource, createStateResource } from '@goofy-client/tech-shared'; import { Mock, mock, useFromMock } from '@goofy-client/test-utils'; import { ResourceUri, getUrl } from '@ngxp/rest'; import { cold, hot } from 'jest-marbles'; @@ -35,11 +35,13 @@ import { createBinaryFileListResource } from 'libs/binary-file-shared/test/binar import { CommandLinkRel } from 'libs/command-shared/src/lib/command.linkrel'; import { createCommandResource } from 'libs/command-shared/test/command'; import { createVorgangWithEingangResource } from 'libs/vorgang-shared/test/vorgang'; -import { of } from 'rxjs'; +import { Observable, of } from 'rxjs'; import { VorgangFacade } from './+state/vorgang.facade'; import { VorgangWithEingangResource } from './vorgang.model'; import { VorgangService } from './vorgang.service'; +import * as VorgangNavigationUtil from './vorgang-navigation.util'; + describe('VorgangService', () => { let service: VorgangService; let navigationService: Mock<NavigationService>; @@ -385,4 +387,44 @@ describe('VorgangService', () => { expect(facade.getBackButtonUrl).toHaveBeenCalled(); }) }) + + describe('canNavigateToPathSegements', () => { + const apiRootStateResource: StateResource<ApiRootResource> = createStateResource(createApiRootResource([ApiRootLinkRel.ALLE_VORGAENGE_ABGESCHLOSSEN])); + + beforeEach(() => { + apiRootService.getApiRoot.mockReturnValue(hot('a', { a: apiRootStateResource })); + }) + + it('should apiRoot', () => { + service.canNavigateToPathSegements(EMPTY_STRING); + + expect(apiRootService.getApiRoot).toHaveBeenCalled(); + }) + + it('should call buildLinkRelFromPathSegments', () => { + const spy = jest.spyOn(VorgangNavigationUtil, 'buildLinkRelFromPathSegments'); + + service.canNavigateToPathSegements(EMPTY_STRING); + + expect(spy).toHaveBeenCalled(); + }) + + it('should return false as Observable if linkRel not available', () => { + const path: string = '/alle/angenommen'; + + const result: Observable<boolean> = service.canNavigateToPathSegements(path); + + expect(result).toBeObservable(cold('a', { a: false })); + }) + + it('should return true as Observable if linkRel available', () => { + const path: string = '/alle/abgeschlossen'; + + const result: Observable<boolean> = service.canNavigateToPathSegements(path); + + expect(result).toBeObservable(cold('a', { a: true })); + }) + + }) + }) diff --git a/goofy-client/libs/vorgang-shared/src/lib/vorgang.service.ts b/goofy-client/libs/vorgang-shared/src/lib/vorgang.service.ts index 6ad076a776da69ec2dcc223bdcba3b1b4d017bb3..88f6a0b83ae40cfb3e2c36fce543f3130924e397 100644 --- a/goofy-client/libs/vorgang-shared/src/lib/vorgang.service.ts +++ b/goofy-client/libs/vorgang-shared/src/lib/vorgang.service.ts @@ -27,11 +27,12 @@ import { BinaryFileListResource } from '@goofy-client/binary-file-shared'; import { CommandResource } from '@goofy-client/command-shared'; import { NavigationService } from '@goofy-client/navigation-shared'; import { StateResource, createEmptyStateResource, doIfLoadingRequired, isNotNull } from '@goofy-client/tech-shared'; -import { ResourceUri, getUrl } from '@ngxp/rest'; +import { ResourceUri, getUrl, hasLink } from '@ngxp/rest'; import { CommandLinkRel } from 'libs/command-shared/src/lib/command.linkrel'; import { Observable, combineLatest } from 'rxjs'; -import { map, startWith, tap, withLatestFrom } from 'rxjs/operators'; +import { filter, map, startWith, tap, withLatestFrom } from 'rxjs/operators'; import { VorgangFacade } from './+state/vorgang.facade'; +import { buildLinkRelFromPathSegments } from './vorgang-navigation.util'; import { VorgangWithEingangResource } from './vorgang.model'; import { createAssignUserCommand } from './vorgang.util'; @@ -127,4 +128,14 @@ export class VorgangService { public getBackButtonUrl(): Observable<string> { return this.facade.getBackButtonUrl(); } + + public canNavigateToPathSegements(path: string): Observable<boolean> { + const pathSegments: string[] = path.substring(1).split('/'); + const linkRel: string = buildLinkRelFromPathSegments(pathSegments); + + return this.apiRootService.getApiRoot().pipe( + filter(apiRoot => isNotNull(apiRoot.resource)), + map(apiRoot => hasLink(apiRoot.resource, linkRel)) + ); + } } diff --git a/goofy-client/libs/vorgang/src/lib/vorgang-filter-view.guard.spec.ts b/goofy-client/libs/vorgang/src/lib/vorgang-filter-view.guard.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..02f81e36dc091b2c4827eadc875fc0355c59fe8e --- /dev/null +++ b/goofy-client/libs/vorgang/src/lib/vorgang-filter-view.guard.spec.ts @@ -0,0 +1,117 @@ +import { TestBed } from '@angular/core/testing'; +import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot, UrlSegment } from '@angular/router'; +import { AuthService } from '@goofy-client/tech-shared'; +import { mock } from '@goofy-client/test-utils'; +import { VorgangService } from '@goofy-client/vorgang-shared'; +import { Observable, of } from 'rxjs'; + +import * as RouterHelper from '@angular/router'; +import * as Guard from './vorgang-filter-view.guard'; + +const next: ActivatedRouteSnapshot = {} as unknown as ActivatedRouteSnapshot; +const state: RouterStateSnapshot = { root: { url: [<UrlSegment>{}] } } as unknown as RouterStateSnapshot; + +describe('vorgangFilterViewGuard', () => { + const vorgangService = mock(VorgangService); + const authService = mock(AuthService); + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { + provide: VorgangService, + useValue: vorgangService + }, + { + provide: AuthService, + useValue: authService + } + ] + }); + + jest.spyOn(vorgangService, 'canNavigateToPathSegements').mockReturnValue(of(false)); + }); + + + it('should be created', () => { + const guard = TestBed.runInInjectionContext(() => Guard.vorgangFilterViewGuard(next, state)) as unknown as CanActivateFn; + + expect(guard).toBeTruthy(); + }); + + describe('vorgangFilterViewGuard', () => { + it('should call authService.hasNoValidToken', () => { + const spy = jest.spyOn(authService, 'hasNoValidToken'); + + TestBed.runInInjectionContext(() => Guard.vorgangFilterViewGuard(next, state)) as unknown as CanActivateFn; + + expect(spy).toHaveBeenCalled(); + }) + + describe('access token is valid', () => { + beforeEach(() => { + jest.spyOn(authService, 'hasNoValidToken').mockReturnValue(false); + }) + + it('should call vorgangService.canNavigateToPathSegements', () => { + const spy = jest.spyOn(vorgangService, 'canNavigateToPathSegements'); + + TestBed.runInInjectionContext(() => Guard.vorgangFilterViewGuard(next, state)) as unknown as CanActivateFn; + + expect(spy).toHaveBeenCalled(); + }) + + it('should call createUrlTreeFromSnapshot', () => { + const createUrlTreeFromSnapshot = jest.spyOn(RouterHelper, 'createUrlTreeFromSnapshot'); + jest.spyOn(vorgangService, 'canNavigateToPathSegements').mockReturnValue(of(false)); + + TestBed.runInInjectionContext(() => ( + Guard.vorgangFilterViewGuard(next, state) as Observable<RouterHelper.UrlTree>).subscribe() + ) as unknown as CanActivateFn; + + expect(createUrlTreeFromSnapshot).toHaveBeenCalled(); + }) + + it.skip('should return UrlTree', (done) => { + jest.spyOn(vorgangService, 'canNavigateToPathSegements').mockReturnValue(of(false)); + + TestBed.runInInjectionContext(() => ( + Guard.vorgangFilterViewGuard(next, state) as Observable<RouterHelper.UrlTree>).subscribe( + result => { + expect(result).toBeInstanceOf(RouterHelper.UrlTree); + done(); + } + ) + ) as unknown as CanActivateFn; + + }) + + it('should return true', (done) => { + jest.spyOn(vorgangService, 'canNavigateToPathSegements').mockReturnValue(of(true)); + + TestBed.runInInjectionContext(() => ( + Guard.vorgangFilterViewGuard(next, state) as Observable<RouterHelper.UrlTree>).subscribe( + result => { + expect(result).toBeTruthy(); + done(); + } + ) + ) as unknown as CanActivateFn; + }) + + }) + + describe('access token is not valid', () => { + beforeEach(() => { + jest.spyOn(authService, 'hasNoValidToken').mockReturnValue(true); + }) + + it('should return true', () => { + const result = TestBed.runInInjectionContext(() => Guard.vorgangFilterViewGuard(next, state)) as unknown as CanActivateFn; + + expect(result).toBeTruthy(); + }) + }) + }) + +}); diff --git a/goofy-client/libs/vorgang/src/lib/vorgang-filter-view.guard.ts b/goofy-client/libs/vorgang/src/lib/vorgang-filter-view.guard.ts new file mode 100644 index 0000000000000000000000000000000000000000..80ca80f5203e1c7bf3cdffeb1d6aa117db3f8c3d --- /dev/null +++ b/goofy-client/libs/vorgang/src/lib/vorgang-filter-view.guard.ts @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2022 Das Land Schleswig-Holstein vertreten durch den + * Ministerpräsidenten des Landes Schleswig-Holstein + * Staatskanzlei + * Abteilung Digitalisierung und zentrales IT-Management der Landesregierung + * + * Lizenziert unter der EUPL, Version 1.2 oder - sobald + * diese von der Europäischen Kommission genehmigt wurden - + * Folgeversionen der EUPL ("Lizenz"); + * Sie dürfen dieses Werk ausschließlich gemäß + * dieser Lizenz nutzen. + * Eine Kopie der Lizenz finden Sie hier: + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Sofern nicht durch anwendbare Rechtsvorschriften + * gefordert oder in schriftlicher Form vereinbart, wird + * die unter der Lizenz verbreitete Software "so wie sie + * ist", OHNE JEGLICHE GEWÄHRLEISTUNG ODER BEDINGUNGEN - + * ausdrücklich oder stillschweigend - verbreitet. + * Die sprachspezifischen Genehmigungen und Beschränkungen + * unter der Lizenz sind dem Lizenztext zu entnehmen. + */ +import { inject } from '@angular/core'; +import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot, createUrlTreeFromSnapshot } from '@angular/router'; +import { AuthService } from '@goofy-client/tech-shared'; +import { ALLE_ROUTE_PARAM, VorgangService } from '@goofy-client/vorgang-shared'; +import { map } from 'rxjs'; + +export const vorgangFilterViewGuard: CanActivateFn = (next: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { + const vorgangService = inject(VorgangService) + const authService = inject(AuthService); + + if (authService.hasNoValidToken()) { + return true; + } + + return vorgangService.canNavigateToPathSegements(state.url).pipe( + map(hasLink => hasLink ? true : createUrlTreeFromSnapshot(next, ['/', ALLE_ROUTE_PARAM])) + ); +}; diff --git a/goofy-client/libs/vorgang/src/lib/vorgang-list-page.guard.spec.ts b/goofy-client/libs/vorgang/src/lib/vorgang-list-page.guard.spec.ts index 0360f3e82c2c3dbf865513f24ba14d0c1a64a006..adad434076dba8cf9a007d460e261caa99f2ca95 100644 --- a/goofy-client/libs/vorgang/src/lib/vorgang-list-page.guard.spec.ts +++ b/goofy-client/libs/vorgang/src/lib/vorgang-list-page.guard.spec.ts @@ -23,19 +23,18 @@ */ import { TestBed } from '@angular/core/testing'; import { ActivatedRouteSnapshot, CanActivateFn, Router, RouterStateSnapshot, UrlSegment, UrlTree } from '@angular/router'; +import { AuthService } from '@goofy-client/tech-shared'; import { mock, useFromMock } from '@goofy-client/test-utils'; -import { OAuthService } from 'angular-oauth2-oidc'; import * as VorgangNavigationUtil from 'libs/vorgang-shared/src/lib/vorgang-navigation.util'; import * as Guard from './vorgang-list-page.guard'; - const next: ActivatedRouteSnapshot = {} as unknown as ActivatedRouteSnapshot; const state: RouterStateSnapshot = { root: { url: [<UrlSegment>{}] } } as unknown as RouterStateSnapshot; describe('VorgangListPageGuard', () => { const router = mock(Router); - const oAuthService = mock(OAuthService); + const authService = mock(AuthService); beforeEach(() => { TestBed.configureTestingModule({ @@ -45,8 +44,8 @@ describe('VorgangListPageGuard', () => { useValue: router }, { - provide: OAuthService, - useValue: oAuthService + provide: AuthService, + useValue: authService } ] }); @@ -82,74 +81,47 @@ describe('VorgangListPageGuard', () => { }) - describe('accessTokenIsInvalid', () => { - it('should call oAuthService.hasValidAccessToken', () => { - oAuthService.hasValidAccessToken = jest.fn(); - - Guard.accessTokenIsInvalid(useFromMock(oAuthService)); - - expect(oAuthService.hasValidAccessToken).toHaveBeenCalled(); - }) - - - it('should return true', () => { - oAuthService.hasValidAccessToken = jest.fn().mockReturnValue(false); - - const result: boolean = Guard.accessTokenIsInvalid(useFromMock(oAuthService)); - - expect(result).toBeTruthy(); - }) - - it('should return false', () => { - oAuthService.hasValidAccessToken = jest.fn().mockReturnValue(true); - - const result: boolean = Guard.accessTokenIsInvalid(useFromMock(oAuthService)); - - expect(result).toBeFalsy(); - }) - }) - describe('buildDefaultUrlTreeFromLocalStorage', () => { const buildPathSegmentsFromLocalStorage = jest.spyOn(VorgangNavigationUtil, 'buildPathSegmentsFromLocalStorage'); - it('should call guard.accessTokenIsInvalid', () => { - const spy = jest.spyOn(Guard, 'accessTokenIsInvalid'); + it('should call hasNoValidToken', () => { + const spy = jest.spyOn(authService, 'hasNoValidToken'); - Guard.buildDefaultUrlTreeFromLocalStorage(useFromMock(router), useFromMock(oAuthService)); + TestBed.runInInjectionContext(() => Guard.buildDefaultUrlTreeFromLocalStorage(useFromMock(router), useFromMock(authService))); expect(spy).toHaveBeenCalled(); }) it('should call router.createUrlTree', () => { - jest.spyOn(Guard, 'accessTokenIsInvalid').mockReturnValue(false); + jest.spyOn(authService, 'hasNoValidToken').mockReturnValue(false); - Guard.buildDefaultUrlTreeFromLocalStorage(useFromMock(router), useFromMock(oAuthService)); + TestBed.runInInjectionContext(() => Guard.buildDefaultUrlTreeFromLocalStorage(useFromMock(router), useFromMock(authService))); expect(router.createUrlTree).toHaveBeenCalled(); }) it('should call VorgangNavigationUtil.buildPathSegmentsFromLocalStorage', () => { - jest.spyOn(Guard, 'accessTokenIsInvalid').mockReturnValue(false); + jest.spyOn(authService, 'hasNoValidToken').mockReturnValue(false); - Guard.buildDefaultUrlTreeFromLocalStorage(useFromMock(router), useFromMock(oAuthService)); + TestBed.runInInjectionContext(() => Guard.buildDefaultUrlTreeFromLocalStorage(useFromMock(router), useFromMock(authService))); expect(buildPathSegmentsFromLocalStorage).toHaveBeenCalled(); }) it('should return URLTree if access token is valid', () => { - jest.spyOn(Guard, 'accessTokenIsInvalid').mockReturnValue(false); + jest.spyOn(authService, 'hasNoValidToken').mockReturnValue(false); const pathSegments: string[] = ['meine', 'neu']; buildPathSegmentsFromLocalStorage.mockReturnValue(pathSegments); - Guard.buildDefaultUrlTreeFromLocalStorage(useFromMock(router), useFromMock(oAuthService)); + TestBed.runInInjectionContext(() => Guard.buildDefaultUrlTreeFromLocalStorage(useFromMock(router), useFromMock(authService))); expect(router.createUrlTree).toHaveBeenCalledWith(pathSegments); }) it('should return true if it has no valid access token', () => { - jest.spyOn(Guard, 'accessTokenIsInvalid').mockReturnValue(true); + jest.spyOn(authService, 'hasNoValidToken').mockReturnValue(true); - const result: boolean | UrlTree = Guard.buildDefaultUrlTreeFromLocalStorage(useFromMock(router), useFromMock(oAuthService)); + const result: boolean | UrlTree = Guard.buildDefaultUrlTreeFromLocalStorage(useFromMock(router), useFromMock(authService)); expect(result).toBeTruthy(); }) diff --git a/goofy-client/libs/vorgang/src/lib/vorgang-list-page.guard.ts b/goofy-client/libs/vorgang/src/lib/vorgang-list-page.guard.ts index 64ddaefe8f147eff3a7e0ed18185cb6bbf949cfa..843413334d0c80e4c276f370be739d7a40158754 100644 --- a/goofy-client/libs/vorgang/src/lib/vorgang-list-page.guard.ts +++ b/goofy-client/libs/vorgang/src/lib/vorgang-list-page.guard.ts @@ -23,32 +23,28 @@ */ import { inject } from '@angular/core'; import { ActivatedRouteSnapshot, CanActivateFn, Router, RouterStateSnapshot, UrlTree } from '@angular/router'; -import { OAuthService } from 'angular-oauth2-oidc'; +import { AuthService } from '@goofy-client/tech-shared'; import { buildPathSegmentsFromLocalStorage } from 'libs/vorgang-shared/src/lib/vorgang-navigation.util'; import { isEmpty } from 'lodash-es'; import * as Guard from './vorgang-list-page.guard'; export const vorgangListPageGuard: CanActivateFn = (next: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { - const oAuthService = inject(OAuthService); const router = inject(Router); + const authService = inject(AuthService); if (isEmpty(state.root.url) ) { - return Guard.buildDefaultUrlTreeFromLocalStorage(router, oAuthService); + return Guard.buildDefaultUrlTreeFromLocalStorage(router, authService); } return true; }; -export function buildDefaultUrlTreeFromLocalStorage(router: Router, oAuthService: OAuthService): boolean | UrlTree { - if (Guard.accessTokenIsInvalid(oAuthService)) { +export function buildDefaultUrlTreeFromLocalStorage(router: Router, authService: AuthService): boolean | UrlTree { + if (authService.hasNoValidToken()) { return true; } const pathSegments: string[] = buildPathSegmentsFromLocalStorage(); return router.createUrlTree(pathSegments); } - -export function accessTokenIsInvalid(oAuthService: OAuthService): boolean { - return ! oAuthService.hasValidAccessToken(); -} diff --git a/goofy-client/libs/vorgang/src/lib/vorgang.module.ts b/goofy-client/libs/vorgang/src/lib/vorgang.module.ts index 96a93084aec49115f98fad1399781e523e2acb18..e37e14294423951d6c37413894256c8f19e4959f 100644 --- a/goofy-client/libs/vorgang/src/lib/vorgang.module.ts +++ b/goofy-client/libs/vorgang/src/lib/vorgang.module.ts @@ -32,6 +32,7 @@ import { UserProfileModule } from '@goofy-client/user-profile'; import { VorgangSharedModule } from '@goofy-client/vorgang-shared'; import { VorgangSharedUiModule } from '@goofy-client/vorgang-shared-ui'; import { WiedervorlageModule } from '@goofy-client/wiedervorlage'; +import { vorgangFilterViewGuard } from './vorgang-filter-view.guard'; import { VorgangListContainerComponent } from './vorgang-list-container/vorgang-list-container.component'; import { EmptyListComponent } from './vorgang-list-container/vorgang-list/empty-list/empty-list.component'; import { VorgangCreatedAtComponent } from './vorgang-list-container/vorgang-list/vorgang-list-item/vorgang-created-at/vorgang-created-at.component'; @@ -70,74 +71,82 @@ const routes: Routes = [ title: 'Alle Vorgänge | Alfa', component: VorgangListContainerComponent, }, - { - path: 'alle/search', - title: 'Suchergebnisseite | Alfa', - component: VorgangListSearchContainerComponent, - }, { path: 'alle/search/:search', title: 'Suchergebnisseite | Alfa', + canActivate: [vorgangFilterViewGuard], component: VorgangListContainerComponent, }, + { + path: 'alle/search', + redirectTo: 'alle' + }, { path: 'alle/:status', title: 'Alle Vorgänge | Alfa', + canActivate: [vorgangFilterViewGuard], component: VorgangListContainerComponent, }, { path: 'alle/wiedervorlagen', title: 'Alle Vorgänge mit offenen Wiedervorlagen', + canActivate: [vorgangFilterViewGuard], component: VorgangListContainerComponent, }, { path: 'meine', title: 'Meine Vorgänge | Alfa', + canActivate: [vorgangFilterViewGuard], component: VorgangListContainerComponent, }, - { - path: 'meine/search', - title: 'Suchergebnisseite | Alfa', - component: VorgangListSearchContainerComponent, - }, { path: 'meine/search/:search', title: 'Suchergebnis in meine Vorgänge | Alfa', + canActivate: [vorgangFilterViewGuard], component: VorgangListContainerComponent, }, + { + path: 'meine/search', + redirectTo: 'meine' + }, { path: 'meine/:status', title: 'Meine Vorgänge | Alfa', + canActivate: [vorgangFilterViewGuard], component: VorgangListContainerComponent, }, { path: 'meine/wiedervorlagen', title: 'Alle mir zugewiesenen Vorgänge mit offenen Wiedervorlagen', + canActivate: [vorgangFilterViewGuard], component: VorgangListContainerComponent, }, { path: 'unassigned', title: 'Nicht zugewiesen | Alfa', + canActivate: [vorgangFilterViewGuard], component: VorgangListContainerComponent, }, - { - path: 'unassigned/search', - title: 'Suchergebnisseite | Alfa', - component: VorgangListSearchContainerComponent, - }, { path: 'unassigned/search/:search', title: 'Suchergebnis in nicht zugewiesen | Alfa', + canActivate: [vorgangFilterViewGuard], component: VorgangListContainerComponent, }, + { + path: 'unassigned/search', + redirectTo: 'unassigned' + }, { path: 'unassigned/:status', title: 'Nicht zugewiesen | Alfa', + canActivate: [vorgangFilterViewGuard], component: VorgangListContainerComponent, }, { path: 'unassigned/wiedervorlagen', title: 'Alle nicht zugewiesenen Vorgänge mit offenen Wiedervorlagen', + canActivate: [vorgangFilterViewGuard], component: VorgangListContainerComponent, }, ], diff --git a/goofy-client/libs/wiedervorlage-shared/src/lib/wiedervorlage.service.spec.ts b/goofy-client/libs/wiedervorlage-shared/src/lib/wiedervorlage.service.spec.ts index caff58b933b10f7e11195717f99ca269fa6b0860..66620330c47c1c42d19ad4c16d89610f0cde8f03 100644 --- a/goofy-client/libs/wiedervorlage-shared/src/lib/wiedervorlage.service.spec.ts +++ b/goofy-client/libs/wiedervorlage-shared/src/lib/wiedervorlage.service.spec.ts @@ -489,8 +489,6 @@ describe('WiedervorlageService', () => { }) describe('hasEditLink', () => { - const wiedervorlage: WiedervorlageResource = createWiedervorlageResource(); - const wiedervorlageStateResource: StateResource<WiedervorlageResource> = createStateResource(wiedervorlage); beforeEach(() => { navigationService.getDecodedParam.mockReturnValue(getUrl(wiedervorlageResource)); @@ -513,5 +511,6 @@ describe('WiedervorlageService', () => { expect(repository.getWiedervorlage).toHaveBeenCalledWith(decodedUrl); }) + }) }) \ No newline at end of file diff --git a/goofy-client/libs/wiedervorlage/src/lib/wiedervorlage.guard.spec.ts b/goofy-client/libs/wiedervorlage/src/lib/wiedervorlage.guard.spec.ts index 52501de12cc9e16b4e85d8f72ec28356d52dbc59..0b54099e8046afd71836a2acebfb726f1d47503f 100644 --- a/goofy-client/libs/wiedervorlage/src/lib/wiedervorlage.guard.spec.ts +++ b/goofy-client/libs/wiedervorlage/src/lib/wiedervorlage.guard.spec.ts @@ -26,18 +26,19 @@ import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot, UrlSegment import faker from '@faker-js/faker'; import { encodeUrlForEmbedding } from '@goofy-client/tech-shared'; import { mock } from '@goofy-client/test-utils'; +import { SnackBarService } from '@goofy-client/ui'; import { WiedervorlageService } from '@goofy-client/wiedervorlage-shared'; import { Observable, of } from 'rxjs'; import * as RouterHelper from '@angular/router'; import * as Guard from './wiedervorlage.guard'; - -const next: ActivatedRouteSnapshot = { params: { wiedervorlageUrl: '' }} as unknown as ActivatedRouteSnapshot; +const next: ActivatedRouteSnapshot = { params: { wiedervorlageUrl: '', vorgangWithEingangUrl: '' } } as unknown as ActivatedRouteSnapshot; const state: RouterStateSnapshot = { root: { url: [<UrlSegment>{}] } } as unknown as RouterStateSnapshot; describe('wiedervorlageGuard', () => { const wiedervorlageService = mock(WiedervorlageService); + const snackbarService = mock(SnackBarService); beforeEach(() => { TestBed.configureTestingModule({ @@ -45,6 +46,10 @@ describe('wiedervorlageGuard', () => { { provide: WiedervorlageService, useValue: wiedervorlageService + }, + { + provide: SnackBarService, + useValue: snackbarService } ] }); @@ -80,27 +85,69 @@ describe('wiedervorlageGuard', () => { expect(createUrlTreeFromSnapshot).toHaveBeenCalled(); }) - it.skip('should return UrlTree', () => { - jest.spyOn(wiedervorlageService, 'hasEditLink').mockReturnValue(of(true)); + it.skip('should return UrlTree', (done) => { + jest.spyOn(wiedervorlageService, 'hasEditLink').mockReturnValue(of(false)); TestBed.runInInjectionContext(() => ( Guard.wiedervorlageGuard(next, state) as Observable<RouterHelper.UrlTree>).subscribe( - result => expect(result).toBeInstanceOf(RouterHelper.UrlTree) + result => { + expect(result).toBeInstanceOf(RouterHelper.UrlTree); + done(); + } ) ) as unknown as CanActivateFn; - - }) - it.skip('should return true', () => { + it('should return true', (done) => { jest.spyOn(wiedervorlageService, 'hasEditLink').mockReturnValue(of(true)); TestBed.runInInjectionContext(() => ( Guard.wiedervorlageGuard(next, state) as Observable<RouterHelper.UrlTree>).subscribe( - result => expect(result).toBeTruthy() + result => { + expect(result).toBeTruthy(); + done(); + } ) ) as unknown as CanActivateFn; + }) + }) + + describe('handleLinkCheck', () => { + it('should return true if hasLink is true', () => { + const hasLink = true; + + const result = Guard.handleLinkCheck(hasLink, null, null); + + expect(result).toBeTruthy(); + }) + + it.skip('should call showSnackbar if hasLink is false', () => { + const hasLink = false; + const spy = jest.spyOn(Guard, 'showSnackbar'); + + Guard.handleLinkCheck(hasLink, next, snackbarService as unknown as SnackBarService); + + expect(spy).toHaveBeenCalled(); + }) + + it.skip('should return URLTree object if hasLink is false', () => { + const spy = jest.spyOn(RouterHelper, 'createUrlTreeFromSnapshot'); + const hasLink = false; + jest.spyOn(Guard, 'showSnackbar'); + + Guard.handleLinkCheck(hasLink, next, snackbarService as unknown as SnackBarService); + + expect(spy).toHaveBeenCalled(); + }) + }) + + describe('showSnackbar', () => { + const showError = jest.spyOn(snackbarService, 'showError'); + + it('should call snackbarService.showError', () => { + Guard.showSnackbar(snackbarService as unknown as SnackBarService); + expect(showError).toHaveBeenCalledWith('Im Status "Zu löschen" ist die Bearbeitung von Wiedervorlagen nicht möglich.'); }) }) diff --git a/goofy-client/libs/wiedervorlage/src/lib/wiedervorlage.guard.ts b/goofy-client/libs/wiedervorlage/src/lib/wiedervorlage.guard.ts index 31ffbbbe3bf80c7ec133ac9f4ffd1f80966f9e89..1238ee52f9c4ed9a8e298d00c1490a70b1909d74 100644 --- a/goofy-client/libs/wiedervorlage/src/lib/wiedervorlage.guard.ts +++ b/goofy-client/libs/wiedervorlage/src/lib/wiedervorlage.guard.ts @@ -22,12 +22,14 @@ * unter der Lizenz sind dem Lizenztext zu entnehmen. */ import { inject } from '@angular/core'; -import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot, createUrlTreeFromSnapshot } from '@angular/router'; +import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot, UrlTree, createUrlTreeFromSnapshot } from '@angular/router'; +import { SnackBarService } from '@goofy-client/ui'; import { WiedervorlageService } from '@goofy-client/wiedervorlage-shared'; import { map } from 'rxjs'; export const wiedervorlageGuard: CanActivateFn = (next: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { const wiedervorlageService = inject(WiedervorlageService) + const snackbarService = inject(SnackBarService); const wiedervorlageUrl = next.params['wiedervorlageUrl']; if (wiedervorlageUrl === 'neu') { @@ -35,6 +37,20 @@ export const wiedervorlageGuard: CanActivateFn = (next: ActivatedRouteSnapshot, } return wiedervorlageService.hasEditLink(wiedervorlageUrl).pipe( - map(hasLink => hasLink ? true : createUrlTreeFromSnapshot(next, ['/vorgang', next.params['vorgangWithEingangUrl']])) + map(hasLink => handleLinkCheck(hasLink, next, snackbarService)) ); }; + +export function handleLinkCheck(hasLink: boolean, next: ActivatedRouteSnapshot, snackbarService: SnackBarService): boolean | UrlTree { + if (hasLink) { + return true; + } + + showSnackbar(snackbarService); + + return createUrlTreeFromSnapshot(next, ['/vorgang', next.params['vorgangWithEingangUrl']]); +} + +export function showSnackbar(snackbarService: SnackBarService): void { + snackbarService.showError('Im Status "Zu löschen" ist die Bearbeitung von Wiedervorlagen nicht möglich.'); +}