From 2dad18cca64e2d04d23b579fa4ddd76a00968109 Mon Sep 17 00:00:00 2001 From: tenretC Date: Tue, 10 Apr 2018 14:02:13 +0200 Subject: [PATCH] feat(session): add actions, entities, reducers and services for session module --- packages/stark-core/package.json | 3 + .../stark-core/src/common/routes/index.ts | 4 + .../stark-core/src/common/routes/routes.ts | 93 ++ .../common/store/starkCoreApplicationState.ts | 10 +- .../application/app-config.entity.intf.ts | 30 +- .../entities/application/app-config.entity.ts | 40 +- .../http-abstract-repository.spec.ts | 4 +- .../src/http/services/http.service.spec.ts | 4 +- packages/stark-core/src/logging/index.ts | 3 +- .../stark-core/src/logging/testing/index.ts | 1 + .../stark-core/src/session/actions/index.ts | 3 + .../src/session/actions/session.actions.ts | 103 ++ .../stark-core/src/session/entities/index.ts | 5 + .../entities/pre-authentication.intf.ts | 11 + .../session/entities/session.entity.intf.ts | 15 + .../src/session/entities/session.entity.ts | 17 + packages/stark-core/src/session/index.ts | 2 +- .../stark-core/src/session/reducers/index.ts | 3 + .../session/reducers/session.reducer.spec.ts | 78 ++ .../src/session/reducers/session.reducer.ts | 26 + .../session/{service => services}/index.ts | 1 + .../session.service.intf.ts | 2 +- .../session/services/session.service.spec.ts | 886 ++++++++++++++++++ .../src/session/services/session.service.ts | 411 ++++++++ packages/stark-core/src/session/user/index.ts | 5 - .../session/user/user-profile.entity.intf.ts | 17 - .../user/user-security-profile.entity.intf.ts | 6 - .../src/session/user/user.entity.ts | 89 -- .../test/unit-testing/unit-testing-utils.ts | 35 +- .../user/entities/user-profile.entity.intf.ts | 2 +- .../src/user/entities/user.entity.ts | 15 +- packages/stark-core/src/user/index.ts | 1 + packages/stark-core/tsconfig.json | 5 +- 33 files changed, 1752 insertions(+), 178 deletions(-) create mode 100644 packages/stark-core/src/common/routes/index.ts create mode 100644 packages/stark-core/src/common/routes/routes.ts create mode 100644 packages/stark-core/src/logging/testing/index.ts create mode 100644 packages/stark-core/src/session/actions/index.ts create mode 100644 packages/stark-core/src/session/actions/session.actions.ts create mode 100644 packages/stark-core/src/session/entities/index.ts create mode 100644 packages/stark-core/src/session/entities/pre-authentication.intf.ts create mode 100644 packages/stark-core/src/session/entities/session.entity.intf.ts create mode 100644 packages/stark-core/src/session/entities/session.entity.ts create mode 100644 packages/stark-core/src/session/reducers/index.ts create mode 100644 packages/stark-core/src/session/reducers/session.reducer.spec.ts create mode 100644 packages/stark-core/src/session/reducers/session.reducer.ts rename packages/stark-core/src/session/{service => services}/index.ts (61%) rename packages/stark-core/src/session/{service => services}/session.service.intf.ts (96%) create mode 100644 packages/stark-core/src/session/services/session.service.spec.ts create mode 100644 packages/stark-core/src/session/services/session.service.ts delete mode 100644 packages/stark-core/src/session/user/index.ts delete mode 100644 packages/stark-core/src/session/user/user-profile.entity.intf.ts delete mode 100644 packages/stark-core/src/session/user/user-security-profile.entity.intf.ts delete mode 100644 packages/stark-core/src/session/user/user.entity.ts create mode 100644 packages/stark-core/src/user/index.ts diff --git a/packages/stark-core/package.json b/packages/stark-core/package.json index 0f9b71266b..e1120b19e0 100644 --- a/packages/stark-core/package.json +++ b/packages/stark-core/package.json @@ -26,6 +26,9 @@ "npm": ">=5.3.0" }, "dependencies": { + "@ng-idle/core": "2.0.0-beta.15", + "@ng-idle/keepalive": "2.0.0-beta.15", + "@ngx-translate/core": "9.1.1", "@ngrx/store": "5.2.0", "@types/core-js": "0.9.46", "@types/jasmine": "2.8.6", diff --git a/packages/stark-core/src/common/routes/index.ts b/packages/stark-core/src/common/routes/index.ts new file mode 100644 index 0000000000..b38fdcd0fb --- /dev/null +++ b/packages/stark-core/src/common/routes/index.ts @@ -0,0 +1,4 @@ + +// TODO only exports the public api stuffs +export * from "./routes"; + diff --git a/packages/stark-core/src/common/routes/routes.ts b/packages/stark-core/src/common/routes/routes.ts new file mode 100644 index 0000000000..922d6c5de8 --- /dev/null +++ b/packages/stark-core/src/common/routes/routes.ts @@ -0,0 +1,93 @@ +"use strict"; + +import { Location } from "@angular/common"; +import { StarkRoutingService, starkRoutingServiceName, StarkStateConfigWithParams } from "../../routing/services/index"; +import { StatesModule } from "@uirouter/angular"; + +export const starkAppInitStateName: string = "starkAppInit"; +export const starkAppExitStateName: string = "starkAppExit"; + +export const starkLoginStateName: string = "starkAppInit.starkLogin"; +export const starkLoginStateUrl: string = "/starkLogin"; + +export const starkPreloadingStateName: string = "starkAppInit.starkPreloading"; +export const starkPreloadingStateUrl: string = "/starkPreloading"; + +export const starkSessionExpiredStateName: string = "starkAppExit.starkSessionExpired"; +export const starkSessionExpiredStateUrl: string = "/starkSessionExpired"; + +export const starkSessionLogoutStateName: string = "starkAppExit.starkSessionLogout"; +export const starkSessionLogoutStateUrl: string = "/starkSessionLogout"; + +// FIXME Fix states declaration +export const starkCoreRouteConfig: StatesModule = { + states: [ + { + name: starkAppInitStateName, // parent state for any initialization state (used to show/hide the main app component) + abstract: true, + resolve: { + targetRoute: ["$location", starkRoutingServiceName, ($location: Location, routingService: StarkRoutingService) => { + // get the path of the current URL in the browser's navigation bar + const targetUrlPath: string = $location.path(); + const targetRoute: StarkStateConfigWithParams | undefined = routingService.getStateConfigByUrlPath(targetUrlPath); + + // skip any init/exit state + const initOrExitStateRegex: RegExp = new RegExp("(" + starkAppInitStateName + "|" + starkAppExitStateName + ")"); + + if (targetRoute) { + if ((targetRoute.state.$$state)().parent) { + if (!(targetRoute.state.$$state)().parent.name.match(initOrExitStateRegex)) { + return targetRoute; + } else { + return undefined; + } + } else { + return targetRoute; + } + } else { + return undefined; + } + }], + targetState: ["targetRoute", (targetRoute: StarkStateConfigWithParams) => { + return (typeof targetRoute !== "undefined") ? targetRoute.state.name : undefined; + }], + targetStateParams: ["targetRoute", (targetRoute: StarkStateConfigWithParams) => { + return (typeof targetRoute !== "undefined") ? targetRoute.paramValues : undefined; + }] + } + }, + { + name: starkAppExitStateName, // parent state for any exit state (used to show/hide the main app component) + abstract: true + }, + { + name: starkLoginStateName, // the parent is defined in the state's name (contains a dot) + url: starkLoginStateUrl, + views: { + // TODO: how to use a constant instead of a hard-coded string? (without loading the component file to avoid circular dependencies!) + "initOrExit@": "starkLoginPage" + } + }, + { + name: starkPreloadingStateName, // the parent is defined in the state's name (contains a dot) + url: starkPreloadingStateUrl, + views: { + "initOrExit@": "starkPreloadingPage" + } + }, + { + name: starkSessionExpiredStateName, // the parent is defined in the state's name (contains a dot) + url: starkSessionExpiredStateUrl, + views: { + "initOrExit@": "starkSessionExpiredPage" + } + }, + { + name: starkSessionLogoutStateName, // the parent is defined in the state's name (contains a dot) + url: starkSessionLogoutStateUrl, + views: { + "initOrExit@": "starkSessionLogoutPage" + } + } + ] +}; diff --git a/packages/stark-core/src/common/store/starkCoreApplicationState.ts b/packages/stark-core/src/common/store/starkCoreApplicationState.ts index d83589df85..8df843b69a 100644 --- a/packages/stark-core/src/common/store/starkCoreApplicationState.ts +++ b/packages/stark-core/src/common/store/starkCoreApplicationState.ts @@ -2,11 +2,11 @@ * Interface defining the shape of the application state of Stark Core (i.e., what's stored in Redux by Stark) */ import { StarkLogging } from "../../logging/entities/index"; -// import {StarkSession} from "../../session/entities"; +import { StarkSession } from "../../session/entities"; // import {StarkSettings} from "../../settings/entities"; export interface StarkCoreApplicationState - extends StarkLoggingApplicationState /*, StarkSessionApplicationState, StarkSettingsApplicationState*/ { + extends StarkLoggingApplicationState , StarkSessionApplicationState/*, StarkSettingsApplicationState*/ { // starkApplicationMetadata: StarkApplicationMetadata; // starkLogging: StarkLogging; // starkSession: StarkSession; @@ -17,9 +17,9 @@ export interface StarkLoggingApplicationState { starkLogging: StarkLogging; } -// export interface StarkSessionApplicationState { -// starkSession: StarkSession; -// } +export interface StarkSessionApplicationState { + starkSession: StarkSession; +} // export interface StarkSettingsApplicationState { // starkSettings: StarkSettings; // } diff --git a/packages/stark-core/src/configuration/entities/application/app-config.entity.intf.ts b/packages/stark-core/src/configuration/entities/application/app-config.entity.intf.ts index 6bfb15b1eb..1f9e9ab2c6 100644 --- a/packages/stark-core/src/configuration/entities/application/app-config.entity.intf.ts +++ b/packages/stark-core/src/configuration/entities/application/app-config.entity.intf.ts @@ -60,11 +60,23 @@ export interface StarkApplicationConfig { */ loggingFlushResourceName?: string; + /** + * Option to disable the logging flush if it not needed for the application. + * Default: false + */ + loggingFlushDisabled?: boolean; + /** * Enable router logging */ routerLoggingEnabled: boolean; + /** + * Enable router visualizer. Only in DEV (the router visualizer is not available in PROD) + * Default: false + */ + routerVisualizerEnabled: boolean; + /** * Timeout period before the session is ended if no user interaction occurs */ @@ -86,6 +98,12 @@ export interface StarkApplicationConfig { */ keepAliveUrl?: string; + /** + * Option to disable the keepAlive if it not needed for the application. + * Default: false + */ + keepAliveDisabled?: boolean; + /** * Url to be navigated to logout the user */ @@ -108,18 +126,6 @@ export interface StarkApplicationConfig { */ publicApp: boolean; - /** - * Option to disable the logging flush if it not needed for the application. - * default: false - */ - loggingFlushDisabled?: boolean; - - /** - * Option to disable the keepAlive if it not needed for the application. - * default: false - */ - keepAliveDisabled?: boolean; - /** * Backends that the application will interact to. */ diff --git a/packages/stark-core/src/configuration/entities/application/app-config.entity.ts b/packages/stark-core/src/configuration/entities/application/app-config.entity.ts index 77817dc2c2..4c7f559806 100644 --- a/packages/stark-core/src/configuration/entities/application/app-config.entity.ts +++ b/packages/stark-core/src/configuration/entities/application/app-config.entity.ts @@ -1,12 +1,13 @@ "use strict"; -import { IsBoolean, IsDefined, IsNotEmpty, IsNumber, IsString, IsUrl, Matches, Min, ValidateIf, validateSync } from "class-validator"; +import { IsBoolean, IsDefined, IsNotEmpty, IsPositive, IsString, IsUrl, Matches, Min, ValidateIf, validateSync } from "class-validator"; import { autoserialize, autoserializeAs } from "cerialize"; import { StarkApplicationConfig } from "./app-config.entity.intf"; import { StarkBackend, StarkBackendImpl } from "../../../http/entities/backend/index"; import { stringMap } from "../../../serialization/index"; import { StarkValidationErrorsUtil } from "../../../util/index"; import { StarkValidationMethodsUtil } from "../../../util/validation-methods.util"; +// FIXME Implement the following decorators as before // import {StarkMapIsValid, StarkMapNotEmpty} from "../../../validation/decorators"; export class StarkApplicationConfigImpl implements StarkApplicationConfig { @@ -41,8 +42,8 @@ export class StarkApplicationConfigImpl implements StarkApplicationConfig { public debugLoggingEnabled: boolean; @ValidateIf(StarkApplicationConfigImpl.validateIfLoggingFlushEnabled) - @IsNumber() - @Min(1) + @IsPositive() + @Min(2) @autoserialize public loggingFlushPersistSize?: number; @@ -58,11 +59,20 @@ export class StarkApplicationConfigImpl implements StarkApplicationConfig { @autoserialize public loggingFlushResourceName?: string; + @ValidateIf(StarkValidationMethodsUtil.validateIfDefined) + @IsBoolean() + @autoserialize + public loggingFlushDisabled?: boolean; + @IsDefined() @IsBoolean() @autoserialize public routerLoggingEnabled: boolean; + @IsBoolean() + @autoserialize + public routerVisualizerEnabled: boolean; + @IsNotEmpty() @IsString() @Matches(/^[a-z]{2}$/) @@ -70,16 +80,16 @@ export class StarkApplicationConfigImpl implements StarkApplicationConfig { public defaultLanguage: string; @IsDefined() - @IsNumber() + @IsPositive() @autoserialize public sessionTimeout: number; - @IsNumber() + @IsPositive() @autoserialize public sessionTimeoutWarningPeriod: number; @ValidateIf(StarkApplicationConfigImpl.validateIfKeepAliveEnabled) - @IsNumber() + @IsPositive() @autoserialize public keepAliveInterval?: number; @@ -88,6 +98,11 @@ export class StarkApplicationConfigImpl implements StarkApplicationConfig { @autoserialize public keepAliveUrl?: string; + @ValidateIf(StarkValidationMethodsUtil.validateIfDefined) + @IsBoolean() + @autoserialize + public keepAliveDisabled?: boolean; + @IsDefined() @IsUrl() @autoserialize @@ -103,15 +118,6 @@ export class StarkApplicationConfigImpl implements StarkApplicationConfig { @autoserialize public publicApp: boolean; - @ValidateIf(StarkValidationMethodsUtil.validateIfDefined) - @IsBoolean() - @autoserialize - public loggingFlushDisabled?: boolean; - - @ValidateIf(StarkValidationMethodsUtil.validateIfDefined) - @IsBoolean() - @autoserialize - public keepAliveDisabled?: boolean; //FIXME Import StarkMapIsValid & StarkMapNotEmpty from validation/decorators // @StarkMapNotEmpty() // @StarkMapIsValid() @@ -127,9 +133,13 @@ export class StarkApplicationConfigImpl implements StarkApplicationConfig { this.loggingFlushPersistSize = 15; // } + this.loggingFlushDisabled = false; this.loggingFlushResourceName = "logging"; + this.sessionTimeout = 900; // default timeout of the F5 this.sessionTimeoutWarningPeriod = 15; + this.keepAliveDisabled = false; this.keepAliveInterval = 15; + this.routerVisualizerEnabled = false; } public addBackend(backend: StarkBackend): void { diff --git a/packages/stark-core/src/http/repository/http-abstract-repository.spec.ts b/packages/stark-core/src/http/repository/http-abstract-repository.spec.ts index 4e08ae6c49..79cc664e9e 100644 --- a/packages/stark-core/src/http/repository/http-abstract-repository.spec.ts +++ b/packages/stark-core/src/http/repository/http-abstract-repository.spec.ts @@ -12,7 +12,7 @@ import { StarkSingleItemResponseWrapper } from "../entities/index"; import { StarkHttpService } from "../services/http.service.intf"; -import { StarkLoggingService } from "../../logging"; +import { StarkLoggingService, MockStarkLoggingService } from "../../logging/index"; import { UnitTestingUtils } from "../../test/unit-testing/index"; import { StarkHttpRequestBuilderImpl } from "../builder/index"; import { StarkHttpSerializer, StarkHttpSerializerImpl } from "../serializer/index"; @@ -32,7 +32,7 @@ describe("Repository: AbstractStarkHttpRepository", () => { beforeEach(() => { mockStarkHttpService = UnitTestingUtils.getMockedHttpService(); - mockLogger = UnitTestingUtils.getMockedLoggingService(); + mockLogger = new MockStarkLoggingService(); mockBackend = createSpyObj("backend", ["url"]); mockResourcePath = "mock"; mockResource = new MockResource(resourceUuid); diff --git a/packages/stark-core/src/http/services/http.service.spec.ts b/packages/stark-core/src/http/services/http.service.spec.ts index 9970264105..5ad21cc255 100644 --- a/packages/stark-core/src/http/services/http.service.spec.ts +++ b/packages/stark-core/src/http/services/http.service.spec.ts @@ -35,7 +35,7 @@ import { import { StarkHttpHeaders, StarkSortOrder } from "../constants/index"; import { StarkHttpStatusCodes } from "../enumerators/index"; -import { StarkLoggingService } from "../../logging/index"; +import { StarkLoggingService, MockStarkLoggingService } from "../../logging/index"; import { UnitTestingUtils } from "../../test/unit-testing/index"; import { StarkHttpSerializer, StarkHttpSerializerImpl } from "../serializer/index"; import { StarkSessionService } from "../../session/index"; @@ -150,7 +150,7 @@ describe("Service: StarkHttpService", () => { metadata: mockResourceMetadata }; // Make sure that a correlation identifier is defined correctly on the logger - loggerMock = UnitTestingUtils.getMockedLoggingService(mockCorrelationId); + loggerMock = new MockStarkLoggingService(mockCorrelationId); mockSessionService = UnitTestingUtils.getMockedSessionService(); httpMock = createSpyObj("HttpClient", ["get", "put", "post", "delete"]); diff --git a/packages/stark-core/src/logging/index.ts b/packages/stark-core/src/logging/index.ts index 4f821f8c0c..ccb73ffdab 100644 --- a/packages/stark-core/src/logging/index.ts +++ b/packages/stark-core/src/logging/index.ts @@ -4,4 +4,5 @@ export * from "./actions/index"; export * from "./entities/index"; export * from "./reducers/index"; export * from "./services/index"; -export * from "./logging.module"; \ No newline at end of file +export * from "./testing/index"; +export * from "./logging.module"; diff --git a/packages/stark-core/src/logging/testing/index.ts b/packages/stark-core/src/logging/testing/index.ts new file mode 100644 index 0000000000..25751eba2d --- /dev/null +++ b/packages/stark-core/src/logging/testing/index.ts @@ -0,0 +1 @@ +export * from "./logging.mock"; diff --git a/packages/stark-core/src/session/actions/index.ts b/packages/stark-core/src/session/actions/index.ts new file mode 100644 index 0000000000..6358462c65 --- /dev/null +++ b/packages/stark-core/src/session/actions/index.ts @@ -0,0 +1,3 @@ +"use strict"; + +export * from "./session.actions"; diff --git a/packages/stark-core/src/session/actions/session.actions.ts b/packages/stark-core/src/session/actions/session.actions.ts new file mode 100644 index 0000000000..f360c2fd6e --- /dev/null +++ b/packages/stark-core/src/session/actions/session.actions.ts @@ -0,0 +1,103 @@ +"use strict"; + +import { Action } from "@ngrx/store"; +import { StarkUser } from "../../user/entities/index"; + +export enum StarkSessionActionTypes { + + CHANGE_LANGUAGE = "CHANGE_LANGUAGE", + CHANGE_LANGUAGE_SUCCESS = "CHANGE_LANGUAGE_SUCCESS", + CHANGE_LANGUAGE_FAILURE = "CHANGE_LANGUAGE_FAILURE", + INITIALIZE_SESSION = "INITIALIZE_SESSION", + INITIALIZE_SESSION_SUCCESS = "INITIALIZE_SESSION_SUCCESS", + DESTROY_SESSION = "DESTROY_SESSION", + DESTROY_SESSION_SUCCESS = "DESTROY_SESSION_SUCCESS", + SESSION_TIMEOUT_COUNTDOWN_START = "SESSION_TIMEOUT_COUNTDOWN_START", + SESSION_TIMEOUT_COUNTDOWN_STOP = "SESSION_TIMEOUT_COUNTDOWN_STOP", + SESSION_TIMEOUT_COUNTDOWN_FINISH = "SESSION_TIMEOUT_COUNTDOWN_FINISH", + SESSION_LOGOUT = "SESSION_LOGOUT", + USER_ACTIVITY_TRACKING_PAUSE = "USER_ACTIVITY_TRACKING_PAUSE", + USER_ACTIVITY_TRACKING_RESUME = "USER_ACTIVITY_TRACKING_RESUME", +} + +export class ChangeLanguage implements Action { + public readonly type:"CHANGE_LANGUAGE" = StarkSessionActionTypes.CHANGE_LANGUAGE; + + public constructor(public languageId: string) { + } +} + +export class ChangeLanguageSuccess implements Action { + public readonly type:"CHANGE_LANGUAGE_SUCCESS" = StarkSessionActionTypes.CHANGE_LANGUAGE_SUCCESS; + + public constructor(public languageId: string) { + } +} + +export class ChangeLanguageFailure implements Action { + public readonly type:"CHANGE_LANGUAGE_FAILURE" = StarkSessionActionTypes.CHANGE_LANGUAGE_FAILURE; + + public constructor(public error: any) { + } +} + +export class InitializeSession implements Action { + public readonly type:"INITIALIZE_SESSION"= StarkSessionActionTypes.INITIALIZE_SESSION; + + public constructor(public user: StarkUser) { + } +} + +export class InitializeSessionSuccess implements Action { + public readonly type:"INITIALIZE_SESSION_SUCCESS" = StarkSessionActionTypes.INITIALIZE_SESSION_SUCCESS; +} + +export class DestroySession implements Action { + public readonly type:"DESTROY_SESSION" = StarkSessionActionTypes.DESTROY_SESSION; +} + +export class DestroySessionSuccess implements Action { + public readonly type:"DESTROY_SESSION_SUCCESS" = StarkSessionActionTypes.DESTROY_SESSION_SUCCESS; +} + +export class SessionTimeoutCountdownStart implements Action { + public readonly type:"SESSION_TIMEOUT_COUNTDOWN_START" = StarkSessionActionTypes.SESSION_TIMEOUT_COUNTDOWN_START; + + public constructor(public countdown: number) { + } +} + +export class SessionTimeoutCountdownStop implements Action { + public readonly type:"SESSION_TIMEOUT_COUNTDOWN_STOP" = StarkSessionActionTypes.SESSION_TIMEOUT_COUNTDOWN_STOP; +} + +export class SessionTimeoutCountdownFinish implements Action { + public readonly type:"SESSION_TIMEOUT_COUNTDOWN_FINISH" = StarkSessionActionTypes.SESSION_TIMEOUT_COUNTDOWN_FINISH; +} + +export class SessionLogout implements Action { + public readonly type:"SESSION_LOGOUT" = StarkSessionActionTypes.SESSION_LOGOUT; +} + +export class UserActivityTrackingPause implements Action { + public readonly type:"USER_ACTIVITY_TRACKING_PAUSE" = StarkSessionActionTypes.USER_ACTIVITY_TRACKING_PAUSE; +} + +export class UserActivityTrackingResume implements Action { + public readonly type:"USER_ACTIVITY_TRACKING_RESUME" = StarkSessionActionTypes.USER_ACTIVITY_TRACKING_RESUME; +} + +export type StarkSessionActions = + ChangeLanguage + | ChangeLanguageSuccess + | ChangeLanguageFailure + | InitializeSession + | InitializeSessionSuccess + | DestroySession + | DestroySessionSuccess + | SessionTimeoutCountdownStart + | SessionTimeoutCountdownStop + | SessionTimeoutCountdownFinish + | SessionLogout + | UserActivityTrackingPause + | UserActivityTrackingResume; diff --git a/packages/stark-core/src/session/entities/index.ts b/packages/stark-core/src/session/entities/index.ts new file mode 100644 index 0000000000..86753a1c0c --- /dev/null +++ b/packages/stark-core/src/session/entities/index.ts @@ -0,0 +1,5 @@ +"use strict"; + +export * from "./pre-authentication.intf"; +export * from "./session.entity"; +export * from "./session.entity.intf"; diff --git a/packages/stark-core/src/session/entities/pre-authentication.intf.ts b/packages/stark-core/src/session/entities/pre-authentication.intf.ts new file mode 100644 index 0000000000..08ab66eeab --- /dev/null +++ b/packages/stark-core/src/session/entities/pre-authentication.intf.ts @@ -0,0 +1,11 @@ +"use strict"; + +export interface StarkPreAuthentication { + roleSeparator: string; + descriptionSeparator: string; + defaults: { + language: string; + workpost: string; + referenceNumber: string; + }; +} diff --git a/packages/stark-core/src/session/entities/session.entity.intf.ts b/packages/stark-core/src/session/entities/session.entity.intf.ts new file mode 100644 index 0000000000..2584c0bd6e --- /dev/null +++ b/packages/stark-core/src/session/entities/session.entity.intf.ts @@ -0,0 +1,15 @@ +"use strict"; + +import { StarkUser } from "../../user/entities/index"; + +export interface StarkSession { + /** + * The current session's language + */ + currentLanguage: string; + + /** + * The current user logged in the application (if there is one logged in), otherwise it will be undefined + */ + user: StarkUser | undefined; +} diff --git a/packages/stark-core/src/session/entities/session.entity.ts b/packages/stark-core/src/session/entities/session.entity.ts new file mode 100644 index 0000000000..285fd48e25 --- /dev/null +++ b/packages/stark-core/src/session/entities/session.entity.ts @@ -0,0 +1,17 @@ +"use strict"; + +import { StarkUser } from "../../user/entities/index"; +import { StarkSession } from "./session.entity.intf"; + +export class StarkSessionImpl implements StarkSession { + public currentLanguage: string; + + public user: StarkUser | undefined; + + //public condensedModeEnabled:boolean; + //public browser:string; + //public browserVersion:string; + //public device:string; + //public loginTimestamp:string; + //public lastSessionRefreshTimestamp:string; +} diff --git a/packages/stark-core/src/session/index.ts b/packages/stark-core/src/session/index.ts index b2f5d16850..ee83a4a3be 100644 --- a/packages/stark-core/src/session/index.ts +++ b/packages/stark-core/src/session/index.ts @@ -1,3 +1,3 @@ "use strict"; -export * from "./service/index"; +export * from "./services/index"; diff --git a/packages/stark-core/src/session/reducers/index.ts b/packages/stark-core/src/session/reducers/index.ts new file mode 100644 index 0000000000..215a73dae8 --- /dev/null +++ b/packages/stark-core/src/session/reducers/index.ts @@ -0,0 +1,3 @@ +"use strict"; + +export * from "./session.reducer"; diff --git a/packages/stark-core/src/session/reducers/session.reducer.spec.ts b/packages/stark-core/src/session/reducers/session.reducer.spec.ts new file mode 100644 index 0000000000..06d9a10cd3 --- /dev/null +++ b/packages/stark-core/src/session/reducers/session.reducer.spec.ts @@ -0,0 +1,78 @@ +"use strict"; + +import { StarkSession } from "../entities/index"; +import { StarkUser } from "../../user/entities/index"; +import { sessionReducer } from "./session.reducer"; +import { ChangeLanguageSuccess,DestroySession, InitializeSession } from "../actions/index"; + +const deepFreeze:Function = require("deep-freeze-strict"); + +describe("Reducer: SessionReducer", () => { + let session: StarkSession; + let user: StarkUser; + + beforeEach(() => { + user = {uuid: "694", username: "sgonzales", firstName: "s", lastName: "gonzales", language:"FR", roles:[]}; + session = {currentLanguage: "FR", user: user}; + }); + + describe("on CHANGE_LANGUAGE_SUCCESS", () => { + + it("should set the session language when state given", () => { + // create the initial state object + const initialState: StarkSession = session; + deepFreeze(initialState); //Enforce immutability + + // // Send the CHANGE_LANGUAGE_SUCCESS action to the sessionReducer + const changedState: StarkSession = sessionReducer(initialState, new ChangeLanguageSuccess("NL")); + expect(changedState.currentLanguage).toBe("NL"); + }); + + it("should set the session language even if the state is not defined", () => { + // // Send the CHANGE_LANGUAGE_SUCCESS action to the sessionReducer + const changedState: StarkSession = sessionReducer(undefined, new ChangeLanguageSuccess("NL")); + expect(changedState).toBeDefined(); + expect(changedState.currentLanguage).toBe("NL"); + }); + }); + + describe("on INITIALIZE_SESSION", () => { + + it("should set the session user when state given", () => { + // create the initial state object + const initialState: StarkSession = session; + deepFreeze(initialState); //Enforce immutability + + // // Send the INITIALIZE_SESSION action to the sessionReducer + const changedState: StarkSession = sessionReducer(initialState, new InitializeSession(user)); + expect(changedState.user).toBe(user); + }); + + it("should set the session user even if the state is not defined", () => { + // // Send the INITIALIZE_SESSION action to the sessionReducer + const changedState: StarkSession = sessionReducer(undefined, new InitializeSession(user)); + expect(changedState).toBeDefined(); + expect(changedState.user).toBe(user); + }); + }); + + describe("on DESTROY_SESSION", () => { + + it("should remove the session user when state given", () => { + // create the initial state object + const initialState: StarkSession = {currentLanguage: "FR", user: user}; + deepFreeze(initialState); //Enforce immutability + + // // Send the EXPIRE_SESSION action to the sessionReducer + const changedState: StarkSession = sessionReducer(initialState, new DestroySession()); + expect(changedState.user).toBeUndefined(); + }); + + it("should set the user as undefined even if the state is not defined", () => { + // // Send the EXPIRE_SESSION action to the sessionReducer + const changedState: StarkSession = sessionReducer(undefined, new DestroySession()); + expect(changedState).toBeDefined(); + expect(changedState.user).toBeUndefined(); + }); + }); +}); diff --git a/packages/stark-core/src/session/reducers/session.reducer.ts b/packages/stark-core/src/session/reducers/session.reducer.ts new file mode 100644 index 0000000000..b08eb1781a --- /dev/null +++ b/packages/stark-core/src/session/reducers/session.reducer.ts @@ -0,0 +1,26 @@ +"use strict"; + +import { StarkSessionActionTypes, StarkSessionActions } from "../actions/index"; +import { StarkSession, StarkSessionImpl } from "../entities/index"; + +export const starkSessionStoreKey: string = "starkSession"; + +const INITIAL_STATE: StarkSession = new StarkSessionImpl(); + +export function sessionReducer(state: Readonly = INITIAL_STATE, action: Readonly): Readonly { + + // the new state will be calculated from the data coming in the actions + switch (action.type) { + case StarkSessionActionTypes.CHANGE_LANGUAGE_SUCCESS: + return {...state, currentLanguage: action.languageId}; + + case StarkSessionActionTypes.INITIALIZE_SESSION: + return {...state, user: action.user}; + + case StarkSessionActionTypes.DESTROY_SESSION: + return {...state, user: undefined}; + + default: + return state; + } +} diff --git a/packages/stark-core/src/session/service/index.ts b/packages/stark-core/src/session/services/index.ts similarity index 61% rename from packages/stark-core/src/session/service/index.ts rename to packages/stark-core/src/session/services/index.ts index 32dcfce13d..6e2bbe8931 100644 --- a/packages/stark-core/src/session/service/index.ts +++ b/packages/stark-core/src/session/services/index.ts @@ -1,3 +1,4 @@ "use strict"; export * from "./session.service.intf"; +export * from "./session.service"; diff --git a/packages/stark-core/src/session/service/session.service.intf.ts b/packages/stark-core/src/session/services/session.service.intf.ts similarity index 96% rename from packages/stark-core/src/session/service/session.service.intf.ts rename to packages/stark-core/src/session/services/session.service.intf.ts index 0f8a4b1965..cacc36b10b 100644 --- a/packages/stark-core/src/session/service/session.service.intf.ts +++ b/packages/stark-core/src/session/services/session.service.intf.ts @@ -1,7 +1,7 @@ "use strict"; import { Observable } from "rxjs/Observable"; -import { StarkUser } from "../user/index"; +import { StarkUser } from "../../user/entities/index"; export const starkSessionServiceName: string = "StarkSessionService"; diff --git a/packages/stark-core/src/session/services/session.service.spec.ts b/packages/stark-core/src/session/services/session.service.spec.ts new file mode 100644 index 0000000000..a44ed6e142 --- /dev/null +++ b/packages/stark-core/src/session/services/session.service.spec.ts @@ -0,0 +1,886 @@ +"use strict"; + +import { HttpHeaders, HttpRequest } from "@angular/common/http"; +import { EventEmitter, Injector } from "@angular/core"; +import { DEFAULT_INTERRUPTSOURCES, Idle, InterruptSource } from "@ng-idle/core"; +import { Keepalive } from "@ng-idle/keepalive"; +import { Store } from "@ngrx/store"; +import { TranslateService } from "@ngx-translate/core"; +import { HookMatchCriteria, Predicate } from "@uirouter/core"; + +import { Observable } from "rxjs/Observable"; +import { Subject } from "rxjs/Subject"; +import { Subscriber } from "rxjs/Subscriber"; +import { defer } from "rxjs/observable/defer"; +import { of } from "rxjs/observable/of"; +import { take } from "rxjs/operators/take"; +import { _throw as observableThrow } from "rxjs/observable/throw"; + +import { + SessionTimeoutCountdownFinish, ChangeLanguage, ChangeLanguageFailure, + ChangeLanguageSuccess, + DestroySession, DestroySessionSuccess, InitializeSession, InitializeSessionSuccess, + SessionLogout, + SessionTimeoutCountdownStart, + SessionTimeoutCountdownStop, UserActivityTrackingPause, UserActivityTrackingResume +} from "../actions/index"; +import { StarkSessionServiceImpl, starkUnauthenticatedUserError } from "./session.service"; +import { StarkPreAuthentication, StarkSession } from "../entities/index"; +import { StarkApplicationConfig, StarkApplicationConfigImpl } from "../../configuration/entities/application/index"; +import { StarkUser } from "../../user/entities/index"; +import { StarkLoggingService } from "../../logging/services/index"; +import { MockStarkLoggingService } from "../../logging/testing/index"; +import { StarkRoutingService, StarkRoutingTransitionHook } from "../../routing/services/index"; +import { UnitTestingUtils } from "../../test/index"; +import { StarkHttpHeaders } from "../../http/constants/index"; +// import { StarkXSRFService } from "../../xsrf"; +import Spy = jasmine.Spy; +import { starkSessionExpiredStateName } from "../../common/routes/index"; +import { StarkCoreApplicationState } from "../../common/store/index"; + +describe("Service: StarkSessionService", () => { + + let mockStore: Store; + let appConfig: StarkApplicationConfig; + let mockSession: StarkSession; + let mockUser: Partial; + let mockLogger: StarkLoggingService; + let mockRoutingService: StarkRoutingService; + // let mockXSRFService: StarkXSRFService; + let mockIdleService: Idle; + let mockInjectorService: Injector; + let mockTranslateService: TranslateService; + let sessionService: SessionServiceHelper; + const mockCorrelationId: string = "12345"; + + // Inject module dependencies + beforeEach(() => { + mockUser = {uuid: "1", firstName: "Christopher", lastName: "Cortes"}; + mockSession = {currentLanguage: "NL", user: mockUser}; + mockStore = jasmine.createSpyObj("store", ["dispatch", "select"]); + (mockStore.select).and.returnValue(of(mockSession)); + appConfig = new StarkApplicationConfigImpl(); + appConfig.sessionTimeout = 123; + appConfig.sessionTimeoutWarningPeriod = 13; + appConfig.keepAliveInterval = 45; + appConfig.keepAliveUrl = "http://my.backend/keepalive"; + appConfig.logoutUrl = "http://localhost:5000/logout"; + appConfig.rootStateUrl = ""; + appConfig.rootStateName = ""; + appConfig.homeStateName = ""; + appConfig.errorStateName = ""; + appConfig.angularDebugInfoEnabled = false; + appConfig.debugLoggingEnabled = false; + appConfig.loggingFlushDisabled = true; + appConfig.defaultLanguage = "fr"; + appConfig.baseUrl = "/"; + appConfig.publicApp = false; + appConfig.routerLoggingEnabled = false; + appConfig.addBackend({ + name: "logging", + url: "http://localhost:5000", + authenticationType: 1, + fakePreAuthenticationEnabled: true, + fakePreAuthenticationRolePrefix: "", + loginResource: "logging", + token: "" + }); + + mockLogger = new MockStarkLoggingService(mockCorrelationId); + mockRoutingService = UnitTestingUtils.getMockedRoutingService(); + // mockXSRFService = UnitTestingUtils.getMockedXSRFService(); + mockIdleService = jasmine.createSpyObj("idleService,", + ["setIdle", "setTimeout", "getTimeout", "setInterrupts", "clearInterrupts", "getKeepaliveEnabled", "watch", "stop", "clearInterrupts"] + ); + mockIdleService.onIdleStart = new EventEmitter(); + mockIdleService.onIdleEnd = new EventEmitter(); + mockIdleService.onTimeout = new EventEmitter(); + mockIdleService.onTimeoutWarning = new EventEmitter(); + mockInjectorService = jasmine.createSpyObj("injector,", ["get"]); + mockTranslateService = jasmine.createSpyObj("translateService,", ["use"]); + sessionService = new SessionServiceHelper(mockStore, mockLogger, mockRoutingService, + appConfig, /*mockXSRFService,*/ mockIdleService, mockInjectorService, mockTranslateService); + (mockIdleService.setIdle).calls.reset(); + (mockIdleService.setTimeout).calls.reset(); + (mockIdleService.setInterrupts).calls.reset(); + (mockIdleService.clearInterrupts).calls.reset(); + (mockRoutingService.addTransitionHook).calls.reset(); + }); + + describe("on initialization", () => { + it("should throw an error in case the session timeout or the warning period in the app config are invalid", () => { + appConfig.sessionTimeout = undefined; + appConfig.sessionTimeoutWarningPeriod = 13; + expect(() => new SessionServiceHelper(mockStore, mockLogger, mockRoutingService, + appConfig, /*mockXSRFService,*/ mockIdleService, mockInjectorService, mockTranslateService)).toThrowError(/sessionTimeout/); + + appConfig.sessionTimeout = 123; + appConfig.sessionTimeoutWarningPeriod = undefined; + expect(() => new SessionServiceHelper(mockStore, mockLogger, mockRoutingService, + appConfig,/* mockXSRFService,*/ mockIdleService, mockInjectorService, mockTranslateService)).toThrowError(/sessionTimeoutWarning/); + + appConfig.sessionTimeout = -1; + appConfig.sessionTimeoutWarningPeriod = 13; + expect(() => new SessionServiceHelper(mockStore, mockLogger, mockRoutingService, + appConfig, /*mockXSRFService,*/ mockIdleService, mockInjectorService, mockTranslateService)).toThrowError(/sessionTimeout/); + + // FIXME problem with this expect ONLY (Solved ? Ask if the fix is OK) + appConfig.sessionTimeout = 123; + appConfig.sessionTimeoutWarningPeriod = -1; + expect(() => new SessionServiceHelper(mockStore, mockLogger, mockRoutingService, + appConfig, /*mockXSRFService,*/ mockIdleService, mockInjectorService, mockTranslateService)).toThrowError(/sessionTimeoutWarning/); + }); + }); + + describe("registerTransitionHook", () => { + it("should add transitionHook (onBefore) to the RoutingService matching all states except starkAppInit/starkAppExit children", () => { + sessionService.registerTransitionHook(); + + expect(mockRoutingService.addTransitionHook).toHaveBeenCalledTimes(1); + expect((mockRoutingService.addTransitionHook).calls.argsFor(0)[0]).toBe(StarkRoutingTransitionHook.ON_BEFORE); + + const hookMatchCriteria: HookMatchCriteria = (mockRoutingService.addTransitionHook).calls.argsFor(0)[1]; + + expect(hookMatchCriteria.entering).toBeDefined(); + + const matchingFn: Predicate = >hookMatchCriteria.entering; + const nonMatchingStates: object[] = [ + {name: "starkAppInit.state1"}, + {name: "starkAppInit.state2"}, + {name: "starkAppInit.stateX"}, + {name: "starkAppExit.state1"}, + {name: "starkAppExit.state2"}, + {name: "starkAppExit.stateX"}, + {abstract: true, name: ""} // root state + ]; + const matchingStates: object[] = [ + {name: "whatever.state1"}, + {name: "other.state2"}, + {name: "stateX"}, + {name: undefined} + ]; + + for (const state of matchingStates) { + expect(matchingFn(state)).toBe(true); + } + + for (const state of nonMatchingStates) { + expect(matchingFn(state)).toBe(false); + } + + expect((mockRoutingService.addTransitionHook).calls.argsFor(0)[2]).toBeDefined(); + expect((mockRoutingService.addTransitionHook).calls.argsFor(0)[3]).toEqual({priority: 1000}); + }); + + it("should resolve the promise when the onBefore hook is triggered and there IS user in the session", () => { + sessionService.registerTransitionHook(); + + expect((mockRoutingService.addTransitionHook).calls.argsFor(0)[0]).toBe(StarkRoutingTransitionHook.ON_BEFORE); + const onBeforeHookCallback: Function = (mockRoutingService.addTransitionHook).calls.argsFor(0)[2]; + + sessionService.session$ = of(mockSession); + + // trigger the onBefore hook callback + defer(() => onBeforeHookCallback()).subscribe( + (result: boolean) => { + expect(result).toBe(true); + }, + () => { + fail("The 'error' function should not be called in case of success"); + } + ); + }); + + it("should reject the promise with an error when the onBefore hook is triggered and there is NO user in the session", () => { + const sessionWithoutUser: StarkSession = {...mockSession, user: undefined}; + sessionService.session$ = of(sessionWithoutUser); + + sessionService.registerTransitionHook(); + + expect((mockRoutingService.addTransitionHook).calls.argsFor(0)[0]).toBe(StarkRoutingTransitionHook.ON_BEFORE); + const onBeforeHookCallback: Function = (mockRoutingService.addTransitionHook).calls.argsFor(0)[2]; + + // trigger the onBefore hook callback + defer(() => onBeforeHookCallback()).subscribe( + () => { + fail("The 'next' function should not be called in case of an http error"); + }, + (error: Error) => { + expect(error.message).toBe(starkUnauthenticatedUserError); + } + ); + }); + }); + + describe("initializeSession", () => { + it("should start the idle and keepalive services and dispatch the corresponding actions", () => { + spyOn(sessionService, "startIdleService"); + spyOn(sessionService, "startKeepaliveService"); + + sessionService.initializeSession(mockUser); + + expect(sessionService.startIdleService).toHaveBeenCalledTimes(1); + expect(sessionService.startKeepaliveService).toHaveBeenCalledTimes(1); + + expect(mockStore.dispatch).toHaveBeenCalledTimes(2); + expect((mockStore.dispatch).calls.argsFor(0)[0]).toEqual(new InitializeSession(mockUser)); + expect((mockStore.dispatch).calls.argsFor(1)[0]).toEqual(new InitializeSessionSuccess()); + }); + }); + + describe("destroySession", () => { + it("should stop the idle and keepalive services and dispatch the corresponding actions", () => { + spyOn(sessionService, "stopIdleService"); + spyOn(sessionService, "stopKeepaliveService"); + + sessionService.destroySession(); + + expect(sessionService.stopIdleService).toHaveBeenCalledTimes(1); + expect(sessionService.stopKeepaliveService).toHaveBeenCalledTimes(1); + + expect(mockStore.dispatch).toHaveBeenCalledTimes(2); + expect((mockStore.dispatch).calls.argsFor(0)[0]).toEqual(new DestroySession()); + expect((mockStore.dispatch).calls.argsFor(1)[0]).toEqual(new DestroySessionSuccess()); + }); + }); + + describe("login", () => { + it("should call the initializeSession() method passing the given user", () => { + spyOn(sessionService, "initializeSession"); + + sessionService.login(mockUser); + + expect(sessionService.initializeSession).toHaveBeenCalledTimes(1); + expect(sessionService.initializeSession).toHaveBeenCalledWith(mockUser); + }); + }); + + describe("logout", () => { + it("should dispatch the SESSION_LOGOUT action and send the logout HTTP request asynchronously ", () => { + spyOn(sessionService, "destroySession"); + const sendLogoutRequestSpy: Spy = spyOn(sessionService, "sendLogoutRequest").and.returnValue(of("HTTP response")); + + sessionService.logout(); + + expect(mockStore.dispatch).toHaveBeenCalledTimes(1); + expect((mockStore.dispatch).calls.mostRecent().args[0]).toEqual(new SessionLogout()); + + expect(sendLogoutRequestSpy).toHaveBeenCalledTimes(1); + expect(sendLogoutRequestSpy.calls.mostRecent().args[0]).toBe(appConfig.logoutUrl); + expect(sendLogoutRequestSpy.calls.mostRecent().args[1]).toBe(""); + expect(sendLogoutRequestSpy.calls.mostRecent().args[2]).toBe(true); + + expect(sessionService.destroySession).toHaveBeenCalledTimes(1); + }); + + it("should call the destroySession() method only when the logout HTTP request has returned a response (either success or error)", () => { + spyOn(sessionService, "destroySession"); + const logoutHttpResponse$: Subject = new Subject(); + const sendLogoutRequestSpy: Spy = spyOn(sessionService, "sendLogoutRequest").and.returnValue(logoutHttpResponse$); + + sessionService.logout(); + + expect(sendLogoutRequestSpy).toHaveBeenCalledTimes(1); + expect(sendLogoutRequestSpy.calls.mostRecent().args[0]).toBe(appConfig.logoutUrl); + expect(sendLogoutRequestSpy.calls.mostRecent().args[1]).toBe(""); + expect(sendLogoutRequestSpy.calls.mostRecent().args[2]).toBe(true); + expect(sessionService.destroySession).not.toHaveBeenCalled(); + + logoutHttpResponse$.next("HTTP 200"); + + expect(sessionService.destroySession).toHaveBeenCalledTimes(1); + (sessionService.destroySession).calls.reset(); + expect(sessionService.destroySession).not.toHaveBeenCalled(); + + logoutHttpResponse$.error("HTTP 500"); + + expect(sessionService.destroySession).toHaveBeenCalledTimes(1); + + logoutHttpResponse$.complete(); + }); + }); + + describe("pauseUserActivityTracking", () => { + it("should call the idle service to clear the interrupts temporarily and dispatch the corresponding action", () => { + sessionService.pauseUserActivityTracking(); + + expect(mockIdleService.clearInterrupts).toHaveBeenCalledTimes(1); + expect(mockStore.dispatch).toHaveBeenCalledTimes(1); + expect(mockStore.dispatch).toHaveBeenCalledWith(new UserActivityTrackingPause()); + }); + }); + + describe("resumeUserActivityTracking", () => { + it("should re-set the interrupts from the idle service and then re-start the idle and keepalive services and dispatch the action", () => { + const interruptsToBeSet: InterruptSource[] = DEFAULT_INTERRUPTSOURCES; + spyOn(sessionService, "startIdleService"); + spyOn(sessionService, "startKeepaliveService"); + + sessionService.resumeUserActivityTracking(); + + expect(mockIdleService.setInterrupts).toHaveBeenCalledTimes(1); + expect(mockIdleService.setInterrupts).toHaveBeenCalledWith(interruptsToBeSet); + + expect(sessionService.startIdleService).toHaveBeenCalledTimes(1); + expect(sessionService.startKeepaliveService).toHaveBeenCalledTimes(1); + + expect(mockStore.dispatch).toHaveBeenCalledTimes(1); + expect(mockStore.dispatch).toHaveBeenCalledWith(new UserActivityTrackingResume()); + }); + }); + + describe("configureIdleService", () => { + it("should set the necessary options of the idle service", () => { + const interruptsToBeSet: InterruptSource[] = DEFAULT_INTERRUPTSOURCES; + + sessionService.configureIdleService(); + + expect(mockIdleService.setIdle).toHaveBeenCalledTimes(1); + expect(mockIdleService.setIdle).toHaveBeenCalledWith(appConfig.sessionTimeout - appConfig.sessionTimeoutWarningPeriod); + + expect(mockIdleService.setTimeout).toHaveBeenCalledTimes(1); + expect(mockIdleService.setTimeout).toHaveBeenCalledWith(appConfig.sessionTimeoutWarningPeriod); + + expect(mockIdleService.setInterrupts).toHaveBeenCalledTimes(1); + expect(mockIdleService.setInterrupts).toHaveBeenCalledWith(interruptsToBeSet); + }); + + it("should throw an error if the warning period is equal or higher than the session timeout in the app config", () => { + appConfig.sessionTimeout = 30; + appConfig.sessionTimeoutWarningPeriod = 30; + + expect(() => sessionService.configureIdleService()).toThrowError(/sessionTimeoutWarningPeriod/); + + appConfig.sessionTimeout = 30; + appConfig.sessionTimeoutWarningPeriod = 31; + + expect(() => sessionService.configureIdleService()).toThrowError(/sessionTimeoutWarningPeriod/); + }); + + describe("onIdleStart notifications", () => { + it("should be listened by subscribing to the observable from the idle service", () => { + mockIdleService.onIdleStart = new EventEmitter(); + expect(mockIdleService.onIdleStart.observers.length).toBe(0); + + sessionService.configureIdleService(); + + expect(mockIdleService.onIdleStart.observers.length).toBe(1); + + const onIdleStartSubscriber: Subscriber = >(mockIdleService.onIdleStart.observers[0]); + spyOn(onIdleStartSubscriber, "next"); + spyOn(onIdleStartSubscriber, "error"); + + mockIdleService.onIdleStart.next("some start value"); + + expect(onIdleStartSubscriber.next).toHaveBeenCalledTimes(1); + expect(onIdleStartSubscriber.next).toHaveBeenCalledWith("some start value"); + expect(onIdleStartSubscriber.error).not.toHaveBeenCalled(); + + mockIdleService.onIdleStart.complete(); + }); + }); + + describe("onIdleEnd notifications", () => { + it("should be listened by subscribing to the observable from the idle service", () => { + mockIdleService.onIdleEnd = new EventEmitter(); + expect(mockIdleService.onIdleEnd.observers.length).toBe(0); + + sessionService.configureIdleService(); + + expect(mockIdleService.onIdleEnd.observers.length).toBe(1); + + const onIdleEndSubscriber: Subscriber = >(mockIdleService.onIdleEnd.observers[0]); + spyOn(onIdleEndSubscriber, "next"); + spyOn(onIdleEndSubscriber, "error"); + + mockIdleService.onIdleEnd.next("some end value"); + + expect(onIdleEndSubscriber.next).toHaveBeenCalledTimes(1); + expect(onIdleEndSubscriber.next).toHaveBeenCalledWith("some end value"); + expect(onIdleEndSubscriber.error).not.toHaveBeenCalled(); + + mockIdleService.onIdleEnd.complete(); + }); + + it("should dispatch the COUNTDOWN_STOP action and only if the countdown was started", () => { + mockIdleService.onIdleEnd = new EventEmitter(); + expect(mockIdleService.onIdleEnd.observers.length).toBe(0); + + sessionService.configureIdleService(); + + sessionService.countdownStarted = false; + mockIdleService.onIdleEnd.next("some end value"); + + expect(sessionService.countdownStarted).toBe(false); + expect(mockStore.dispatch).not.toHaveBeenCalled(); + + sessionService.countdownStarted = true; + mockIdleService.onIdleEnd.next("another end value"); + + expect(sessionService.countdownStarted).toBe(false); + expect(mockStore.dispatch).toHaveBeenCalledTimes(1); + expect((mockStore.dispatch).calls.argsFor(0)[0]).toEqual(new SessionTimeoutCountdownStop()); + + mockIdleService.onIdleEnd.complete(); + }); + }); + + describe("onTimeout notifications", () => { + it("should be listened by subscribing to the observable from the idle service", () => { + mockIdleService.onTimeout = new EventEmitter(); + expect(mockIdleService.onTimeout.observers.length).toBe(0); + + sessionService.configureIdleService(); + + expect(mockIdleService.onTimeout.observers.length).toBe(1); + + const onTimeoutSubscriber: Subscriber = >(mockIdleService.onTimeout.observers[0]); + spyOn(onTimeoutSubscriber, "next"); + spyOn(onTimeoutSubscriber, "error"); + + mockIdleService.onTimeout.next(321); + + expect(onTimeoutSubscriber.next).toHaveBeenCalledTimes(1); + expect(onTimeoutSubscriber.next).toHaveBeenCalledWith(321); + expect(onTimeoutSubscriber.error).not.toHaveBeenCalled(); + + mockIdleService.onTimeout.complete(); + }); + + it("should dispatch the COUNTDOWN_FINISH action, trigger the logout and navigate to the SessionExpired state", () => { + spyOn(sessionService, "logout"); + + mockIdleService.onTimeout = new EventEmitter(); + expect(mockIdleService.onTimeout.observers.length).toBe(0); + + sessionService.configureIdleService(); + + mockIdleService.onTimeout.next(321); + + expect(mockStore.dispatch).toHaveBeenCalledTimes(1); + expect((mockStore.dispatch).calls.argsFor(0)[0]).toEqual(new SessionTimeoutCountdownFinish()); + expect(sessionService.logout).toHaveBeenCalledTimes(1); + expect(mockRoutingService.navigateTo).toHaveBeenCalledTimes(1); + expect(mockRoutingService.navigateTo).toHaveBeenCalledWith(starkSessionExpiredStateName); + + mockIdleService.onTimeout.complete(); + }); + }); + + describe("onTimeoutWarning notifications", () => { + it("should be listened by subscribing to the observable from the idle service", () => { + mockIdleService.onTimeoutWarning = new EventEmitter(); + expect(mockIdleService.onTimeoutWarning.observers.length).toBe(0); + + sessionService.configureIdleService(); + + expect(mockIdleService.onTimeoutWarning.observers.length).toBe(1); + + const onTimeoutWarningSubscriber: Subscriber = >(mockIdleService.onTimeoutWarning.observers[0]); + spyOn(onTimeoutWarningSubscriber, "next"); + spyOn(onTimeoutWarningSubscriber, "error"); + + mockIdleService.onTimeoutWarning.next(10); + + expect(onTimeoutWarningSubscriber.next).toHaveBeenCalledTimes(1); + expect(onTimeoutWarningSubscriber.next).toHaveBeenCalledWith(10); + expect(onTimeoutWarningSubscriber.error).not.toHaveBeenCalled(); + + mockIdleService.onTimeoutWarning.complete(); + }); + + it("should dispatch the COUNTDOWN_START action only when the value emitted is the first one of the countdown", () => { + mockIdleService.onTimeoutWarning = new EventEmitter(); + expect(mockIdleService.onTimeoutWarning.observers.length).toBe(0); + const countdownStartValue: number = 22; + (mockIdleService.getTimeout).and.returnValue(countdownStartValue); + + sessionService.configureIdleService(); + + sessionService.countdownStarted = false; + mockIdleService.onTimeoutWarning.next(10); + + expect(sessionService.countdownStarted).toBe(false); + expect(mockStore.dispatch).not.toHaveBeenCalled(); + + mockIdleService.onTimeoutWarning.next(countdownStartValue); + + expect(sessionService.countdownStarted).toBe(true); + expect(mockStore.dispatch).toHaveBeenCalledTimes(1); + expect((mockStore.dispatch).calls.argsFor(0)[0]).toEqual(new SessionTimeoutCountdownStart(countdownStartValue)); + + mockIdleService.onTimeoutWarning.complete(); + }); + }); + }); + + describe("configureKeepaliveService", () => { + let mockKeepaliveService: Keepalive; + + beforeEach(() => { + mockKeepaliveService = jasmine.createSpyObj("keepaliveService,", + ["interval", "request", "ping", "stop"] + ); + mockKeepaliveService.onPing = new EventEmitter(); + (mockInjectorService.get).and.returnValue(mockKeepaliveService); + (mockIdleService.getKeepaliveEnabled).and.returnValue(true); + }); + + it("should set the necessary options and headers of the keepalive service if it is ENABLED", () => { + const sessionServiceHelper: SessionServiceHelper = new SessionServiceHelper(mockStore, mockLogger, mockRoutingService, + appConfig, /*mockXSRFService,*/ mockIdleService, mockInjectorService, mockTranslateService); + + expect(sessionServiceHelper.keepalive).toBeDefined(); + (mockKeepaliveService.interval).calls.reset(); + (mockKeepaliveService.request).calls.reset(); + + // make sure the fake pre-auth info is set correctly + const dummyUser: Partial = { + username: "jdoe", + firstName: "john", + lastName: "doe", + email: "jdoe@email.com", + language: "es", + workpost: "dummy workpost", + referenceNumber: "dummy ref number", + roles: ["a role", "another role", "yet another role"] + }; + + const preAuthDefaults: StarkPreAuthentication = sessionServiceHelper.getFakePreAuthenticationDefaults(); + + /* + * headers: HttpHeaders({ normalizedNames: Map( ), lazyUpdate: null, headers: Map( ) }), params: , urlWithParams: 'http://my.backend/keepalive' })*/ + + const expectedPreAuthHeaders: object = {}; + expectedPreAuthHeaders[StarkHttpHeaders.NBB_USER_NAME] = dummyUser.username; + expectedPreAuthHeaders[StarkHttpHeaders.NBB_FIRST_NAME] = dummyUser.firstName; + expectedPreAuthHeaders[StarkHttpHeaders.NBB_LAST_NAME] = dummyUser.lastName; + expectedPreAuthHeaders[StarkHttpHeaders.NBB_MAIL] = dummyUser.email; + expectedPreAuthHeaders[StarkHttpHeaders.NBB_LANGUAGE] = dummyUser.language; + expectedPreAuthHeaders[StarkHttpHeaders.NBB_DESCRIPTION] = dummyUser.referenceNumber + + preAuthDefaults.descriptionSeparator + dummyUser.workpost; + expectedPreAuthHeaders[StarkHttpHeaders.NBB_ROLES] = (dummyUser.roles).join(preAuthDefaults.roleSeparator); + + sessionServiceHelper.setFakePreAuthenticationHeaders(dummyUser); + + sessionServiceHelper.configureKeepaliveService(); + + expect(mockKeepaliveService.interval).toHaveBeenCalledTimes(1); + expect(mockKeepaliveService.interval).toHaveBeenCalledWith(appConfig.keepAliveInterval); + expect(mockKeepaliveService.request).toHaveBeenCalledTimes(1); + + let mockHeadersObj: HttpHeaders = new HttpHeaders(); + mockHeadersObj = mockHeadersObj.set(StarkHttpHeaders.NBB_CORRELATION_ID, mockCorrelationId); + + for (const key of Object.keys(expectedPreAuthHeaders)) { + mockHeadersObj = mockHeadersObj.set(key, expectedPreAuthHeaders[key]); + } + + expect(mockKeepaliveService.request).toHaveBeenCalledWith(new HttpRequest("GET",appConfig + .keepAliveUrl,{ headers: mockHeadersObj})); + }); + + it("should not set any option of the keepalive service if it is DISABLED", () => { + (mockIdleService.getKeepaliveEnabled).and.returnValue(false); + + const sessionServiceHelper: SessionServiceHelper = new SessionServiceHelper(mockStore, mockLogger, mockRoutingService, + appConfig, /*mockXSRFService,*/ mockIdleService, mockInjectorService, mockTranslateService); + + expect(sessionServiceHelper.keepalive).toBeUndefined(); + (mockKeepaliveService.interval).calls.reset(); + (mockKeepaliveService.request).calls.reset(); + + sessionServiceHelper.configureKeepaliveService(); + + expect(mockKeepaliveService.interval).not.toHaveBeenCalled(); + expect(mockKeepaliveService.request).not.toHaveBeenCalled(); + }); + + describe("onPing notifications", () => { + it("should be listened by subscribing to the observable from the idle service", () => { + const sessionServiceHelper: SessionServiceHelper = new SessionServiceHelper(mockStore, mockLogger, mockRoutingService, + appConfig, /*mockXSRFService,*/ mockIdleService, mockInjectorService, mockTranslateService); + + mockKeepaliveService.onPing = new EventEmitter(); + expect(mockKeepaliveService.onPing.observers.length).toBe(0); + + sessionServiceHelper.configureKeepaliveService(); + + expect(mockKeepaliveService.onPing.observers.length).toBe(1); + + const onPingSubscriber: Subscriber = >(mockKeepaliveService.onPing.observers[0]); + spyOn(onPingSubscriber, "next"); + spyOn(onPingSubscriber, "error"); + + mockKeepaliveService.onPing.next("some ping value"); + + expect(onPingSubscriber.next).toHaveBeenCalledTimes(1); + expect(onPingSubscriber.next).toHaveBeenCalledWith("some ping value"); + expect(onPingSubscriber.error).not.toHaveBeenCalled(); + }); + }); + }); + + describe("startIdleService", () => { + it("should call the watch() method from Idle service to start watching for inactivity", () => { + sessionService.startIdleService(); + + expect(sessionService.idle.watch).toHaveBeenCalledTimes(1); + }); + }); + + describe("stopIdleService", () => { + it("should call the stop() method from Idle service to stop watching for inactivity and clear all interrupt sources", () => { + sessionService.stopIdleService(); + + expect(sessionService.idle.stop).toHaveBeenCalledTimes(1); + expect(sessionService.idle.clearInterrupts).toHaveBeenCalledTimes(1); + }); + }); + + describe("startKeepaliveService", () => { + let mockKeepaliveService: Keepalive; + + beforeEach(() => { + mockKeepaliveService = jasmine.createSpyObj("keepaliveService,", + ["interval", "request", "ping", "stop"] + ); + + mockKeepaliveService.onPing = new EventEmitter(); + }); + + it("should trigger a ping using the Keepalive service", () => { + (mockInjectorService.get).and.returnValue(mockKeepaliveService); + (mockIdleService.getKeepaliveEnabled).and.returnValue(true); + + const sessionServiceHelper: SessionServiceHelper = new SessionServiceHelper(mockStore, mockLogger, mockRoutingService, + appConfig, /*mockXSRFService,*/ mockIdleService, mockInjectorService, mockTranslateService); + + sessionServiceHelper.startKeepaliveService(); + + expect(sessionServiceHelper.keepalive.ping).toHaveBeenCalledTimes(1); + }); + + it("should do NOTHING in case the Keepalive service is DISABLED", () => { + (mockIdleService.getKeepaliveEnabled).and.returnValue(false); + + sessionService.startKeepaliveService(); + + expect(sessionService.keepalive).toBeUndefined(); + }); + }); + + describe("stopKeepaliveService", () => { + let mockKeepaliveService: Keepalive; + + beforeEach(() => { + mockKeepaliveService = jasmine.createSpyObj("keepaliveService,", + ["interval", "request", "ping", "stop"] + ); + + mockKeepaliveService.onPing = new EventEmitter(); + }); + + it("should call the stop() method from the Keepalive service to stop the keepalive ping requests", () => { + (mockInjectorService.get).and.returnValue(mockKeepaliveService); + (mockIdleService.getKeepaliveEnabled).and.returnValue(true); + + const sessionServiceHelper: SessionServiceHelper = new SessionServiceHelper(mockStore, mockLogger, mockRoutingService, + appConfig, /*mockXSRFService,*/ mockIdleService, mockInjectorService, mockTranslateService); + + sessionServiceHelper.stopKeepaliveService(); + + expect(sessionServiceHelper.keepalive.stop).toHaveBeenCalledTimes(1); + }); + + it("should do NOTHING in case the keepalive service is DISABLED", () => { + (mockIdleService.getKeepaliveEnabled).and.returnValue(false); + + sessionService.stopKeepaliveService(); + + expect(sessionService.keepalive).toBeUndefined(); + }); + }); + + describe("getCurrentLanguage", () => { + it("should get the current language in an observable", () => { + sessionService.getCurrentLanguage().pipe( + take(1) + ).subscribe((language: string) => { + expect(language).toBe("NL"); + }); + }); + }); + + describe("setCurrentLanguage", () => { + it("should change the language successfully and dispatch the SUCCESS action", () => { + (mockTranslateService.use).and.returnValue(of("FR")); + + sessionService.setCurrentLanguage("FR"); + + expect(mockStore.dispatch).toHaveBeenCalledTimes(2); + expect((mockStore.dispatch).calls.argsFor(0)[0]).toEqual(new ChangeLanguage("FR")); + expect((mockStore.dispatch).calls.argsFor(1)[0]).toEqual(new ChangeLanguageSuccess("FR")); + + expect(mockTranslateService.use).toHaveBeenCalledTimes(1); + expect(mockTranslateService.use).toHaveBeenCalledWith("FR"); + }); + + it("should not change the language in case of failure and dispatch the FAILURE action", () => { + (mockTranslateService.use).and.returnValue(observableThrow("dummy error")); + + sessionService.setCurrentLanguage("FR"); + + expect(mockStore.dispatch).toHaveBeenCalledTimes(2); + expect((mockStore.dispatch).calls.argsFor(0)[0]).toEqual(new ChangeLanguage("FR")); + expect((mockStore.dispatch).calls.argsFor(1)[0]).toEqual(new ChangeLanguageFailure("dummy error")); + + // expect(mockTranslateService.use).toHaveBeenCalledTimes(1); + expect(mockTranslateService.use).toHaveBeenCalledWith("FR"); + }); + }); + + describe("setFakePreAuthenticationHeaders", () => { + it("should construct the pre-authentication headers based on the user that is passed", () => { + const dummyUser: Partial = { + username: "jdoe", + firstName: "john", + lastName: "doe", + email: "jdoe@email.com", + language: "es", + workpost: "dummy workpost", + referenceNumber: "dummy ref number", + roles: ["a role", "another role", "yet another role"] + }; + const preAuthDefaults: StarkPreAuthentication = sessionService.getFakePreAuthenticationDefaults(); + + const expectedPreAuthHeaders: object = {}; + expectedPreAuthHeaders[StarkHttpHeaders.NBB_USER_NAME] = dummyUser.username; + expectedPreAuthHeaders[StarkHttpHeaders.NBB_FIRST_NAME] = dummyUser.firstName; + expectedPreAuthHeaders[StarkHttpHeaders.NBB_LAST_NAME] = dummyUser.lastName; + expectedPreAuthHeaders[StarkHttpHeaders.NBB_MAIL] = dummyUser.email; + expectedPreAuthHeaders[StarkHttpHeaders.NBB_LANGUAGE] = dummyUser.language; + expectedPreAuthHeaders[StarkHttpHeaders.NBB_DESCRIPTION] = dummyUser.referenceNumber + + preAuthDefaults.descriptionSeparator + dummyUser.workpost; + expectedPreAuthHeaders[StarkHttpHeaders.NBB_ROLES] = (dummyUser.roles).join(preAuthDefaults.roleSeparator); + + sessionService.setFakePreAuthenticationHeaders(dummyUser); + + expect(sessionService.fakePreAuthenticationHeaders.size).toBe(7); + + for (const header of Object.keys(expectedPreAuthHeaders)) { + expect(sessionService.fakePreAuthenticationHeaders.has(header)).toBe(true); + expect(sessionService.fakePreAuthenticationHeaders.get(header)).toBe(expectedPreAuthHeaders[header]); + } + }); + + it("should construct only certain pre-authentication headers and with default values in case no user is passed", () => { + const preAuthDefaults: StarkPreAuthentication = sessionService.getFakePreAuthenticationDefaults(); + + const expectedPreAuthHeaders: object = {}; + expectedPreAuthHeaders[StarkHttpHeaders.NBB_LANGUAGE] = preAuthDefaults.defaults.language; + expectedPreAuthHeaders[StarkHttpHeaders.NBB_DESCRIPTION] = + preAuthDefaults.defaults.referenceNumber + preAuthDefaults.descriptionSeparator + preAuthDefaults.defaults.workpost; + expectedPreAuthHeaders[StarkHttpHeaders.NBB_ROLES] = ""; + + sessionService.setFakePreAuthenticationHeaders(); + + expect(sessionService.fakePreAuthenticationHeaders.size).toBe(3); + + for (const header of Object.keys(expectedPreAuthHeaders)) { + expect(sessionService.fakePreAuthenticationHeaders.has(header)).toBe(true); + expect(sessionService.fakePreAuthenticationHeaders.get(header)).toBe(expectedPreAuthHeaders[header]); + } + }); + }); + + describe("fakePreAuthenticationHeaders", () => { + it("should return the pre-authentication headers if they were constructed", () => { + const expectedPreAuthHeaders: Map = new Map(); + expectedPreAuthHeaders.set(StarkHttpHeaders.NBB_USER_NAME, "jdoe"); + expectedPreAuthHeaders.set(StarkHttpHeaders.NBB_FIRST_NAME, "john"); + expectedPreAuthHeaders.set(StarkHttpHeaders.NBB_LAST_NAME, "doe"); + expectedPreAuthHeaders.set(StarkHttpHeaders.NBB_MAIL, "jdoe@email.com"); + expectedPreAuthHeaders.set(StarkHttpHeaders.NBB_LANGUAGE, "es"); + expectedPreAuthHeaders.set(StarkHttpHeaders.NBB_DESCRIPTION, "dummy description"); + expectedPreAuthHeaders.set(StarkHttpHeaders.NBB_ROLES, "dummy roles"); + + sessionService.setInternalFakePreAuthenticationHeaders(expectedPreAuthHeaders); + + const fakePreAuthenticationHeaders: Map = sessionService.fakePreAuthenticationHeaders; + + expect(fakePreAuthenticationHeaders.size).toBe(expectedPreAuthHeaders.size); + + expectedPreAuthHeaders.forEach((value: string, header: string) => { + expect(fakePreAuthenticationHeaders.has(header)).toBe(true); + expect(fakePreAuthenticationHeaders.get(header)).toBe(value); + }); + }); + + it("should return an empty map if the pre-authentication headers were not constructed", () => { + sessionService.setInternalFakePreAuthenticationHeaders(undefined); + + const fakePreAuthenticationHeaders: Map = sessionService.fakePreAuthenticationHeaders; + + expect(fakePreAuthenticationHeaders.size).toBe(0); + }); + }); +}); + +class SessionServiceHelper extends StarkSessionServiceImpl { + public constructor(store: Store, logger: StarkLoggingService, routingService: StarkRoutingService, + appConfig: StarkApplicationConfig, /*xsrfService: StarkXSRFService,*/ idle: Idle, injector: Injector, + translateService: TranslateService) { + super(store, logger, routingService, appConfig, /*xsrfService,*/ idle, injector, translateService); + } + + public registerTransitionHook(): void { + super.registerTransitionHook(); + } + + public initializeSession(user: StarkUser): void { + super.initializeSession(user); + } + + public destroySession(): void { + super.destroySession(); + } + + public configureIdleService(): void { + super.configureIdleService(); + } + + public startIdleService(): void { + super.startIdleService(); + } + + public stopIdleService(): void { + super.stopIdleService(); + } + + public startKeepaliveService(): void { + super.startKeepaliveService(); + } + + public stopKeepaliveService(): void { + super.stopKeepaliveService(); + } + + public setFakePreAuthenticationHeaders(user?: StarkUser): void { + super.setFakePreAuthenticationHeaders(user); + } + + // override parent's implementation to prevent actual HTTP request to be sent! + public sendLogoutRequest(): Observable { + /* dummy function to be mocked */ + return of(undefined); + } + + public getFakePreAuthenticationDefaults(): StarkPreAuthentication { + return this.fakePreAuthentication; + } + + public setInternalFakePreAuthenticationHeaders(headers?: Map): Map { + return this._fakePreAuthenticationHeaders = headers; + } +} diff --git a/packages/stark-core/src/session/services/session.service.ts b/packages/stark-core/src/session/services/session.service.ts new file mode 100644 index 0000000000..08483cae92 --- /dev/null +++ b/packages/stark-core/src/session/services/session.service.ts @@ -0,0 +1,411 @@ +"use strict"; + +import { HttpHeaders, HttpRequest } from "@angular/common/http"; +import { Inject, Injectable, Injector } from "@angular/core"; +import { DEFAULT_INTERRUPTSOURCES, Idle } from "@ng-idle/core"; +import { Keepalive } from "@ng-idle/keepalive"; +import { TranslateService } from "@ngx-translate/core"; +import { Store } from "@ngrx/store"; +import { StateObject } from "@uirouter/core"; +import { Observable } from "rxjs/Observable"; +import { Subject } from "rxjs/Subject"; +import { take } from "rxjs/operators/take"; +import { map } from "rxjs/operators/map"; +import { defer } from "rxjs/observable/defer"; +import { validateSync } from "class-validator"; + +import { StarkLoggingService, starkLoggingServiceName } from "../../logging/services/index"; +import { StarkSessionService, starkSessionServiceName } from "./session.service.intf"; +import { StarkRoutingService, starkRoutingServiceName, StarkRoutingTransitionHook } from "../../routing/services/index"; +import { StarkApplicationConfig, STARK_APP_CONFIG } from "../../configuration/entities/index"; +import { StarkPreAuthentication, StarkSession } from "../entities/index"; +import { StarkUser } from "../../user/entities/index"; +import { + DestroySession, InitializeSession, InitializeSessionSuccess, DestroySessionSuccess, + ChangeLanguageFailure, + ChangeLanguage, + ChangeLanguageSuccess, + SessionTimeoutCountdownStop, + SessionTimeoutCountdownFinish, + UserActivityTrackingPause, + UserActivityTrackingResume, SessionLogout, + SessionTimeoutCountdownStart +} from "../actions/index"; +import { StarkHttpStatusCodes } from "../../http/enumerators/index"; +import { StarkHttpHeaders } from "../../http/constants/index"; +import { StarkValidationErrorsUtil } from "../../util/index"; +import { starkSessionExpiredStateName } from "../../common/routes/index"; +import { StarkCoreApplicationState, StarkSessionApplicationState } from "../../common/store/starkCoreApplicationState"; +// import { StarkXSRFService, starkXSRFServiceName } from "../../xsrf/"; + +export const starkUnauthenticatedUserError: string = "StarkSessionService => user not authenticated"; + +/** + * @ngdoc service + * @name stark-core.service:StarkSessionService + * @description Service to get/set session settings (language, ...). + * + * @requires ngrx-store.Store + * @requires StarkLoggingService + * @requires StarkRoutingService + * @requires StarkApplicationConfig + * @requires StarkXSRFService + * @requires ng-idle-core.Idle + * @requires $injector + * @requires $translate + */ +@Injectable() +export class StarkSessionServiceImpl implements StarkSessionService { + public keepalive: Keepalive; + public session$: Observable; + protected _fakePreAuthenticationHeaders: Map; + public countdownStarted: boolean; + + protected fakePreAuthentication: StarkPreAuthentication = { + roleSeparator: "^", + descriptionSeparator: "/", + defaults: { + language: "F", + workpost: "XXX", + referenceNumber: "00000" + } + }; + + public constructor(public store: Store, + @Inject(starkLoggingServiceName) public logger: StarkLoggingService, + @Inject(starkRoutingServiceName) public routingService: StarkRoutingService, + @Inject(STARK_APP_CONFIG) private appConfig: StarkApplicationConfig, + // FIXME Uncomment when XSRF Service is implemented + // @Inject(starkXSRFServiceName) public xsrfService: StarkXSRFService, + public idle: Idle, + injector: Injector, + // @Inject("$translate") $translate: any) { + public translateService: TranslateService) { + + if (this.idle.getKeepaliveEnabled() && !this.appConfig.keepAliveDisabled) { + this.keepalive = injector.get(Keepalive); + } + + this.registerTransitionHook(); + + // ensuring that the app config is valid before configuring the Idle and Keepalive services + StarkValidationErrorsUtil.throwOnError( + validateSync(this.appConfig), + starkSessionServiceName + ": " + STARK_APP_CONFIG + " constant is not valid." + ); + this.configureIdleService(); + this.configureKeepaliveService(); + + this.session$ = this.store.select((state: StarkSessionApplicationState) => state.starkSession); + + // this.session$ = this.store.select(starkSessionStoreKey,"test"); + + // FIXME Where does DEVELOPMENT Variable come from ??? + // if (DEVELOPMENT) { + // this.session$.pipe( + // filter((session: StarkSession) => session.hasOwnProperty("user")), + // tap((session: StarkSession) => this.setFakePreAuthenticationHeaders(session.user)) + // ).subscribe(); + // } + + if (window) { + window.addEventListener("beforeunload", () => { //ev: BeforeUnloadEvent + // Hit the logout URL before leaving the application. + // We need to call the REST service synchronously, + // because the browser has to wait for the HTTP call to complete. + + // dispatch action so an effect can run any logic if needed + this.store.dispatch(new SessionLogout()); + this.sendLogoutRequest(this.appConfig.logoutUrl, "", false); + // in this case, since the HTTP call is synchronous, the session can be destroy immediately + this.destroySession(); + }); + } + + this.logger.debug(starkSessionServiceName + " loaded"); + } + + protected registerTransitionHook(): void { + this.routingService.addKnownNavigationRejectionCause(starkUnauthenticatedUserError); + + this.routingService.addTransitionHook(StarkRoutingTransitionHook.ON_BEFORE, + { + // match any state except the ones that are children of starkAppInit/starkAppExit or the Ui-Router's root state + entering: (state?: StateObject) => { + if (state && typeof state.name !== "undefined") { + return !state.name.match(/(starkAppInit|starkAppExit)/) && + !(state.abstract && state.name === ""); + } else { + return true; // always match + } + } + }, + (): Promise => { + return this.session$.pipe( + take(1), + map((session: StarkSession) => { + if (typeof session.user === "undefined") { + // reject transition in case there is no user in the session + throw new Error(starkUnauthenticatedUserError); + } else { + return true; + } + }) + ).toPromise(); + }, + {priority: 1000} // very high priority (this hook should be the first one to be called to reject transitions immediately) + ); + } + + /** + * Performs all the necessary actions to initialize the session. + * It dispatches a INITIALIZE_SESSION action to the NGRX-Store + * @param user - The user used to initialize the session. + */ + protected initializeSession(user: StarkUser): void { + this.store.dispatch(new InitializeSession(user)); + this.startIdleService(); + this.startKeepaliveService(); + this.store.dispatch(new InitializeSessionSuccess()); + } + + /** + * Performs all the necessary actions to destroy the session. The user stored in the session is removed. + * It dispatches a DESTROY_SESSION action to the NGRX-Store + */ + protected destroySession(): void { + this.store.dispatch(new DestroySession()); + this.stopIdleService(); + this.stopKeepaliveService(); + this.store.dispatch(new DestroySessionSuccess()); + } + + public login(user: StarkUser): void { + this.initializeSession(user); + } + + public logout(): void { + // dispatch action so an effect can run any logic if needed + this.store.dispatch(new SessionLogout()); + // the session will always be destroyed right after the response of the logout HTTP call (regardless of its result) + this.sendLogoutRequest(this.appConfig.logoutUrl, "", true).subscribe( + () => this.destroySession(), + () => this.destroySession() + ); + } + + protected sendLogoutRequest(url: string, serializedData: string, async: boolean = true): Observable { + const httpRequest$: Subject = new Subject(); + + const emitXhrResult: Function = (xhrRequest: XMLHttpRequest) => { + if (xhrRequest.readyState === XMLHttpRequest.DONE) { + if (xhrRequest.status === StarkHttpStatusCodes.HTTP_200_OK || xhrRequest.status === StarkHttpStatusCodes.HTTP_201_CREATED) { + httpRequest$.next(); + httpRequest$.complete(); + } else { + httpRequest$.error(xhrRequest.status); + } + } + }; + + const xhr: XMLHttpRequest = new XMLHttpRequest(); + + if (async) { + xhr.onreadystatechange = () => { + emitXhrResult(xhr); + }; + } else { + emitXhrResult(xhr); + } + + // catch any error raised by the browser while opening the connection. for example: + // Chrome "mixed content" error: https://developers.google.com/web/fundamentals/security/prevent-mixed-content/what-is-mixed-content + // IE "Access is denied" error: https://stackoverflow.com/questions/22098259/access-denied-in-ie-10-and-11-when-ajax-target-is-localhost + try { + xhr.open("GET", url, async); + // FIXME Uncomment when XSRF Service is implemented + // this.xsrfService.configureXHR(xhr); + xhr.setRequestHeader(StarkHttpHeaders.CONTENT_TYPE, "application/json"); + xhr.send(serializedData); + } catch (e) { + httpRequest$.error(e); + } + + return httpRequest$; + } + + public pauseUserActivityTracking(): void { + this.store.dispatch(new UserActivityTrackingPause()); + this.idle.clearInterrupts(); + } + + public resumeUserActivityTracking(): void { + this.store.dispatch(new UserActivityTrackingResume()); + this.idle.setInterrupts(DEFAULT_INTERRUPTSOURCES); + this.startIdleService(); + this.startKeepaliveService(); + } + + protected configureIdleService(): void { + // ensuring that the session timeout values are valid to prevent errors while setting the idle value + if (this.appConfig.sessionTimeoutWarningPeriod >= this.appConfig.sessionTimeout) { + const errorMsg: string = starkSessionServiceName + ": " + STARK_APP_CONFIG + " constant is not valid.\n\n" + + "- sessionTimeoutWarningPeriod cannot be equal or higher than sessionTimeout\n"; + + throw new Error(errorMsg); + } + + // seconds before the user is considered to be idle (should be calculated subtracting the timeout warning period) + this.idle.setIdle(this.appConfig.sessionTimeout - this.appConfig.sessionTimeoutWarningPeriod); + // seconds before the session times out and the timeout warning event should be emitted + this.idle.setTimeout(this.appConfig.sessionTimeoutWarningPeriod); + // sets the default interrupts (clicks, scrolls, touches to the document) + this.idle.setInterrupts(DEFAULT_INTERRUPTSOURCES); + + this.idle.onIdleStart.subscribe( + () => this.logger.warn(starkSessionServiceName + ": the user has gone idle") + ); + this.idle.onIdleEnd.subscribe( + () => { + this.logger.info(starkSessionServiceName + ": the user is no longer idle"); + if (this.countdownStarted) { + this.countdownStarted = false; + // dispatch action so an effect can run any logic if needed + this.store.dispatch(new SessionTimeoutCountdownStop()); + } + } + ); + + this.idle.onTimeout.subscribe( + () => { + this.logger.warn(starkSessionServiceName + ": the user session has timed out!"); + // dispatch action so an effect can run any logic if needed + this.store.dispatch(new SessionTimeoutCountdownFinish()); + this.logout(); + this.routingService.navigateTo(starkSessionExpiredStateName); + } + ); + this.idle.onTimeoutWarning.subscribe( + (countdown: number) => { + if (countdown === this.idle.getTimeout()) { + this.countdownStarted = true; + // dispatch action so an effect can run any logic if needed (i.e. displaying a timeout countdown dialog) + this.store.dispatch(new SessionTimeoutCountdownStart(countdown)); + } + } + ); + } + + public configureKeepaliveService(): void { + if (!this.keepalive) { + return; + } + + this.keepalive.interval(this.appConfig.keepAliveInterval); // ping interval in seconds + + let pingRequestHeaders: HttpHeaders = new HttpHeaders(); + pingRequestHeaders = pingRequestHeaders.set(StarkHttpHeaders.NBB_CORRELATION_ID, this.logger.correlationId); + + // FIXME Where does DEVELOPMENT Variable come from ??? + // if (DEVELOPMENT) { + this.fakePreAuthenticationHeaders.forEach((value: string, key: string) => { + pingRequestHeaders = pingRequestHeaders.set(key, value); + }); + // } + + if (typeof this.appConfig.keepAliveUrl !== "string") { + throw new Error("KeepAliveUrl must be defined when KeepAlive is enabled.") + } + + // FIXME Should we create an interface instead of using any ? + const pingRequest: HttpRequest = new HttpRequest( + "GET", + this.appConfig.keepAliveUrl, + { + headers: pingRequestHeaders + } + ); + + // the XSRF config for this request will be automatically added by the XSRF Http Interceptor + this.keepalive.request(pingRequest); + this.keepalive.onPing.subscribe( + () => this.logger.info(starkSessionServiceName + ": keepAlive ping sent") + ); + } + + protected setFakePreAuthenticationHeaders(user?: StarkUser): void { + this.logger.debug(starkSessionServiceName + ": constructing fake pre-authentication headers"); + + // set a default language if not known + const languageHeaderValue: string = (user && user.language) ? user.language : this.fakePreAuthentication.defaults.language; + // set a default workpost if not known + const workpost: string = (user && user.workpost) ? user.workpost : this.fakePreAuthentication.defaults.workpost; + // set a default reference number if not known + const referenceNumber: string = + (user && user.referenceNumber) ? user.referenceNumber : this.fakePreAuthentication.defaults.referenceNumber; + + const descriptionHeaderValue: string = referenceNumber + this.fakePreAuthentication.descriptionSeparator + workpost; + + let rolesHeaderValue: string = ""; + if (user && user.roles) { + rolesHeaderValue = user.roles.join(this.fakePreAuthentication.roleSeparator); + } + + this._fakePreAuthenticationHeaders = new Map(); + + if (user) { + this._fakePreAuthenticationHeaders.set(StarkHttpHeaders.NBB_USER_NAME, user.username); + this._fakePreAuthenticationHeaders.set(StarkHttpHeaders.NBB_FIRST_NAME, user.firstName); + this._fakePreAuthenticationHeaders.set(StarkHttpHeaders.NBB_LAST_NAME, user.lastName); + if (user.email) { + this._fakePreAuthenticationHeaders.set(StarkHttpHeaders.NBB_MAIL, user.email); + } + } + this._fakePreAuthenticationHeaders.set(StarkHttpHeaders.NBB_LANGUAGE, languageHeaderValue); + this._fakePreAuthenticationHeaders.set(StarkHttpHeaders.NBB_DESCRIPTION, descriptionHeaderValue); + this._fakePreAuthenticationHeaders.set(StarkHttpHeaders.NBB_ROLES, rolesHeaderValue); + } + + public get fakePreAuthenticationHeaders(): Map { + return this._fakePreAuthenticationHeaders || new Map(); + } + + protected startIdleService(): void { + this.idle.watch(); + } + + protected stopIdleService(): void { + this.idle.stop(); + this.idle.clearInterrupts(); + } + + protected startKeepaliveService(): void { + if (this.keepalive) { + // the Keepalive service is automatically started by the Idle service + this.keepalive.ping(); + } + } + + protected stopKeepaliveService(): void { + if (this.keepalive) { + this.keepalive.stop(); + } + } + + public getCurrentLanguage(): Observable { + return this.session$.pipe( + map((session: StarkSession) => session.currentLanguage) + ); + } + + public setCurrentLanguage(newLanguage: string): void { + // dispatch corresponding action to allow the user to trigger his own effects if needed + this.store.dispatch(new ChangeLanguage(newLanguage)); + + defer(() => this.translateService.use(newLanguage)) + .subscribe( + (languageId: string) => this.store.dispatch(new ChangeLanguageSuccess(languageId)), + (error: any) => this.store.dispatch(new ChangeLanguageFailure(error)) + ); + } +} diff --git a/packages/stark-core/src/session/user/index.ts b/packages/stark-core/src/session/user/index.ts deleted file mode 100644 index f736638320..0000000000 --- a/packages/stark-core/src/session/user/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -"use strict"; - -export * from "./user.entity"; -export * from "./user-profile.entity.intf"; -export * from "./user-security-profile.entity.intf"; diff --git a/packages/stark-core/src/session/user/user-profile.entity.intf.ts b/packages/stark-core/src/session/user/user-profile.entity.intf.ts deleted file mode 100644 index e3be13fb75..0000000000 --- a/packages/stark-core/src/session/user/user-profile.entity.intf.ts +++ /dev/null @@ -1,17 +0,0 @@ -"use strict"; - -export interface StarkUserProfile { - username: string; - firstName: string; - lastName: string; - email?: string; - phone?: string; - language?: string; - referenceNumber?: string; - isAnonymous?: boolean; - - /** - * This property will contain any additional details for the user profile returned by the backend - */ - custom?: object; -} diff --git a/packages/stark-core/src/session/user/user-security-profile.entity.intf.ts b/packages/stark-core/src/session/user/user-security-profile.entity.intf.ts deleted file mode 100644 index 402eb30825..0000000000 --- a/packages/stark-core/src/session/user/user-security-profile.entity.intf.ts +++ /dev/null @@ -1,6 +0,0 @@ -"use strict"; - -export interface StarkUserSecurityProfile { - roles: string[]; - workpost?: string; -} diff --git a/packages/stark-core/src/session/user/user.entity.ts b/packages/stark-core/src/session/user/user.entity.ts deleted file mode 100644 index 3ad5ec1bfe..0000000000 --- a/packages/stark-core/src/session/user/user.entity.ts +++ /dev/null @@ -1,89 +0,0 @@ -"use strict"; - -import { autoserialize } from "cerialize"; -import { StarkUserProfile } from "./user-profile.entity.intf"; -import { StarkUserSecurityProfile } from "./user-security-profile.entity.intf"; -import { StarkResource } from "../../http/index"; -import { IsArray, IsBoolean, IsDefined, IsEmail, IsString, ValidateIf } from "class-validator"; -import { StarkValidationMethodsUtil } from "../../util/validation-methods.util"; - -export class StarkUser implements StarkUserProfile, StarkUserSecurityProfile, StarkResource { - @IsDefined() - @IsString() - @autoserialize - public uuid: string; - - @IsDefined() - @IsString() - @autoserialize - public username: string; - - @IsDefined() - @IsString() - @autoserialize - public firstName: string; - - @IsDefined() - @IsString() - @autoserialize - public lastName: string; - - @ValidateIf(StarkValidationMethodsUtil.validateIfDefinedAndNotNull) - @IsEmail() - @autoserialize - public email?: string; - - @ValidateIf(StarkValidationMethodsUtil.validateIfDefinedAndNotNull) - @IsString() - @autoserialize - public phone?: string; - - @ValidateIf(StarkValidationMethodsUtil.validateIfDefinedAndNotNull) - @IsString() - @autoserialize - public language?: string; - - @ValidateIf(StarkValidationMethodsUtil.validateIfDefinedAndNotNull) - @IsString() - @autoserialize - public selectedLanguage?: string; - - @ValidateIf(StarkValidationMethodsUtil.validateIfDefinedAndNotNull) - @IsString() - @autoserialize - public referenceNumber?: string; - - @IsDefined() - @IsArray() - @autoserialize - public roles: string[] = []; - - @ValidateIf(StarkValidationMethodsUtil.validateIfDefinedAndNotNull) - @IsString() - @autoserialize - public workpost?: string; - - @ValidateIf(StarkValidationMethodsUtil.validateIfDefinedAndNotNull) - @IsBoolean() - @autoserialize - public isAnonymous?: boolean; - - @autoserialize public custom?: object; - - /** - * Extract the properties coming in the "details" object. - * This is a callback method provided by cerialize in order to post-process the de-serialized json object. - * @param instance - Instantiated object with its properties already set as defined via the serializer annotations - * @param json - Raw json object retrieved from the http call - * @link https://confluence.prd.nbb/display/jag/REST+-+How-to+configure+the+user+profile+resource - */ - public static OnDeserialized(instance: StarkUser, json: any): void { - if (json.details) { - instance.language = json.details.language; - instance.firstName = json.details.firstName; - instance.lastName = json.details.lastName; - instance.email = json.details.mail; - instance.referenceNumber = json.details.referenceNumber; - } - } -} diff --git a/packages/stark-core/src/test/unit-testing/unit-testing-utils.ts b/packages/stark-core/src/test/unit-testing/unit-testing-utils.ts index db8c89c39a..c56d21de3a 100644 --- a/packages/stark-core/src/test/unit-testing/unit-testing-utils.ts +++ b/packages/stark-core/src/test/unit-testing/unit-testing-utils.ts @@ -1,6 +1,6 @@ "use strict"; -import { StarkLoggingService, starkLoggingServiceName } from "../..//logging/index"; +import { StarkRoutingService, starkRoutingServiceName } from "../../routing/index"; import { StarkSessionService, starkSessionServiceName } from "../../session/index"; import { StarkHttpHeaders, StarkHttpService, starkHttpServiceName } from "../../http/index"; @@ -9,23 +9,32 @@ import { StarkHttpHeaders, StarkHttpService, starkHttpServiceName } from "../../ */ export class UnitTestingUtils { /** - * Returns a new instance of a mocked StarkLoggingService. It should always return a new instance otherwise all the tests + * Returns a new instance of a mocked StarkRoutingService. It should always return a new instance otherwise all the tests * would share the same instance including all the customizations made to such instance, causing an unexpected behaviour * and many tests to fail */ - public static getMockedLoggingService(correlationId: string = "dummyCorrelationId"): StarkLoggingService { - const mockLoggingService: any = jasmine.createSpyObj(starkLoggingServiceName, [ - "debug", - "info", - "warn", - "error", - "generateNewCorrelationId" + public static getMockedRoutingService(): StarkRoutingService { + return jasmine.createSpyObj(starkRoutingServiceName, [ + "getCurrentState", + "getCurrentStateName", + "navigateTo", + "navigateToHome", + "navigateToPrevious", + "reload", + "getStatesConfig", + "getCurrentStateConfig", + "getCurrentStateParams", + "isCurrentUiState", + "getStateTreeParams", + "getStateTreeResolves", + "getStateTreeData", + "addKnownNavigationRejectionCause", + "addTransitionHook", + "getTranslationKeyFromState", + "getStateDeclarationByStateName" ]); - mockLoggingService.correlationId = correlationId; - - return mockLoggingService; } - + /** * Returns a new instance of a mocked StarkHttpService. It should always return a new instance otherwise all the tests * would share the same instance including all the customizations made to such instance, causing an unexpected behaviour diff --git a/packages/stark-core/src/user/entities/user-profile.entity.intf.ts b/packages/stark-core/src/user/entities/user-profile.entity.intf.ts index 478e302383..d39e9527b9 100644 --- a/packages/stark-core/src/user/entities/user-profile.entity.intf.ts +++ b/packages/stark-core/src/user/entities/user-profile.entity.intf.ts @@ -6,7 +6,7 @@ export interface StarkUserProfile { lastName: string; email?: string; phone?: string; - language: string; + language?: string; referenceNumber?: string; isAnonymous?: boolean; diff --git a/packages/stark-core/src/user/entities/user.entity.ts b/packages/stark-core/src/user/entities/user.entity.ts index 4b32ead843..1795bac589 100644 --- a/packages/stark-core/src/user/entities/user.entity.ts +++ b/packages/stark-core/src/user/entities/user.entity.ts @@ -5,6 +5,7 @@ import { StarkUserProfile } from "./user-profile.entity.intf"; import { StarkUserSecurityProfile } from "./user-security-profile.entity.intf"; import { StarkResource } from "../../http/entities/index"; import { IsArray, IsBoolean, IsDefined, IsEmail, IsString, ValidateIf } from "class-validator"; +import { StarkValidationMethodsUtil } from "../../util/validation-methods.util"; export class StarkUser implements StarkUserProfile, StarkUserSecurityProfile, StarkResource { @IsDefined() @@ -27,27 +28,27 @@ export class StarkUser implements StarkUserProfile, StarkUserSecurityProfile, St @autoserialize public lastName: string; - // @ValidateIf((user: StarkUser) => typeof user.email !== "undefined" && user.email !== null) + @ValidateIf(StarkValidationMethodsUtil.validateIfDefined) @IsEmail() @autoserialize public email?: string; - // @ValidateIf((user: StarkUser) => typeof user.phone !== "undefined" && user.phone !== null) + @ValidateIf(StarkValidationMethodsUtil.validateIfDefined) @IsString() @autoserialize public phone?: string; - // @ValidateIf((user: StarkUser) => typeof user.language !== "undefined" && user.language !== null) + @ValidateIf(StarkValidationMethodsUtil.validateIfDefined) @IsString() @autoserialize public language: string; - // @ValidateIf((user: StarkUser) => typeof user.selectedLanguage !== "undefined" && user.selectedLanguage !== null) + @ValidateIf(StarkValidationMethodsUtil.validateIfDefined) @IsString() @autoserialize public selectedLanguage?: string; - // @ValidateIf((user: StarkUser) => typeof user.referenceNumber !== "undefined" && user.referenceNumber !== null) + @ValidateIf(StarkValidationMethodsUtil.validateIfDefined) @IsString() @autoserialize public referenceNumber?: string; @@ -57,12 +58,12 @@ export class StarkUser implements StarkUserProfile, StarkUserSecurityProfile, St @autoserialize public roles: string[] = []; - @ValidateIf((user: StarkUser) => typeof user.workpost !== "undefined" && user.workpost !== null) + @ValidateIf(StarkValidationMethodsUtil.validateIfDefined) @IsString() @autoserialize public workpost?: string; - @ValidateIf((user: StarkUser) => typeof user.isAnonymous !== "undefined" && user.isAnonymous !== null) + @ValidateIf(StarkValidationMethodsUtil.validateIfDefined) @IsBoolean() @autoserialize public isAnonymous?: boolean; diff --git a/packages/stark-core/src/user/index.ts b/packages/stark-core/src/user/index.ts new file mode 100644 index 0000000000..a121b71818 --- /dev/null +++ b/packages/stark-core/src/user/index.ts @@ -0,0 +1 @@ +export * from "./entities/index"; diff --git a/packages/stark-core/tsconfig.json b/packages/stark-core/tsconfig.json index 8cc970f378..74495f14d8 100644 --- a/packages/stark-core/tsconfig.json +++ b/packages/stark-core/tsconfig.json @@ -4,7 +4,10 @@ "compilerOptions": { "baseUrl": ".", "rootDir": ".", - "typeRoots": ["./node_modules/@types", "./node_modules/@nationalbankbelgium/stark-testing/node_modules/@types"], + "typeRoots": [ + "./node_modules/@types", + "./node_modules/@nationalbankbelgium/stark-testing/node_modules/@types" + ], "lib": ["dom", "dom.iterable", "es2017"], "paths": { "rxjs/*": ["../../node_modules/rxjs/*"],