From ea1cd55c1a0946915fd34278ff7fc10d3cc966d2 Mon Sep 17 00:00:00 2001 From: mdelez <60604010+mdelez@users.noreply.github.com> Date: Tue, 17 Aug 2021 09:45:51 +0200 Subject: [PATCH] refactor(core): migrate core module from UI-lib (DSP-1853) (#505) * refactor(core): migrates core module from UI-lib * refactor(core): removes core module and moves the files inside to more relevant directories --- src/app/main/declarations/dsp-api-tokens.ts | 8 + .../main/services/app-init.service.spec.ts | 167 +++++++++++ src/app/main/services/app-init.service.ts | 67 +++++ src/app/main/services/session.service.spec.ts | 271 ++++++++++++++++++ src/app/main/services/session.service.ts | 210 ++++++++++++++ 5 files changed, 723 insertions(+) create mode 100644 src/app/main/declarations/dsp-api-tokens.ts create mode 100644 src/app/main/services/app-init.service.spec.ts create mode 100644 src/app/main/services/app-init.service.ts create mode 100644 src/app/main/services/session.service.spec.ts create mode 100644 src/app/main/services/session.service.ts diff --git a/src/app/main/declarations/dsp-api-tokens.ts b/src/app/main/declarations/dsp-api-tokens.ts new file mode 100644 index 0000000000..dd898feb82 --- /dev/null +++ b/src/app/main/declarations/dsp-api-tokens.ts @@ -0,0 +1,8 @@ +import { InjectionToken } from '@angular/core'; +import { KnoraApiConfig, KnoraApiConnection } from '@dasch-swiss/dsp-js'; + +// config for dsp-js-lib (@dasch-swiss/dsp-js) config object +export const DspApiConfigToken = new InjectionToken('DSP api configuration'); + +// connection config for dsp-js-lib (@dasch-swiss/dsp-js) connection +export const DspApiConnectionToken = new InjectionToken('DSP api connection instance'); diff --git a/src/app/main/services/app-init.service.spec.ts b/src/app/main/services/app-init.service.spec.ts new file mode 100644 index 0000000000..abbf7404e3 --- /dev/null +++ b/src/app/main/services/app-init.service.spec.ts @@ -0,0 +1,167 @@ +import { TestBed } from '@angular/core/testing'; +import { AppInitService } from './app-init.service'; + +describe('TestService', () => { + let service: AppInitService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(AppInitService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should fetch the fully specified config file when method Init is called', async () => { + + const fetchSpy = spyOn(window, 'fetch').and.callFake( + path => Promise.resolve(new Response(JSON.stringify({ + apiProtocol: 'http', + apiHost: '0.0.0.0', + apiPort: 3333, + apiPath: 'mypath', + jsonWebToken: 'mytoken', + logErrors: true + }))) + ); + + await service.Init('config', { name: 'prod', production: true }); + + expect(service.dspApiConfig.apiProtocol).toEqual('http'); + expect(service.dspApiConfig.apiHost).toEqual('0.0.0.0'); + expect(service.dspApiConfig.apiPort).toEqual(3333); + expect(service.dspApiConfig.apiPath).toEqual('mypath'); + expect(service.dspApiConfig.jsonWebToken).toEqual('mytoken'); + expect(service.dspApiConfig.logErrors).toEqual(true); + + expect(service.config['apiProtocol']).toEqual('http'); + expect(service.config['apiHost']).toEqual('0.0.0.0'); + expect(service.config['apiPort']).toEqual(3333); + expect(service.config['apiPath']).toEqual('mypath'); + expect(service.config['jsonWebToken']).toEqual('mytoken'); + expect(service.config['logErrors']).toEqual(true); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledWith('config/config.prod.json'); + + }); + + it('should fetch the minimally specified config file when method Init is called', async () => { + + const fetchSpy = spyOn(window, 'fetch').and.callFake( + path => Promise.resolve(new Response(JSON.stringify({ + apiProtocol: 'http', + apiHost: '0.0.0.0' + }))) + ); + + await service.Init('config', { name: 'prod', production: true }); + + expect(service.dspApiConfig.apiProtocol).toEqual('http'); + expect(service.dspApiConfig.apiHost).toEqual('0.0.0.0'); + expect(service.dspApiConfig.apiPort).toEqual(null); + expect(service.dspApiConfig.apiPath).toEqual(''); + expect(service.dspApiConfig.jsonWebToken).toEqual(''); + expect(service.dspApiConfig.logErrors).toEqual(false); + + expect(service.config['apiProtocol']).toEqual('http'); + expect(service.config['apiHost']).toEqual('0.0.0.0'); + expect(service.config['apiPort']).toEqual(null); + expect(service.config['apiPath']).toEqual(''); + expect(service.config['jsonWebToken']).toEqual(''); + expect(service.config['logErrors']).toEqual(false); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledWith('config/config.prod.json'); + + }); + + it('should fetch the config file with additional options when method Init is called', async () => { + + const fetchSpy = spyOn(window, 'fetch').and.callFake( + path => Promise.resolve(new Response(JSON.stringify({ + apiProtocol: 'http', + apiHost: '0.0.0.0', + myOption: true + }))) + ); + + await service.Init('config', { name: 'prod', production: true }); + + expect(service.dspApiConfig.apiProtocol).toEqual('http'); + expect(service.dspApiConfig.apiHost).toEqual('0.0.0.0'); + expect(service.dspApiConfig.apiPort).toEqual(null); + expect(service.dspApiConfig.apiPath).toEqual(''); + expect(service.dspApiConfig.jsonWebToken).toEqual(''); + expect(service.dspApiConfig.logErrors).toEqual(false); + + expect(service.config['apiProtocol']).toEqual('http'); + expect(service.config['apiHost']).toEqual('0.0.0.0'); + expect(service.config['apiPort']).toEqual(null); + expect(service.config['apiPath']).toEqual(''); + expect(service.config['jsonWebToken']).toEqual(''); + expect(service.config['logErrors']).toEqual(false); + expect(service.config['myOption']).toEqual(true); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledWith('config/config.prod.json'); + + }); + + it('should throw an error if required members are missing on the config object', async () => { + + const fetchSpy = spyOn(window, 'fetch').and.callFake( + path => Promise.resolve(new Response(JSON.stringify({}))) + ); + + await expectAsync(service.Init('config', { + name: 'prod', + production: true + })) + .toBeRejectedWith(new Error('config misses required members: apiProtocol and/or apiHost')); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledWith('config/config.prod.json'); + + }); + + it('should throw an error if "apiProtocol" is missing on the config object', async () => { + + const fetchSpy = spyOn(window, 'fetch').and.callFake( + path => Promise.resolve(new Response(JSON.stringify({ + apiHost: '0.0.0.0' + }))) + ); + + await expectAsync(service.Init('config', { + name: 'prod', + production: true + })) + .toBeRejectedWith(new Error('config misses required members: apiProtocol and/or apiHost')); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledWith('config/config.prod.json'); + + }); + + it('should throw an error if "apiHost" is missing on the config object', async () => { + + const fetchSpy = spyOn(window, 'fetch').and.callFake( + path => Promise.resolve(new Response(JSON.stringify({ + apiProtocol: 'http' + }))) + ); + + await expectAsync(service.Init('config', { + name: 'prod', + production: true + })) + .toBeRejectedWith(new Error('config misses required members: apiProtocol and/or apiHost')); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledWith('config/config.prod.json'); + + }); + +}); diff --git a/src/app/main/services/app-init.service.ts b/src/app/main/services/app-init.service.ts new file mode 100644 index 0000000000..a19cc804f4 --- /dev/null +++ b/src/app/main/services/app-init.service.ts @@ -0,0 +1,67 @@ +import { Injectable } from '@angular/core'; +import { KnoraApiConfig } from '@dasch-swiss/dsp-js'; + +@Injectable({ + providedIn: 'root' +}) +export class AppInitService { + + dspApiConfig: KnoraApiConfig; + + config: object; + + constructor() { + } + + /** + * fetches and initialises the configuration. + * + * @param path path to the config file. + * @param env environment to be used (dev or prod). + */ + Init(path: string, env: { name: string; production: boolean }): Promise { + + return new Promise((resolve, reject) => { + fetch(`${path}/config.${env.name}.json`).then( + (response: Response) => response.json()).then(dspApiConfig => { + + // check for presence of apiProtocol and apiHost + if (typeof dspApiConfig.apiProtocol !== 'string' || typeof dspApiConfig.apiHost !== 'string') { + throw new Error('config misses required members: apiProtocol and/or apiHost'); + } + + // make input type safe + const apiPort = (typeof dspApiConfig.apiPort === 'number' ? dspApiConfig.apiPort : null); + const apiPath = (typeof dspApiConfig.apiPath === 'string' ? dspApiConfig.apiPath : ''); + const jsonWebToken = (typeof dspApiConfig.jsonWebToken === 'string' ? dspApiConfig.jsonWebToken : ''); + const logErrors = (typeof dspApiConfig.logErrors === 'boolean' ? dspApiConfig.logErrors : false); + + // init dsp-api configuration + this.dspApiConfig = new KnoraApiConfig( + dspApiConfig.apiProtocol, + dspApiConfig.apiHost, + apiPort, + apiPath, + jsonWebToken, + logErrors + ); + + // get all options from config + this.config = dspApiConfig; + + // set sanitized standard config options + this.config['apiProtocol'] = dspApiConfig.apiProtocol; + this.config['apiHost'] = dspApiConfig.apiHost; + this.config['apiPort'] = apiPort; + this.config['apiPath'] = apiPath; + this.config['jsonWebToken'] = jsonWebToken; + this.config['logErrors'] = logErrors; + + resolve(); + } + ).catch((err) => { + reject(err); + }); + }); + } +} diff --git a/src/app/main/services/session.service.spec.ts b/src/app/main/services/session.service.spec.ts new file mode 100644 index 0000000000..a3d6989b5a --- /dev/null +++ b/src/app/main/services/session.service.spec.ts @@ -0,0 +1,271 @@ +import { waitForAsync, TestBed } from '@angular/core/testing'; +import { + ApiResponseData, + AuthenticationEndpointV2, + CredentialsResponse, + MockUsers, + UsersEndpointAdmin +} from '@dasch-swiss/dsp-js'; +import { of } from 'rxjs'; +import { DspApiConnectionToken } from '../declarations/dsp-api-tokens'; +import { Session, SessionService } from './session.service'; + +describe('SessionService', () => { + let service: SessionService; + + beforeEach(waitForAsync(() => { + + const dspConnSpy = { + admin: { + usersEndpoint: jasmine.createSpyObj('usersEndpoint', ['getUser']) + }, + v2: { + auth: jasmine.createSpyObj('auth', ['checkCredentials', 'login']), + jsonWebToken: '' + }, + + }; + + TestBed.configureTestingModule({ + providers: [ + { + provide: DspApiConnectionToken, + useValue: dspConnSpy + } + ] + }); + service = TestBed.inject(SessionService); + })); + + // mock localStorage + beforeEach(() => { + let store = {}; + + spyOn(localStorage, 'getItem').and.callFake( + (key: string): string => store[key] || null + ); + spyOn(localStorage, 'removeItem').and.callFake( + (key: string): void => { + delete store[key]; + } + ); + spyOn(localStorage, 'setItem').and.callFake( + (key: string, value: string): void => { + store[key] = value; + } + ); + spyOn(localStorage, 'clear').and.callFake(() => { + store = {}; + }); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('Method setSession', () => { + + it('should store user information in local storage without a jwt', done => { + const dspSpy = TestBed.inject(DspApiConnectionToken); + + (dspSpy.admin.usersEndpoint as jasmine.SpyObj).getUser.and.callFake( + () => { + const loggedInUser = MockUsers.mockUser(); + return of(loggedInUser); + } + ); + + service.setSession(undefined, 'root', 'username').subscribe( () => { + const ls: Session = JSON.parse(localStorage.getItem('session')); + + expect(dspSpy.v2.jsonWebToken).toEqual(''); + + expect(ls.user.name).toEqual('root'); + expect(ls.user.jwt).toBeUndefined(); + expect(ls.user.lang).toEqual('de'); + expect(ls.user.sysAdmin).toEqual(false); + expect(ls.user.projectAdmin.length).toEqual(0); + + expect(dspSpy.admin.usersEndpoint.getUser).toHaveBeenCalledTimes(1); + expect(dspSpy.admin.usersEndpoint.getUser).toHaveBeenCalledWith('username', 'root'); + + expect(localStorage.setItem).toHaveBeenCalledTimes(1); + + done(); + }); + + }); + + it('should store user information in local storage with a jwt', done => { + const dspSpy = TestBed.inject(DspApiConnectionToken); + + (dspSpy.admin.usersEndpoint as jasmine.SpyObj).getUser.and.callFake( + () => { + const loggedInUser = MockUsers.mockUser(); + return of(loggedInUser); + } + ); + + service.setSession('mytoken', 'root', 'username').subscribe( () => { + const ls: Session = JSON.parse(localStorage.getItem('session')); + + expect(dspSpy.v2.jsonWebToken).toEqual('mytoken'); + + expect(ls.user.name).toEqual('root'); + expect(ls.user.jwt).toEqual('mytoken'); + expect(ls.user.lang).toEqual('de'); + expect(ls.user.sysAdmin).toEqual(false); + expect(ls.user.projectAdmin.length).toEqual(0); + + expect(dspSpy.admin.usersEndpoint.getUser).toHaveBeenCalledTimes(1); + expect(dspSpy.admin.usersEndpoint.getUser).toHaveBeenCalledWith('username', 'root'); + + expect(localStorage.setItem).toHaveBeenCalledTimes(1); + + done(); + }); + + }); + }); + + describe('Method getSession', () => { + + it('should get the session with user information', () => { + + const session: Session = { + id: 12345, + user: { + name: 'username', + jwt: 'myToken', + lang: 'en', + sysAdmin: false, + projectAdmin: [] + } + }; + + // store session in localStorage + localStorage.setItem('session', JSON.stringify(session)); + + const ls: Session = service.getSession(); + expect(ls.id).toEqual(12345); + expect(ls.user.name).toEqual('username'); + expect(ls.user.lang).toEqual('en'); + expect(ls.user.jwt).toEqual('myToken'); + expect(ls.user.sysAdmin).toEqual(false); + expect(ls.user.projectAdmin.length).toEqual(0); + + expect(localStorage.getItem).toHaveBeenCalledTimes(1); + + }); + }); + + describe('Method destroySession', () => { + + it('should destroy the session', () => { + + const session: Session = { + id: 12345, + user: { + name: 'username', + jwt: 'myToken', + lang: 'en', + sysAdmin: false, + projectAdmin: [] + } + }; + + // store session in localStorage + localStorage.setItem('session', JSON.stringify(session)); + + service.destroySession(); + const ls: Session = JSON.parse(localStorage.getItem('session')); + expect(ls).toEqual(null); + + expect(localStorage.removeItem).toHaveBeenCalledTimes(1); + }); + }); + + describe('Method isSessionValid', () => { + + it('should return false if there is no session', done => { + const dspSpy = TestBed.inject(DspApiConnectionToken); + + service.isSessionValid().subscribe( isValid => { + expect(isValid).toBeFalsy(); + expect(dspSpy.v2.jsonWebToken).toEqual(''); + + done(); + }); + }); + + it('should return true if session is still valid', done => { + + // mocks Date.now() so every call will return this timestamp + const baseTime = new Date(2020, 6, 7); + jasmine.clock().mockDate(baseTime); + + // create a session with the mocked date to ensure the session is valid + const session: Session = { + id: (Date.now() / 1000) - service.MAX_SESSION_TIME + 1, // still valid + user: { + name: 'username', + jwt: 'myToken', + lang: 'en', + sysAdmin: false, + projectAdmin: [] + } + }; + + // store session in localStorage + localStorage.setItem('session', JSON.stringify(session)); + + service.isSessionValid().subscribe( isValid => { + expect(isValid).toBeTruthy(); + + done(); + }); + }); + + it('should get credentials again if session has expired', done => { + + const dspSpy = TestBed.inject(DspApiConnectionToken); + + (dspSpy.v2.auth as jasmine.SpyObj).checkCredentials.and.callFake( + () => { + const response: CredentialsResponse = new CredentialsResponse(); + + response.message = 'credentials are OK'; + + return of(response as unknown as ApiResponseData); + } + ); + + const baseTime = new Date(2020, 6, 7); + jasmine.clock().mockDate(baseTime); + + // create a session with an expired id + const session: Session = { + id: (Date.now() / 1000) - service.MAX_SESSION_TIME, // expired + user: { + name: 'username', + jwt: 'myToken', + lang: 'en', + sysAdmin: false, + projectAdmin: [] + } + }; + + // store session in localStorage + localStorage.setItem('session', JSON.stringify(session)); + + service.isSessionValid().subscribe( isValid => { + expect(isValid).toBeFalsy(); + expect(dspSpy.v2.auth.checkCredentials).toHaveBeenCalledTimes(1); + + done(); + }); + + }); + }); + +}); diff --git a/src/app/main/services/session.service.ts b/src/app/main/services/session.service.ts new file mode 100644 index 0000000000..0d5c989115 --- /dev/null +++ b/src/app/main/services/session.service.ts @@ -0,0 +1,210 @@ +import { Inject, Injectable } from '@angular/core'; +import { + ApiResponseData, + ApiResponseError, + Constants, + CredentialsResponse, + KnoraApiConnection, + UserResponse +} from '@dasch-swiss/dsp-js'; +import { Observable, of } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { DspApiConnectionToken } from '../declarations/dsp-api-tokens'; + +/** + * information about the current user + */ +interface CurrentUser { + // username + name: string; + + // json web token + jwt?: string; + + // default language for ui + lang: string; + + // is system admin? + sysAdmin: boolean; + + // list of project shortcodes where the user is project admin + projectAdmin: string[]; +} + +/** + * session with id (= login timestamp) and inforamtion about logged-in user + */ +export interface Session { + id: number; + user: CurrentUser; +} + +@Injectable({ + providedIn: 'root' +}) +export class SessionService { + + /** + * max session time in milliseconds + * default value (24h): 86400000 + * + */ + readonly MAX_SESSION_TIME: number = 86400000; // 1d = 24 * 60 * 60 * 1000 + + + constructor( + @Inject(DspApiConnectionToken) private _dspApiConnection: KnoraApiConnection + ) { } + + /** + * get session information from localstorage + */ + getSession(): Session | null { + return JSON.parse(localStorage.getItem('session')); + } + + /** + * set session by using the json web token (jwt) and the user object; + * it will be used in the login process + * + * @param jwt Json Web Token + * @param identifier email address or username + * @param identifierType 'email' or 'username' + */ + setSession(jwt: string, identifier: string, identifierType: 'email' | 'username'): Observable { + + this._dspApiConnection.v2.jsonWebToken = (jwt ? jwt : ''); + + // get user information + return this._dspApiConnection.admin.usersEndpoint.getUser(identifierType, identifier).pipe( + map((response: ApiResponseData | ApiResponseError) => { + this._storeSessionInLocalStorage(response, jwt); + // return type is void + return; + }) + ); + } + + /** + * validate intern session and check knora api credentials if necessary. + * If a json web token exists, it doesn't mean that the knora api credentials are still valid. + * + */ + isSessionValid(): Observable { + // mix of checks with session.validation and this.authenticate + const session = JSON.parse(localStorage.getItem('session')); + + const tsNow: number = this._setTimestamp(); + + if (session) { + + this._dspApiConnection.v2.jsonWebToken = session.user.jwt; + + // check if the session is still valid: + if (session.id + this.MAX_SESSION_TIME <= tsNow) { + // the internal (dsp-ui) session has expired + // check if the api credentials are still valid + + return this._dspApiConnection.v2.auth.checkCredentials().pipe( + map((credentials: ApiResponseData | ApiResponseError) => { + const idUpdated = this._updateSessionId(credentials, session, tsNow); + return idUpdated; + } + ) + ); + + } else { + // the internal (dsp-ui) session is still valid + return of(true); + } + } else { + // no session found; update knora api connection with empty jwt + this._dspApiConnection.v2.jsonWebToken = ''; + return of(false); + } + } + + /** + * destroy session by removing the session from local storage + * + */ + destroySession() { + localStorage.removeItem('session'); + } + + /** + * returns a timestamp represented in seconds + * + */ + private _setTimestamp(): number { + return Math.floor(Date.now() / 1000); + } + + /** + * store session in local storage + * @param response response from getUser method call + * @param jwt JSON web token string + */ + private _storeSessionInLocalStorage(response: any, jwt: string) { + let session: Session; + + if (response instanceof ApiResponseData) { + let sysAdmin = false; + const projectAdmin: string[] = []; + + // get permission information: a) is user sysadmin? b) get list of project iri's where user is project admin + const groupsPerProjectKeys: string[] = Object.keys(response.body.user.permissions.groupsPerProject); + + for (const key of groupsPerProjectKeys) { + if (key === Constants.SystemProjectIRI) { + sysAdmin = response.body.user.permissions.groupsPerProject[key].indexOf(Constants.SystemAdminGroupIRI) > -1; + } + + if (response.body.user.permissions.groupsPerProject[key].indexOf(Constants.ProjectAdminGroupIRI) > -1) { + projectAdmin.push(key); + } + } + + // store session information in browser's localstorage + // tODO: jwt will be removed, when we have a better cookie solution (DSP-261) + // --> no it can't be removed because the token is needed in sipi upload: + // https://docs.dasch.swiss/DSP-API/03-apis/api-v2/editing-values/#upload-files-to-sipi + session = { + id: this._setTimestamp(), + user: { + name: response.body.user.username, + jwt: jwt, + lang: response.body.user.lang, + sysAdmin: sysAdmin, + projectAdmin: projectAdmin + } + }; + + // update localStorage + localStorage.setItem('session', JSON.stringify(session)); + } else { + localStorage.removeItem('session'); + console.error(response); + } + } + + /** + * updates the id of the current session in the local storage + * @param credentials response from getCredentials method call + * @param session the current session + * @param timestamp timestamp in form of a number + */ + private _updateSessionId(credentials: any, session: Session, timestamp: number): boolean { + if (credentials instanceof ApiResponseData) { + // the knora api credentials are still valid + // update the session.id + session.id = timestamp; + localStorage.setItem('session', JSON.stringify(session)); + return true; + } else { + // a user is not authenticated anymore! + this.destroySession(); + return false; + } + } +}