Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import HomeSteps from "../../steps/home-steps";
import {OperationsStatusesComponentSteps} from "../../steps/operations-statuses-component-steps";
import {GlobalOperationsStatusesStub} from "../../stubs/global-operations-statuses-stub";
import {ImportUserDataSteps} from "../../steps/import/import-user-data-steps";
import {BrowserStubs} from "../../stubs/browser-stubs";

describe('Operations Status Component', () => {

Expand Down Expand Up @@ -141,8 +140,8 @@ describe('Operations Status Component', () => {
});

function checkOperationElementUrl(expectedUrl, operationIndex = 0) {
BrowserStubs.spyNavigateToUrl();
OperationsStatusesComponentSteps.getOperationStatuses().eq(operationIndex).click();
cy.get(BrowserStubs.NAVIGATE_TO_URL_ALIAS()).should('have.been.calledWithMatch', expectedUrl)
OperationsStatusesComponentSteps.getOperationStatuses().eq(operationIndex)
.should('have.attr', 'href', expectedUrl)
.should('have.attr', 'target', '_blank');
}
});
4 changes: 4 additions & 0 deletions e2e-tests/e2e-security/setup/home/cookie-policy.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@ describe('Cookie policy', () => {
cy.reload();
// Then I expect the banner to be hidden
CookieConsentBannerSteps.getCookieConsentBanner().should('not.exist');
// Wait for the page to be fully loaded.
// Cypress executes actions very quickly, and without this wait the logout subscribers may not be registered yet.
// As a result, the logout event is not handled correctly and the logout operation may fail.
SettingsSteps.getCookiePolicyButton().should('be.visible');
// When I logout
LoginSteps.logout();
// Then I should see the login page
Expand Down
13 changes: 13 additions & 0 deletions packages/api/src/models/events/auth/logged-out.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {Event} from '../event';
import {EventName} from '../event-name';

/**
* Represents a "loggedOut" event.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be: represents a EventName.LOGGED_OUT event

as this is the proper event name

*
* This event is triggered when users logged out.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

uesr is logged out

*/
export class LoggedOut extends Event<undefined> {
constructor() {
super(EventName.LOGGED_OUT);
}
}
1 change: 1 addition & 0 deletions packages/api/src/models/events/event-name.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const EventName = {
NAVIGATION_START: 'navigationStart',
LOGIN: 'login',
LOGOUT: 'logout',
LOGGED_OUT: 'loggedOut',
APP_DATA_LOADED: 'applicationDataLoaded',
APPLICATION_MOUNTED: 'applicationMounted',
APPLICATION_UNMOUNTED: 'applicationUnmounted',
Expand Down
27 changes: 22 additions & 5 deletions packages/api/src/models/events/event-observer.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,35 @@
import { EventSubscriptionCancelHandler } from './event-subscription-cancel-handler';
import { EventSubscriptionCallback } from './event-subscription-callback';

/**
* Represents an observer for events, encapsulating a callback to handle event notifications.
* Represents an observer for events, encapsulating a callback to handle event notifications
* and an optional handler that can prevent the event from being emitted.
*
* @template T - The type of the event payload.
*/
export class EventObserver<T> {

/**
* A callback function to notify the observer of an event.
* Callback invoked when the event is emitted.
*/
notify: EventSubscriptionCallback<T>;

/**
* Handler evaluated before the event is emitted.
*
* @param eventPayload - The payload of the event to notify the observer about.
* If the handler resolves to <code>true</code>, the event emission is canceled.
* If the handler resolves to <code>false</code>, the event emission is allowed to proceed.
*/
notify: (eventPayload: T) => void;
shouldCancelEventHandler?: EventSubscriptionCancelHandler;

constructor(callback: (payload: T) => void) {
/**
* Creates a new event observer.
*
* @param callback - Callback invoked when the event is emitted.
* @param shouldCancelEventHandler - Handler used to determine whether the event emission should be canceled.
*/
constructor(callback: EventSubscriptionCallback<T>, shouldCancelEventHandler?: EventSubscriptionCancelHandler) {
this.notify = callback;
this.shouldCancelEventHandler = shouldCancelEventHandler;
}
}
7 changes: 7 additions & 0 deletions packages/api/src/models/events/event-subscription-callback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Represents a callback function invoked when an event is emitted.
*
* @template T - The type of the event payload.
* @param payload - The payload associated with the emitted event.
*/
export type EventSubscriptionCallback<T> = (payload: T) => void;
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Represents a handler that determines whether an event emission should be canceled.
*
* The handler resolves to <code>true</code> when the event emission should be canceled,
* or <code>false</code> when the event should be allowed to proceed.
*/
export type EventSubscriptionCancelHandler = () => Promise<boolean>;
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {Service} from '../../../providers/service/service';
import {AuthenticatedUser, SecurityConfig} from '../../../models/security';
import {service} from '../../../providers';
import {EventService} from '../../event-service';
import {Logout} from '../../../models/events';
import {LoggedOut} from '../../../models/events/auth/logged-out';
import {SecurityContextService} from './security-context.service';
import {AuthStrategyResolver} from './auth-strategy-resolver';
import {Login} from '../../../models/events/auth/login';
Expand Down Expand Up @@ -83,7 +83,7 @@ export class AuthenticationService implements Service {
this.eventService.emit(new Login());
} else {
navigate('login');
this.eventService.emit(new Logout());
this.eventService.emit(new LoggedOut());
}
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {OpenidTokenUtils} from './openid-token-utils';
import {OpenIdUtils} from './openid-utils';
import {AuthenticationStorageService} from '../authentication-storage.service';
import {EventService} from '../../../event-service';
import {Logout} from '../../../../models/events';
import {LoggedOut} from '../../../../models/events/auth/logged-out';
import {OpenIdError} from '../errors/openid/openid-error';
import {WindowService} from '../../../window';
import {MissingOpenidConfiguration} from '../errors/openid/missing-openid-configuration';
Expand Down Expand Up @@ -92,7 +92,7 @@ export class OpenIdService implements Service {
notify(notification);

this.softLogout();
service(EventService).emit(new Logout());
service(EventService).emit(new LoggedOut());
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {WindowService} from '../../../../window';
import {service} from '../../../../../providers';
import {OpenidSecurityConfig, SecurityConfig} from '../../../../../models/security';
import {OpenIdAuthFlowType, OpenIdTokens} from '../../../../../models/security/authentication/openid-auth-flow-models';
import {Logout} from '../../../../../models/events';
import {LoggedOut} from '../../../../../models/events/auth/logged-out';
import {MissingOpenidConfiguration} from '../../errors/openid/missing-openid-configuration';
import {ResponseMock} from '../../../../http/test/response-mock';
import {TestUtil} from '../../../../utils/test/test-util';
Expand Down Expand Up @@ -194,7 +194,7 @@ describe('OpenIdService', () => {
await openIdService.refreshTokens('invalid-token');

expect(clearAuthenticationSpy).toHaveBeenCalled();
expect(emitSpy).toHaveBeenCalledWith(expect.any(Logout));
expect(emitSpy).toHaveBeenCalledWith(expect.any(LoggedOut));
});

it('should throw MissingOpenidConfiguration when config is missing', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {service} from '../../../../providers';
import {AuthenticationStorageService} from '../authentication-storage.service';
import {SecurityContextService} from '../security-context.service';
import {EventService} from '../../../event-service';
import {Logout} from '../../../../models/events';
import {LoggedOut} from '../../../../models/events/auth/logged-out';
import {WindowService} from '../../../window';
import {AuthStrategy, AuthStrategyType} from '../../../../models/security/authentication';
import {AuthStrategyResolver} from '../auth-strategy-resolver';
Expand Down Expand Up @@ -108,7 +108,7 @@ describe('AuthenticationService', () => {
const testAuthStrategySpy = jest.spyOn(testAuthStrategy, 'logout');
await authService.logout();
expect(emitSpy).toHaveBeenCalledTimes(1);
expect(emitSpy).toHaveBeenCalledWith(expect.any(Logout));
expect(emitSpy).toHaveBeenCalledWith(expect.any(LoggedOut));
expect(testAuthStrategySpy).toHaveBeenCalled();
});

Expand Down
49 changes: 42 additions & 7 deletions packages/api/src/services/event-service/event.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import {Event} from '../../models/events';
import {Service} from '../../providers/service/service';
import {ObjectUtil} from '../utils';
import {EventObserver} from '../../models/events/event-observer';
import {EventSubscriptionCancelHandler} from '../../models/events/event-subscription-cancel-handler';
import {EventSubscriptionCallback} from '../../models/events/event-subscription-callback';

/**
* A service that manages event subscriptions and emissions.
Expand All @@ -19,29 +21,62 @@ export class EventService implements Service {
/**
* Emits an event to all subscribers of the specified event type.
*
* Before notifying subscribers, registered cancellation handlers are evaluated.
* If any handler requests cancellation, the event is not emitted.
*
* @template T - The type of the event payload.
* @param event - The event to emit, containing its name and payload.
*/
emit<T extends {} | undefined>(event: Event<T>): void {
const subscribers = this.eventSubscribers.get(event.NAME);
if (subscribers) {
subscribers.forEach((subscriber) => {
subscriber.notify(ObjectUtil.deepCopy(event.payload));
if (!subscribers) {
return;
}

this.shouldCancelEvent(subscribers)
.then((cancelEvent) => {
if (!cancelEvent) {
subscribers.forEach((subscriber) => {
subscriber.notify(ObjectUtil.deepCopy(event.payload));
});
}
});
}

/**
* Evaluates cancellation handlers for the provided observers.
*
* Handlers are evaluated sequentially until one of them requests cancellation of the event.
*
* @param observers - The observers whose cancellation handlers should be evaluated.
*
* @returns A promise that resolves to <code>true</code> if the event should be canceled,
* or <code>false</code> if no handler requests cancellation.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private async shouldCancelEvent(observers: EventObserver<any>[]): Promise<boolean> {
for (const observer of observers) {
const shouldCancel = await observer.shouldCancelEventHandler?.();
if (shouldCancel) {
return true;
}
}
return false;
}

/**
* Subscribes to a specific event with a callback function.
*
* @template T - The type of the event payload.
* @param eventName - The name of the event to subscribe to.
* @param callback - A callback function to handle the event notifications.
* @param callback - A callback function invoked when the event is emitted.
* @param shouldCancelEventHandler - Optional handler that resolves to <code>true</code> when the event emission should be canceled,
* or <code>false</code> when it should proceed.
*
* @returns A function to unsubscribe from the event.
* @returns A function that unsubscribes the observer from the event.
*/
subscribe<T extends {} | undefined>(eventName: string, callback: (payload: T) => void): () => void {
const observer = new EventObserver<T>(callback);
subscribe<T extends {} | undefined>(eventName: string, callback: EventSubscriptionCallback<T>, shouldCancelEventHandler?: EventSubscriptionCancelHandler): () => void {
const observer = new EventObserver<T>(callback, shouldCancelEventHandler);
const eventSubscribers = this.eventSubscribers.get(eventName);

if (eventSubscribers) {
Expand Down
58 changes: 48 additions & 10 deletions packages/api/src/services/event-service/test/event.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {EventService} from '../event.service';
import {Event} from '../../../models/events';
import {Event, Logout} from '../../../models/events';
import {EventName, NavigationEnd} from '../../../models/events';

const TEST_EVENT_NAME = 'testEventName';
Expand All @@ -11,7 +11,7 @@ describe('EventService', () => {
eventService = new EventService();
});

it('emit should notify observers of a given event', () => {
it('emit should notify observers of a given event', async () => {

const oldUrl = '/import';
const newUrl = '/graphql';
Expand All @@ -23,40 +23,40 @@ describe('EventService', () => {
eventService.subscribe(EventName.NAVIGATION_END, callBackFunctionTwo);

// When: the navigation end event is emitted.
eventService.emit(new NavigationEnd(oldUrl, newUrl));
await emitAndWait(eventService, new NavigationEnd(oldUrl, newUrl));

// Then: I expect the registered callback functions to be called.
expect(callBackFunction).toHaveBeenLastCalledWith({oldUrl, newUrl});
expect(callBackFunctionTwo).toHaveBeenLastCalledWith({oldUrl, newUrl});
});

it('emit should notify observers of a given event if emitted value is undefined', () => {
it('emit should notify observers of a given event if emitted value is undefined', async () => {

// Given: there is a registered callback function for an event.
const callBackFunction = jest.fn();
eventService.subscribe(TEST_EVENT_NAME, callBackFunction);

// When: the event is emitted with undefined value.
eventService.emit(new TestEvent(undefined));
await emitAndWait(eventService, new TestEvent(undefined));

// Then: I expect the registered callback function to be called.
expect(callBackFunction).toHaveBeenLastCalledWith(undefined);
});

it('emit shouldn\'t notify observers if they are registered for another event', () => {
it('emit shouldn\'t notify observers if they are registered for another event', async () => {

// Given: there is a registered callback function for the navigation end event.
const callBackFunction = jest.fn();
eventService.subscribe(EventName.NAVIGATION_END, callBackFunction);

// When: an event different of the navigation end event is emitted.
eventService.emit(new TestEvent(undefined));
await emitAndWait(eventService, new TestEvent(undefined));

// I expect the registered callback function not to be called
expect(callBackFunction).toHaveBeenCalledTimes(0);
});

it('emit shouldn\'t notify observers that are unsubscribed', () => {
it('emit shouldn\'t notify observers that are unsubscribed', async () => {

// Given there is a registered callback function for an event.
const callBackFunction = jest.fn();
Expand All @@ -65,21 +65,59 @@ describe('EventService', () => {
const unsubscribe = eventService.subscribe(TEST_EVENT_NAME, callBackFunctionTwo);

// When the event is emitted,
eventService.emit(new TestEvent(undefined));
await emitAndWait(eventService, new TestEvent(undefined));
// and unregister one of the functions (for example, the second one).
unsubscribe();
// and second event is emitted
eventService.emit(new TestEvent('test'));
await emitAndWait(eventService, new TestEvent('test'));

// Then I expect the first registered callback function to be called twice.
expect(callBackFunction).toHaveBeenCalledTimes(2);
// and the second one to be called only one
expect(callBackFunctionTwo).toHaveBeenCalledTimes(1);
});

test('should not emit an event if a cancellation handler prevents the notification', async () => {
// GIVEN: There is a registered event observer that cancels emission of the logout event.
const callBackFunction = jest.fn();
const shouldCancelHandler = () => Promise.resolve(true);
const unsubscribeCanceledObserver = eventService.subscribe(EventName.LOGOUT, callBackFunction, shouldCancelHandler);
// AND: There is a registered event observer that does not cancel emission of the logout event.
const callBackFunctionTwo = jest.fn();
const shouldCancelHandlerTwo = () => Promise.resolve(false);
eventService.subscribe(EventName.LOGOUT, callBackFunctionTwo, shouldCancelHandlerTwo);

// WHEN: I emit the logout event.
await emitAndWait(eventService, new Logout());
// THEN: I expect the callback functions not to be notified because a cancellation handler blocks the event emission.
expect(callBackFunction).toHaveBeenCalledTimes(0);
expect(callBackFunctionTwo).toHaveBeenCalledTimes(0);

// WHEN: I unsubscribe the observer that cancels the event.
unsubscribeCanceledObserver();
// AND: I emit the logout event.
await emitAndWait(eventService, new Logout());
// THEN: I expect the callback function to be called because there is no cancellation handler that blocks the event emission.
expect(callBackFunctionTwo).toHaveBeenCalledTimes(1);
});
});

class TestEvent extends Event<string> {
constructor(payload: string | undefined) {
super(TEST_EVENT_NAME, payload);
}
}

/**
* Emits the provided event and waits for all asynchronous event processing to complete before returning.
*
* @template T - The type of the event payload.
* @param eventService - The event service used to emit the event.
* @param event - The event to emit.
*/
export async function emitAndWait<T extends {} | undefined>(eventService: EventService, event: Event<T>): Promise<void> {
eventService.emit(event);
// emit() returns void, but it performs an asynchronous cancellation check before notifying subscribers.
// Wait for the pending promise chain to complete before verifying that the callbacks were called.
await new Promise(process.nextTick);
}
Loading