From 994a8b20bb716fa8f45f894d0cb2abd48bd2db6c Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 18 Oct 2024 17:02:48 -0400 Subject: [PATCH 01/12] Pass HTTP status code & errcode from CS-API errors --- src/ClientWidgetApi.ts | 36 +++++++++++++++++------ src/interfaces/IWidgetApiErrorResponse.ts | 20 +++++++++++-- src/transport/PostmessageTransport.ts | 16 +++++++++- 3 files changed, 59 insertions(+), 13 deletions(-) diff --git a/src/ClientWidgetApi.ts b/src/ClientWidgetApi.ts index de88957..80bddd3 100644 --- a/src/ClientWidgetApi.ts +++ b/src/ClientWidgetApi.ts @@ -22,7 +22,7 @@ import { WidgetApiDirection } from "./interfaces/WidgetApiDirection"; import { IWidgetApiRequest, IWidgetApiRequestEmptyData } from "./interfaces/IWidgetApiRequest"; import { IContentLoadedActionRequest } from "./interfaces/ContentLoadedAction"; import { WidgetApiFromWidgetAction, WidgetApiToWidgetAction } from "./interfaces/WidgetApiAction"; -import { IWidgetApiErrorResponseData } from "./interfaces/IWidgetApiErrorResponse"; +import { IWidgetApiErrorResponseData, isMatrixError } from "./interfaces/IWidgetApiErrorResponse"; import { Capability, MatrixCapabilities } from "./interfaces/Capabilities"; import { IOpenIDUpdate, ISendEventDetails, ISendDelayedEventDetails, WidgetDriver } from "./driver/WidgetDriver"; import { @@ -554,10 +554,13 @@ export class ClientWidgetApi extends EventEmitter { delay_id: sentEvent.delayId, }), }); - }).catch(e => { + }).catch((e: unknown) => { console.error("error sending event: ", e); return this.transport.reply(request, { - error: {message: "Error sending event"}, + error: { + message: "Error sending event", + ...(isMatrixError(e) && e), + }, }); }); } @@ -581,10 +584,13 @@ export class ClientWidgetApi extends EventEmitter { case UpdateDelayedEventAction.Send: this.driver.updateDelayedEvent(request.data.delay_id, request.data.action).then(() => { return this.transport.reply(request, {}); - }).catch(e => { + }).catch((e: unknown) => { console.error("error updating delayed event: ", e); return this.transport.reply(request, { - error: {message: "Error updating delayed event"}, + error: { + message: "Error updating delayed event", + ...(isMatrixError(e) && e), + }, }); }); break; @@ -736,7 +742,10 @@ export class ClientWidgetApi extends EventEmitter { } catch (e) { console.error("error getting the relations", e); await this.transport.reply(request, { - error: { message: "Unexpected error while reading relations" }, + error: { + message: "Unexpected error while reading relations", + ...(isMatrixError(e) && e), + }, }); } } @@ -779,7 +788,10 @@ export class ClientWidgetApi extends EventEmitter { } catch (e) { console.error("error searching in the user directory", e); await this.transport.reply(request, { - error: { message: "Unexpected error while searching in the user directory" }, + error: { + message: "Unexpected error while searching in the user directory", + ...(isMatrixError(e) && e), + }, }); } } @@ -801,7 +813,10 @@ export class ClientWidgetApi extends EventEmitter { } catch (e) { console.error("error while getting the media configuration", e); await this.transport.reply(request, { - error: { message: "Unexpected error while getting the media configuration" }, + error: { + message: "Unexpected error while getting the media configuration", + ...(isMatrixError(e) && e), + }, }); } } @@ -823,7 +838,10 @@ export class ClientWidgetApi extends EventEmitter { } catch (e) { console.error("error while uploading a file", e); await this.transport.reply(request, { - error: { message: "Unexpected error while uploading a file" }, + error: { + message: "Unexpected error while uploading a file", + ...(isMatrixError(e) && e), + }, }); } } diff --git a/src/interfaces/IWidgetApiErrorResponse.ts b/src/interfaces/IWidgetApiErrorResponse.ts index f9e123f..221b453 100644 --- a/src/interfaces/IWidgetApiErrorResponse.ts +++ b/src/interfaces/IWidgetApiErrorResponse.ts @@ -16,10 +16,24 @@ import { IWidgetApiResponse, IWidgetApiResponseData } from "./IWidgetApiResponse"; +interface IWidgetApiErrorData { + message: string; +} + +interface IMatrixErrorData { + httpStatus?: number; + errcode?: string; +} + export interface IWidgetApiErrorResponseData extends IWidgetApiResponseData { - error: { - message: string; - }; + error: IWidgetApiErrorData & IMatrixErrorData; +} + +export function isMatrixError(err: unknown): err is IMatrixErrorData { + return typeof err === "object" && err !== null && ( + "httpStatus" in err && typeof err.httpStatus === "number" || + "errcode" in err && typeof err.errcode === "string" + ); } export interface IWidgetApiErrorResponse extends IWidgetApiResponse { diff --git a/src/transport/PostmessageTransport.ts b/src/transport/PostmessageTransport.ts index 9f86aa5..4065ef7 100644 --- a/src/transport/PostmessageTransport.ts +++ b/src/transport/PostmessageTransport.ts @@ -35,6 +35,16 @@ interface IOutboundRequest { reject: (err: Error) => void; } +class MatrixError extends Error { + public constructor( + msg: string, + public readonly httpStatus?: number, + public readonly errcode?: string, + ) { + super(msg); + } +} + /** * Transport for the Widget API over postMessage. */ @@ -195,7 +205,11 @@ export class PostmessageTransport extends EventEmitter implements ITransport { if (isErrorResponse(response.response)) { const err = response.response; - req.reject(new Error(err.error.message)); + req.reject( + err.error.httpStatus !== undefined || err.error.errcode !== undefined + ? new MatrixError(err.error.message, err.error.httpStatus, err.error.errcode) + : new Error(err.error.message), + ); } else { req.resolve(response); } From 6abf9f942f7be955af1b2a98b6d6d07f43eca4b0 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 1 Nov 2024 01:18:28 -0400 Subject: [PATCH 02/12] (De)serialize error response details Allow client widget drivers to serialize Matrix API error responses into JSON to be received by the requesting widget. --- src/ClientWidgetApi.ts | 70 ++--- src/WidgetApi.ts | 11 +- src/driver/WidgetDriver.ts | 12 + src/interfaces/IWidgetApiErrorResponse.ts | 48 +-- src/transport/ITransport.ts | 20 +- src/transport/PostmessageTransport.ts | 22 +- test/ClientWidgetApi-test.ts | 357 +++++++++++++++++++++- 7 files changed, 442 insertions(+), 98 deletions(-) diff --git a/src/ClientWidgetApi.ts b/src/ClientWidgetApi.ts index 80bddd3..59634cd 100644 --- a/src/ClientWidgetApi.ts +++ b/src/ClientWidgetApi.ts @@ -22,7 +22,7 @@ import { WidgetApiDirection } from "./interfaces/WidgetApiDirection"; import { IWidgetApiRequest, IWidgetApiRequestEmptyData } from "./interfaces/IWidgetApiRequest"; import { IContentLoadedActionRequest } from "./interfaces/ContentLoadedAction"; import { WidgetApiFromWidgetAction, WidgetApiToWidgetAction } from "./interfaces/WidgetApiAction"; -import { IWidgetApiErrorResponseData, isMatrixError } from "./interfaces/IWidgetApiErrorResponse"; +import { IWidgetApiErrorResponseData } from "./interfaces/IWidgetApiErrorResponse"; import { Capability, MatrixCapabilities } from "./interfaces/Capabilities"; import { IOpenIDUpdate, ISendEventDetails, ISendDelayedEventDetails, WidgetDriver } from "./driver/WidgetDriver"; import { @@ -330,15 +330,13 @@ export class ClientWidgetApi extends EventEmitter { }); } - const onErr = (e: any) => { + const onErr = (e: unknown) => { console.error("[ClientWidgetApi] Failed to handle navigation: ", e); - return this.transport.reply(request, { - error: {message: "Error handling navigation"}, - }); + this.handleDriverError(e, request, "Error handling navigation"); }; try { - this.driver.navigate(request.data.uri.toString()).catch(e => onErr(e)).then(() => { + this.driver.navigate(request.data.uri.toString()).catch((e: unknown) => onErr(e)).then(() => { return this.transport.reply(request, {}); }); } catch (e) { @@ -556,12 +554,7 @@ export class ClientWidgetApi extends EventEmitter { }); }).catch((e: unknown) => { console.error("error sending event: ", e); - return this.transport.reply(request, { - error: { - message: "Error sending event", - ...(isMatrixError(e) && e), - }, - }); + this.handleDriverError(e, request, "Error sending event"); }); } @@ -586,12 +579,7 @@ export class ClientWidgetApi extends EventEmitter { return this.transport.reply(request, {}); }).catch((e: unknown) => { console.error("error updating delayed event: ", e); - return this.transport.reply(request, { - error: { - message: "Error updating delayed event", - ...(isMatrixError(e) && e), - }, - }); + this.handleDriverError(e, request, "Error updating delayed event"); }); break; default: @@ -624,9 +612,7 @@ export class ClientWidgetApi extends EventEmitter { await this.transport.reply(request, {}); } catch (e) { console.error("error sending to-device event", e); - await this.transport.reply(request, { - error: {message: "Error sending event"}, - }); + this.handleDriverError(e, request, "Error sending event"); } } } @@ -741,12 +727,7 @@ export class ClientWidgetApi extends EventEmitter { ); } catch (e) { console.error("error getting the relations", e); - await this.transport.reply(request, { - error: { - message: "Unexpected error while reading relations", - ...(isMatrixError(e) && e), - }, - }); + this.handleDriverError(e, request, "Unexpected error while reading relations"); } } @@ -787,12 +768,7 @@ export class ClientWidgetApi extends EventEmitter { ); } catch (e) { console.error("error searching in the user directory", e); - await this.transport.reply(request, { - error: { - message: "Unexpected error while searching in the user directory", - ...(isMatrixError(e) && e), - }, - }); + this.handleDriverError(e, request, "Unexpected error while searching in the user directory"); } } @@ -812,12 +788,7 @@ export class ClientWidgetApi extends EventEmitter { ); } catch (e) { console.error("error while getting the media configuration", e); - await this.transport.reply(request, { - error: { - message: "Unexpected error while getting the media configuration", - ...(isMatrixError(e) && e), - }, - }); + this.handleDriverError(e, request, "Unexpected error while getting the media configuration"); } } @@ -837,12 +808,7 @@ export class ClientWidgetApi extends EventEmitter { ); } catch (e) { console.error("error while uploading a file", e); - await this.transport.reply(request, { - error: { - message: "Unexpected error while uploading a file", - ...(isMatrixError(e) && e), - }, - }); + this.handleDriverError(e, request, "Unexpected error while uploading a file"); } } @@ -862,12 +828,20 @@ export class ClientWidgetApi extends EventEmitter { ); } catch (e) { console.error("error while downloading a file", e); - this.transport.reply(request, { - error: { message: "Unexpected error while downloading a file" }, - }); + this.handleDriverError(e, request, "Unexpected error while downloading a file"); } } + private handleDriverError(e: unknown, request: IWidgetApiRequest, message: string) { + const matrixApiError = this.driver.processError(e); + this.transport.reply(request, { + error: { + message, + ...(matrixApiError && { matrix_api_error: { ...matrixApiError } }), + }, + }); + } + private handleMessage(ev: CustomEvent) { if (this.isStopped) return; const actionEv = new CustomEvent(`action:${ev.detail.action}`, { diff --git a/src/WidgetApi.ts b/src/WidgetApi.ts index eb567d0..908afa2 100644 --- a/src/WidgetApi.ts +++ b/src/WidgetApi.ts @@ -33,7 +33,7 @@ import { import { ITransport } from "./transport/ITransport"; import { PostmessageTransport } from "./transport/PostmessageTransport"; import { WidgetApiFromWidgetAction, WidgetApiToWidgetAction } from "./interfaces/WidgetApiAction"; -import { IWidgetApiErrorResponseData } from "./interfaces/IWidgetApiErrorResponse"; +import { IWidgetApiErrorResponseData, IWidgetApiErrorResponseDataDetails } from "./interfaces/IWidgetApiErrorResponse"; import { IStickerActionRequestData } from "./interfaces/StickerAction"; import { IStickyActionRequestData, IStickyActionResponseData } from "./interfaces/StickyAction"; import { @@ -95,6 +95,15 @@ import { UpdateDelayedEventAction, } from "./interfaces/UpdateDelayedEventAction"; +export class WidgetApiResponseError extends Error { + public constructor( + message: string, + public readonly data: IWidgetApiErrorResponseDataDetails, + ) { + super(message); + } +} + /** * API handler for widgets. This raises events for each action * received as `action:${action}` (eg: "action:screenshot"). diff --git a/src/driver/WidgetDriver.ts b/src/driver/WidgetDriver.ts index 590d677..e4e7ca5 100644 --- a/src/driver/WidgetDriver.ts +++ b/src/driver/WidgetDriver.ts @@ -19,6 +19,7 @@ import { IOpenIDCredentials, OpenIDRequestState, SimpleObservable, + IMatrixApiError, IRoomEvent, IRoomAccountData, ITurnServer, @@ -358,4 +359,15 @@ export abstract class WidgetDriver { ): Promise<{ file: XMLHttpRequestBodyInit }> { throw new Error("Download file is not implemented"); } + + /** + * Expresses an error thrown by a Matrix API request made by this driver + * in a format compatible with the Widget API. + * @param error The error to handle. + * @returns The error expressed as a {@link IMatrixApiError}, + * or undefined if it cannot be expressed as one. + */ + public processError(error: unknown): IMatrixApiError | undefined { + return undefined; + } } diff --git a/src/interfaces/IWidgetApiErrorResponse.ts b/src/interfaces/IWidgetApiErrorResponse.ts index 221b453..a44d4d6 100644 --- a/src/interfaces/IWidgetApiErrorResponse.ts +++ b/src/interfaces/IWidgetApiErrorResponse.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Matrix.org Foundation C.I.C. + * Copyright 2020 - 2024 The Matrix.org Foundation C.I.C. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,34 +16,42 @@ import { IWidgetApiResponse, IWidgetApiResponseData } from "./IWidgetApiResponse"; -interface IWidgetApiErrorData { - message: string; +/** + * The format of errors returned by Matrix API requests + * made by a WidgetDriver. + */ +export interface IMatrixApiError { + /** The HTTP status code of the associated request. */ + http_status: number; // eslint-disable-line camelcase + /** Any HTTP response headers that are relevant to the error. */ + http_headers: {[name: string]: string}; // eslint-disable-line camelcase + /** The URL of the failed request. */ + url: string; + /** @see {@link https://spec.matrix.org/latest/client-server-api/#standard-error-response} */ + response: { + errcode: string; + error: string; + } & IWidgetApiResponseData; // extensible } -interface IMatrixErrorData { - httpStatus?: number; - errcode?: string; +export interface IWidgetApiErrorResponseDataDetails extends IWidgetApiResponseData { + /** Set if the error came from a Matrix API request made by a widget driver */ + matrix_api_error?: IMatrixApiError; // eslint-disable-line camelcase } export interface IWidgetApiErrorResponseData extends IWidgetApiResponseData { - error: IWidgetApiErrorData & IMatrixErrorData; -} - -export function isMatrixError(err: unknown): err is IMatrixErrorData { - return typeof err === "object" && err !== null && ( - "httpStatus" in err && typeof err.httpStatus === "number" || - "errcode" in err && typeof err.errcode === "string" - ); + error: { + /** A user-friendly string describing the error */ + message: string; + } & IWidgetApiErrorResponseDataDetails; } export interface IWidgetApiErrorResponse extends IWidgetApiResponse { response: IWidgetApiErrorResponseData; } -export function isErrorResponse(responseData: IWidgetApiResponseData): boolean { - if ("error" in responseData) { - const err = responseData; - return !!err.error.message; - } - return false; +export function isErrorResponse(responseData: IWidgetApiResponseData): responseData is IWidgetApiErrorResponseData { + const error = responseData.error; + return typeof error === "object" && error !== null && + "message" in error && typeof error.message === "string"; } diff --git a/src/transport/ITransport.ts b/src/transport/ITransport.ts index b3b2e9a..776c5f5 100644 --- a/src/transport/ITransport.ts +++ b/src/transport/ITransport.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Matrix.org Foundation C.I.C. + * Copyright 2020 - 2024 The Matrix.org Foundation C.I.C. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -71,11 +71,12 @@ export interface ITransport extends EventEmitter { /** * Sends a request to the remote end. - * @param {WidgetApiAction} action The action to send. - * @param {IWidgetApiRequestData} data The request data. - * @returns {Promise} A promise which resolves - * to the remote end's response, or throws with an Error if the request - * failed. + * @param action The action to send. + * @param data The request data. + * @returns A promise which resolves to the remote end's response. + * @throws {Error} if the request failed with a generic error. + * @throws {WidgetApiResponseError} if the request failed with error details + * that can be communicated to the Widget API. */ send( action: WidgetApiAction, @@ -88,9 +89,10 @@ export interface ITransport extends EventEmitter { * data. * @param {WidgetApiAction} action The action to send. * @param {IWidgetApiRequestData} data The request data. - * @returns {Promise} A promise which resolves - * to the remote end's response, or throws with an Error if the request - * failed. + * @returns {Promise} A promise which resolves to the remote end's response + * @throws {Error} if the request failed with a generic error. + * @throws {WidgetApiResponseError} if the request failed with error details + * that can be communicated to the Widget API. */ sendComplete(action: WidgetApiAction, data: T) : Promise; diff --git a/src/transport/PostmessageTransport.ts b/src/transport/PostmessageTransport.ts index 4065ef7..b836c6b 100644 --- a/src/transport/PostmessageTransport.ts +++ b/src/transport/PostmessageTransport.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Matrix.org Foundation C.I.C. + * Copyright 2020 - 2024 The Matrix.org Foundation C.I.C. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,11 +19,11 @@ import { ITransport } from "./ITransport"; import { invertedDirection, isErrorResponse, - IWidgetApiErrorResponseData, IWidgetApiRequest, IWidgetApiRequestData, IWidgetApiResponse, IWidgetApiResponseData, + WidgetApiResponseError, WidgetApiAction, WidgetApiDirection, WidgetApiToWidgetAction, @@ -35,16 +35,6 @@ interface IOutboundRequest { reject: (err: Error) => void; } -class MatrixError extends Error { - public constructor( - msg: string, - public readonly httpStatus?: number, - public readonly errcode?: string, - ) { - super(msg); - } -} - /** * Transport for the Widget API over postMessage. */ @@ -204,12 +194,8 @@ export class PostmessageTransport extends EventEmitter implements ITransport { if (!req) return; // response to an unknown request if (isErrorResponse(response.response)) { - const err = response.response; - req.reject( - err.error.httpStatus !== undefined || err.error.errcode !== undefined - ? new MatrixError(err.error.message, err.error.httpStatus, err.error.errcode) - : new Error(err.error.message), - ); + const {message, ...data} = response.response.error; + req.reject(new WidgetApiResponseError(message, data)); } else { req.resolve(response); } diff --git a/test/ClientWidgetApi-test.ts b/test/ClientWidgetApi-test.ts index 40b5b64..fb9d7a4 100644 --- a/test/ClientWidgetApi-test.ts +++ b/test/ClientWidgetApi-test.ts @@ -1,5 +1,6 @@ /* * Copyright 2022 Nordeck IT + Consulting GmbH. + * Copyright 2024 The Matrix.org Foundation C.I.C. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,6 +31,7 @@ import { Widget } from '../src/models/Widget'; import { PostmessageTransport } from '../src/transport/PostmessageTransport'; import { IDownloadFileActionFromWidgetActionRequest, + IMatrixApiError, IReadEventFromWidgetActionRequest, ISendEventFromWidgetActionRequest, IUpdateDelayedEventFromWidgetActionRequest, @@ -57,6 +59,30 @@ function createRoomEvent(event: Partial = {}): IRoomEvent { }; } +class CustomMatrixError extends Error { + constructor( + message: string, + public readonly httpStatus: number, + public readonly name: string, + public readonly data: Record, + ) { + super(message); + } +} + +function processCustomMatrixError(e: unknown): IMatrixApiError | undefined { + return e instanceof CustomMatrixError ? { + http_status: e.httpStatus, + http_headers: {}, + url: '', + response: { + errcode: e.name, + error: e.message, + ...e.data, + }, + } : undefined; +} + describe('ClientWidgetApi', () => { let capabilities: Capability[]; let iframe: HTMLIFrameElement; @@ -93,6 +119,7 @@ describe('ClientWidgetApi', () => { getMediaConfig: jest.fn(), uploadFile: jest.fn(), downloadFile: jest.fn(), + processError: jest.fn(), } as Partial as jest.Mocked; clientWidgetApi = new ClientWidgetApi( @@ -214,6 +241,87 @@ describe('ClientWidgetApi', () => { roomId, ); }); + + it('should reject requests when the driver throws an exception', async () => { + driver.sendEvent.mockRejectedValue( + new Error("M_BAD_JSON: Content must be a JSON object"), + ); + + const event: ISendEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.SendEvent, + data: { + type: 'm.room.message', + content: 'hello', + }, + }; + + await loadIframe([ + `org.matrix.msc2762.timeline:${event.data.room_id}`, + `org.matrix.msc2762.send.event:${event.data.type}`, + ]); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: 'Error sending event' }, + }); + }); + }); + + it('should reject with Matrix API error response thrown by driver', async () => { + driver.processError.mockImplementation(processCustomMatrixError); + + driver.sendEvent.mockRejectedValue( + new CustomMatrixError( + 'failed to send event', + 400, + 'M_NOT_JSON', + { + reason: 'Content must be a JSON object.', + }, + ), + ); + + const event: ISendEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.SendEvent, + data: { + type: 'm.room.message', + content: 'hello', + }, + }; + + await loadIframe([ + `org.matrix.msc2762.timeline:${event.data.room_id}`, + `org.matrix.msc2762.send.event:${event.data.type}`, + ]); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { + message: 'Error sending event', + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: '', + response: { + errcode: 'M_NOT_JSON', + error: 'failed to send event', + reason: 'Content must be a JSON object.', + }, + } satisfies IMatrixApiError, + }, + }); + }); + }); }); describe('send_event action for delayed events', () => { @@ -761,6 +869,51 @@ describe('ClientWidgetApi', () => { }); }); }); + + it('should reject with Matrix API error response thrown by driver', async () => { + driver.processError.mockImplementation(processCustomMatrixError); + + driver.readEventRelations.mockRejectedValue( + new CustomMatrixError( + 'failed to read relations', + 403, + 'M_FORBIDDEN', + { + reason: "You don't have permission to access that event", + }, + ), + ); + + const event: IReadRelationsFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.MSC3869ReadRelations, + data: { event_id: '$event' }, + }; + + await loadIframe(); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { + message: 'Unexpected error while reading relations', + matrix_api_error: { + http_status: 403, + http_headers: {}, + url: '', + response: { + errcode: 'M_FORBIDDEN', + error: 'failed to read relations', + reason: "You don't have permission to access that event", + }, + } satisfies IMatrixApiError, + }, + }); + }); + }); }); describe('org.matrix.msc3973.user_directory_search action', () => { @@ -991,6 +1144,55 @@ describe('ClientWidgetApi', () => { }); }); }); + + it('should reject with Matrix API error response thrown by driver', async () => { + driver.processError.mockImplementation(processCustomMatrixError); + + driver.searchUserDirectory.mockRejectedValue( + new CustomMatrixError( + 'failed to search the user directory', + 429, + 'M_LIMIT_EXCEEDED', + { + reason: 'Too many requests', + retry_after_ms: 2000, + }, + ), + ); + + const event: IUserDirectorySearchFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, + data: { search_term: 'foo' }, + }; + + await loadIframe([ + 'org.matrix.msc3973.user_directory_search', + ]); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { + message: 'Unexpected error while searching in the user directory', + matrix_api_error: { + http_status: 429, + http_headers: {}, + url: '', + response: { + errcode: 'M_LIMIT_EXCEEDED', + error: 'failed to search the user directory', + reason: 'Too many requests', + retry_after_ms: 2000, + }, + } satisfies IMatrixApiError, + }, + }); + }); + }); }); describe('org.matrix.msc4039.get_media_config action', () => { @@ -1083,6 +1285,55 @@ describe('ClientWidgetApi', () => { }); }); }); + + it('should reject with Matrix API error response thrown by driver', async () => { + driver.processError.mockImplementation(processCustomMatrixError); + + driver.getMediaConfig.mockRejectedValue( + new CustomMatrixError( + 'failed to get the media configuration', + 429, + 'M_LIMIT_EXCEEDED', + { + reason: 'Too many requests', + retry_after_ms: 2000, + }, + ), + ); + + const event: IGetMediaConfigActionFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, + data: {}, + }; + + await loadIframe([ + 'org.matrix.msc4039.upload_file', + ]); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { + message: 'Unexpected error while getting the media configuration', + matrix_api_error: { + http_status: 429, + http_headers: {}, + url: '', + response: { + errcode: 'M_LIMIT_EXCEEDED', + error: 'failed to get the media configuration', + reason: 'Too many requests', + retry_after_ms: 2000, + }, + } satisfies IMatrixApiError, + }, + }); + }); + }); }); describe('MSC4039', () => { @@ -1157,7 +1408,7 @@ describe('ClientWidgetApi', () => { }); it('should reject requests when the driver throws an exception', async () => { - driver.getMediaConfig.mockRejectedValue( + driver.uploadFile.mockRejectedValue( new Error("M_LIMIT_EXCEEDED: Too many requests"), ); @@ -1183,6 +1434,57 @@ describe('ClientWidgetApi', () => { }); }); }); + + it('should reject with Matrix API error response thrown by driver', async () => { + driver.processError.mockImplementation(processCustomMatrixError); + + driver.uploadFile.mockRejectedValue( + new CustomMatrixError( + 'failed to upload a file', + 429, + 'M_LIMIT_EXCEEDED', + { + reason: 'Too many requests', + retry_after_ms: 2000, + }, + ), + ); + + const event: IUploadFileActionFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.MSC4039UploadFileAction, + data: { + file: 'data', + }, + }; + + await loadIframe([ + 'org.matrix.msc4039.upload_file', + ]); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { + message: 'Unexpected error while uploading a file', + matrix_api_error: { + http_status: 429, + http_headers: {}, + url: '', + response: { + errcode: 'M_LIMIT_EXCEEDED', + error: 'failed to upload a file', + reason: 'Too many requests', + retry_after_ms: 2000, + }, + } satisfies IMatrixApiError, + }, + }); + }); + }); }); describe('org.matrix.msc4039.download_file action', () => { @@ -1237,7 +1539,7 @@ describe('ClientWidgetApi', () => { }); it('should reject requests when the driver throws an exception', async () => { - driver.getMediaConfig.mockRejectedValue( + driver.downloadFile.mockRejectedValue( new Error("M_LIMIT_EXCEEDED: Too many requests"), ); @@ -1263,5 +1565,56 @@ describe('ClientWidgetApi', () => { }); }); }); + + it('should reject with Matrix API error response thrown by driver', async () => { + driver.processError.mockImplementation(processCustomMatrixError); + + driver.downloadFile.mockRejectedValue( + new CustomMatrixError( + 'failed to download a file', + 429, + 'M_LIMIT_EXCEEDED', + { + reason: 'Too many requests', + retry_after_ms: 2000, + }, + ), + ); + + const event: IDownloadFileActionFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.MSC4039DownloadFileAction, + data: { + content_uri: 'mxc://example.com/test_file', + }, + }; + + await loadIframe([ + 'org.matrix.msc4039.download_file', + ]); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { + message: 'Unexpected error while downloading a file', + matrix_api_error: { + http_status: 429, + http_headers: {}, + url: '', + response: { + errcode: 'M_LIMIT_EXCEEDED', + error: 'failed to download a file', + reason: 'Too many requests', + retry_after_ms: 2000, + }, + } satisfies IMatrixApiError, + }, + }); + }); + }); }); }); From f8f86ef17a0021e7a1e71c4e139a3bc55890c448 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 1 Nov 2024 15:29:42 -0400 Subject: [PATCH 03/12] Override name property of WidgetApiResponseError --- src/WidgetApi.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/WidgetApi.ts b/src/WidgetApi.ts index 908afa2..e02176f 100644 --- a/src/WidgetApi.ts +++ b/src/WidgetApi.ts @@ -96,6 +96,10 @@ import { } from "./interfaces/UpdateDelayedEventAction"; export class WidgetApiResponseError extends Error { + static { + this.prototype.name = this.name; + } + public constructor( message: string, public readonly data: IWidgetApiErrorResponseDataDetails, From af386551f3858b20e8e26228c8ca0aa11be84e2e Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Tue, 5 Nov 2024 10:51:38 -0500 Subject: [PATCH 04/12] Disable babel's no-invalid-this rule because Typescript has its own version of that rule --- .eslintrc.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.eslintrc.js b/.eslintrc.js index 65a34bd..5d451d2 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -31,6 +31,9 @@ module.exports = { "files": ["src/**/*.ts", "test/**/*.ts"], "extends": ["matrix-org/ts"], "rules": { + // TypeScript has its own version of this + "babel/no-invalid-this": "off", + "quotes": "off", }, }], From 3b69837836e00a55320fb0e5bfc6bfb0594dd5ca Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Tue, 5 Nov 2024 11:02:56 -0500 Subject: [PATCH 05/12] Increase test coverage Mock client-side responses to test deserializing them on the widget side --- test/WidgetApi-test.ts | 596 ++++++++++++++++++++++++++++++++++------- 1 file changed, 506 insertions(+), 90 deletions(-) diff --git a/test/WidgetApi-test.ts b/test/WidgetApi-test.ts index 4d17d6f..668c2cb 100644 --- a/test/WidgetApi-test.ts +++ b/test/WidgetApi-test.ts @@ -23,28 +23,110 @@ import { IUploadFileActionFromWidgetResponseData } from '../src/interfaces/Uploa import { IDownloadFileActionFromWidgetResponseData } from '../src/interfaces/DownloadFileAction'; import { IUserDirectorySearchFromWidgetResponseData } from '../src/interfaces/UserDirectorySearchAction'; import { WidgetApiFromWidgetAction } from '../src/interfaces/WidgetApiAction'; -import { PostmessageTransport } from '../src/transport/PostmessageTransport'; -import { WidgetApi } from '../src/WidgetApi'; - -jest.mock('../src/transport/PostmessageTransport') +import { WidgetApi, WidgetApiResponseError } from '../src/WidgetApi'; +import { + IWidgetApiErrorResponseData, + IWidgetApiErrorResponseDataDetails, + IWidgetApiRequest, + IWidgetApiRequestData, + IWidgetApiResponse, + IWidgetApiResponseData, + UpdateDelayedEventAction, + WidgetApiDirection, +} from '../src'; + +type SendRequestArgs = { + action: WidgetApiFromWidgetAction, + data: IWidgetApiRequestData, +}; + +class TransportChannels { + /** Data sent by widget requests */ + public readonly requestQueue: Array = []; + /** Responses to send as if from a client. Initialized with the response to {@link WidgetApi.start}*/ + public readonly responseQueue: IWidgetApiResponseData[] = [ + { supported_versions: [] } satisfies ISupportedVersionsActionResponseData, + ]; +} + +class WidgetTransportHelper { + /** For ignoring the request sent by {@link WidgetApi.start} */ + private skippedFirstRequest = false; + + constructor(private channels: TransportChannels) {} + + public nextTrackedRequest(): SendRequestArgs | undefined { + if (!this.skippedFirstRequest) { + this.skippedFirstRequest = true; + this.channels.requestQueue.shift(); + } + return this.channels.requestQueue.shift(); + } + + public queueResponse(data: IWidgetApiResponseData): void { + this.channels.responseQueue.push(data); + } +} + +class ClientTransportHelper { + constructor(private channels: TransportChannels) {} + + public trackRequest(action: WidgetApiFromWidgetAction, data: IWidgetApiRequestData): void { + this.channels.requestQueue.push({action, data}); + } + + public nextQueuedResponse(): IWidgetApiRequestData | undefined { + return this.channels.responseQueue.shift(); + } +} describe('WidgetApi', () => { let widgetApi: WidgetApi; + let widgetTransportHelper: WidgetTransportHelper; + let clientListener: (e: MessageEvent) => void; beforeEach(() => { - widgetApi = new WidgetApi() + const channels = new TransportChannels(); + widgetTransportHelper = new WidgetTransportHelper(channels); + const clientTrafficHelper = new ClientTransportHelper(channels); + + clientListener = (e: MessageEvent) => { + if (!e.data.action || !e.data.requestId || !e.data.widgetId) return; // invalid request/response + if ("response" in e.data || e.data.api !== WidgetApiDirection.FromWidget) return; // not a request + const request = e.data; + + clientTrafficHelper.trackRequest( + request.action as WidgetApiFromWidgetAction, + request.data, + ); + + const response = clientTrafficHelper.nextQueuedResponse(); + if (response) { + window.postMessage( + { + ...request, + response: response, + } satisfies IWidgetApiResponse, + "*", + ); + } + } + window.addEventListener("message", clientListener); + + widgetApi = new WidgetApi("WidgetApi-test", "*"); + widgetApi.start(); }); afterEach(() => { - jest.resetAllMocks(); + window.removeEventListener("message", clientListener); }); describe('readEventRelations', () => { it('should forward the request to the ClientWidgetApi', async () => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + widgetTransportHelper.queueResponse( { supported_versions: [UnstableApiVersion.MSC3869] } as ISupportedVersionsActionResponseData, ); - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValue( + widgetTransportHelper.queueResponse( { chunk: [], } as IReadRelationsFromWidgetResponseData, @@ -57,20 +139,24 @@ describe('WidgetApi', () => { chunk: [], }); - expect(PostmessageTransport.prototype.send).toBeCalledWith(WidgetApiFromWidgetAction.MSC3869ReadRelations, { - event_id: '$event', - room_id: '!room-id', - rel_type: 'm.reference', - event_type: 'm.room.message', - limit: 25, - from: 'from-token', - to: 'to-token', - direction: 'f', - }); + expect(widgetTransportHelper.nextTrackedRequest()).not.toBeUndefined(); + expect(widgetTransportHelper.nextTrackedRequest()).toEqual({ + action: WidgetApiFromWidgetAction.MSC3869ReadRelations, + data: { + event_id: '$event', + room_id: '!room-id', + rel_type: 'm.reference', + event_type: 'm.room.message', + limit: 25, + from: 'from-token', + to: 'to-token', + direction: 'f', + }, + } satisfies SendRequestArgs); }); it('should reject the request if the api is not supported', async () => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + widgetTransportHelper.queueResponse( { supported_versions: [] } as ISupportedVersionsActionResponseData, ); @@ -79,16 +165,20 @@ describe('WidgetApi', () => { 'from-token', 'to-token', 'f', )).rejects.toThrow("The read_relations action is not supported by the client."); - expect(PostmessageTransport.prototype.send) - .not.toBeCalledWith(WidgetApiFromWidgetAction.MSC3869ReadRelations, expect.anything()); + const request = widgetTransportHelper.nextTrackedRequest(); + expect(request).not.toBeUndefined(); + expect(request).not.toEqual({ + action: WidgetApiFromWidgetAction.MSC3869ReadRelations, + data: expect.anything(), + } satisfies SendRequestArgs) }); it('should handle an error', async () => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + widgetTransportHelper.queueResponse( { supported_versions: [UnstableApiVersion.MSC3869] } as ISupportedVersionsActionResponseData, ); - jest.mocked(PostmessageTransport.prototype.send).mockRejectedValue( - new Error('An error occurred'), + widgetTransportHelper.queueResponse( + { error: { message: 'An error occurred' } } as IWidgetApiErrorResponseData, ); await expect(widgetApi.readEventRelations( @@ -96,19 +186,49 @@ describe('WidgetApi', () => { 'from-token', 'to-token', 'f', )).rejects.toThrow('An error occurred'); }); + + it('should handle an error with details', async () => { + widgetTransportHelper.queueResponse( + { supported_versions: [UnstableApiVersion.MSC3869] } as ISupportedVersionsActionResponseData, + ); + + const errorDetails: IWidgetApiErrorResponseDataDetails = { + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: "", + response: { + errcode: "M_UNKNOWN", + error: 'Unknown error', + }, + }, + }; + + widgetTransportHelper.queueResponse( + { + error: { + message: 'An error occurred', + ...errorDetails, + }, + } as IWidgetApiErrorResponseData, + ); + + await expect(widgetApi.readEventRelations( + '$event', '!room-id', 'm.reference', 'm.room.message', 25, + 'from-token', 'to-token', 'f', + )).rejects.toThrow(new WidgetApiResponseError('An error occurred', errorDetails)); + }); }); describe('sendEvent', () => { - beforeEach(() => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + it('sends message events', async () => { + widgetTransportHelper.queueResponse( { room_id: '!room-id', event_id: '$event', } as ISendEventFromWidgetResponseData, ); - }); - it('sends message events', async () => { await expect(widgetApi.sendRoomEvent( 'm.room.message', {}, @@ -120,6 +240,13 @@ describe('WidgetApi', () => { }); it('sends state events', async () => { + widgetTransportHelper.queueResponse( + { + room_id: '!room-id', + event_id: '$event', + } as ISendEventFromWidgetResponseData, + ); + await expect(widgetApi.sendStateEvent( 'm.room.topic', "", @@ -130,19 +257,58 @@ describe('WidgetApi', () => { event_id: '$event', }); }); + + it('should handle an error', async () => { + widgetTransportHelper.queueResponse( + { error: { message: 'An error occurred' } } as IWidgetApiErrorResponseData, + ); + + await expect(widgetApi.sendRoomEvent( + 'm.room.message', + {}, + '!room-id', + )).rejects.toThrow('An error occurred'); + }); + + it('should handle an error with details', async () => { + const errorDetails: IWidgetApiErrorResponseDataDetails = { + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: "", + response: { + errcode: "M_UNKNOWN", + error: 'Unknown error', + }, + }, + }; + + widgetTransportHelper.queueResponse( + { + error: { + message: 'An error occurred', + ...errorDetails, + }, + } as IWidgetApiErrorResponseData, + ); + + await expect(widgetApi.sendRoomEvent( + 'm.room.message', + {}, + '!room-id', + )).rejects.toThrow(new WidgetApiResponseError('An error occurred', errorDetails)); + }); }); describe('delayed sendEvent', () => { - beforeEach(() => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + it('sends delayed message events', async () => { + widgetTransportHelper.queueResponse( { room_id: '!room-id', delay_id: 'id', } as ISendEventFromWidgetResponseData, ); - }); - it('sends delayed message events', async () => { await expect(widgetApi.sendRoomEvent( 'm.room.message', {}, @@ -155,6 +321,13 @@ describe('WidgetApi', () => { }); it('sends delayed state events', async () => { + widgetTransportHelper.queueResponse( + { + room_id: '!room-id', + delay_id: 'id', + } as ISendEventFromWidgetResponseData, + ); + await expect(widgetApi.sendStateEvent( 'm.room.topic', "", @@ -168,12 +341,19 @@ describe('WidgetApi', () => { }); it('sends delayed child action message events', async () => { + widgetTransportHelper.queueResponse( + { + room_id: '!room-id', + delay_id: 'id', + } as ISendEventFromWidgetResponseData, + ); + await expect(widgetApi.sendRoomEvent( 'm.room.message', {}, '!room-id', - null, - 'id-parent', + 1000, + undefined, )).resolves.toEqual({ room_id: '!room-id', delay_id: 'id', @@ -181,33 +361,124 @@ describe('WidgetApi', () => { }); it('sends delayed child action state events', async () => { + widgetTransportHelper.queueResponse( + { + room_id: '!room-id', + delay_id: 'id', + } as ISendEventFromWidgetResponseData, + ); + await expect(widgetApi.sendStateEvent( 'm.room.topic', "", {}, '!room-id', - null, - 'id-parent', + 1000, + undefined, )).resolves.toEqual({ room_id: '!room-id', delay_id: 'id', }); }); + + it('should handle an error', async () => { + widgetTransportHelper.queueResponse( + { error: { message: 'An error occurred' } } as IWidgetApiErrorResponseData, + ); + + await expect(widgetApi.sendRoomEvent( + 'm.room.message', + {}, + '!room-id', + 1000, + undefined, + )).rejects.toThrow('An error occurred'); + }); + + it('should handle an error with details', async () => { + const errorDetails: IWidgetApiErrorResponseDataDetails = { + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: "", + response: { + errcode: "M_UNKNOWN", + error: 'Unknown error', + }, + }, + }; + + widgetTransportHelper.queueResponse( + { + error: { + message: 'An error occurred', + ...errorDetails, + }, + } as IWidgetApiErrorResponseData, + ); + + await expect(widgetApi.sendRoomEvent( + 'm.room.message', + {}, + '!room-id', + 1000, + undefined, + )).rejects.toThrow(new WidgetApiResponseError('An error occurred', errorDetails)); + }); }); describe('updateDelayedEvent', () => { - beforeEach(() => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce({}); + it('updates delayed events', async () => { + widgetTransportHelper.queueResponse({}); + await expect(widgetApi.updateDelayedEvent( + 'id', + UpdateDelayedEventAction.Send, + )).resolves.toEqual({}); }); - it('updates delayed events', async () => { - await expect(widgetApi.updateDelayedEvent('id', 'send')).resolves.toEqual({}); + it('should handle an error', async () => { + widgetTransportHelper.queueResponse( + { error: { message: 'An error occurred' } } as IWidgetApiErrorResponseData, + ); + + await expect(widgetApi.updateDelayedEvent( + 'id', + UpdateDelayedEventAction.Send, + )).rejects.toThrow('An error occurred'); + }); + + it('should handle an error with details', async () => { + const errorDetails: IWidgetApiErrorResponseDataDetails = { + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: "", + response: { + errcode: "M_UNKNOWN", + error: 'Unknown error', + }, + }, + }; + + widgetTransportHelper.queueResponse( + { + error: { + message: 'An error occurred', + ...errorDetails, + }, + } as IWidgetApiErrorResponseData, + ); + + await expect(widgetApi.updateDelayedEvent( + 'id', + UpdateDelayedEventAction.Send, + )).rejects.toThrow(new WidgetApiResponseError('An error occurred', errorDetails)); }); }); describe('getClientVersions', () => { beforeEach(() => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + widgetTransportHelper.queueResponse( { supported_versions: [ UnstableApiVersion.MSC3869, UnstableApiVersion.MSC2762, @@ -231,16 +502,17 @@ describe('WidgetApi', () => { 'org.matrix.msc3869', 'org.matrix.msc2762', ]); - expect(PostmessageTransport.prototype.send).toBeCalledTimes(1); + expect(widgetTransportHelper.nextTrackedRequest()).not.toBeUndefined(); + expect(widgetTransportHelper.nextTrackedRequest()).toBeUndefined(); }) }); describe('searchUserDirectory', () => { it('should forward the request to the ClientWidgetApi', async () => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + widgetTransportHelper.queueResponse( { supported_versions: [UnstableApiVersion.MSC3973] } as ISupportedVersionsActionResponseData, ); - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValue( + widgetTransportHelper.queueResponse( { limited: false, results: [], @@ -254,17 +526,18 @@ describe('WidgetApi', () => { results: [], }); - expect(PostmessageTransport.prototype.send).toBeCalledWith( - WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, - { + expect(widgetTransportHelper.nextTrackedRequest()).not.toBeUndefined(); + expect(widgetTransportHelper.nextTrackedRequest()).toEqual({ + action: WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, + data: { search_term: 'foo', limit: 10, }, - ); + } satisfies SendRequestArgs); }); it('should reject the request if the api is not supported', async () => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + widgetTransportHelper.queueResponse( { supported_versions: [] } as ISupportedVersionsActionResponseData, ); @@ -272,30 +545,65 @@ describe('WidgetApi', () => { 'foo', 10, )).rejects.toThrow("The user_directory_search action is not supported by the client."); - expect(PostmessageTransport.prototype.send) - .not.toBeCalledWith(WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, expect.anything()); + const request = widgetTransportHelper.nextTrackedRequest(); + expect(request).not.toBeUndefined(); + expect(request).not.toEqual({ + action: WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, + data: expect.anything(), + } satisfies SendRequestArgs); }); it('should handle an error', async () => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + widgetTransportHelper.queueResponse( { supported_versions: [UnstableApiVersion.MSC3973] } as ISupportedVersionsActionResponseData, ); - jest.mocked(PostmessageTransport.prototype.send).mockRejectedValue( - new Error('An error occurred'), + widgetTransportHelper.queueResponse( + { error: { message: 'An error occurred' } }, ); await expect(widgetApi.searchUserDirectory( 'foo', 10, )).rejects.toThrow('An error occurred'); }); + + it('should handle an error with details', async () => { + widgetTransportHelper.queueResponse( + { supported_versions: [UnstableApiVersion.MSC3973] } as ISupportedVersionsActionResponseData, + ); + + const errorDetails: IWidgetApiErrorResponseDataDetails = { + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: "", + response: { + errcode: "M_UNKNOWN", + error: 'Unknown error', + }, + }, + }; + + widgetTransportHelper.queueResponse( + { + error: { + message: 'An error occurred', + ...errorDetails, + }, + } as IWidgetApiErrorResponseData, + ); + + await expect(widgetApi.searchUserDirectory( + 'foo', 10, + )).rejects.toThrow(new WidgetApiResponseError('An error occurred', errorDetails)); + }); }); describe('getMediaConfig', () => { it('should forward the request to the ClientWidgetApi', async () => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + widgetTransportHelper.queueResponse( { supported_versions: [UnstableApiVersion.MSC4039] } as ISupportedVersionsActionResponseData, ); - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValue( + widgetTransportHelper.queueResponse( { 'm.upload.size': 1000 } as IGetMediaConfigActionFromWidgetResponseData, ); @@ -303,14 +611,15 @@ describe('WidgetApi', () => { 'm.upload.size': 1000, }); - expect(PostmessageTransport.prototype.send).toBeCalledWith( - WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, - {}, - ); + expect(widgetTransportHelper.nextTrackedRequest()).not.toBeUndefined(); + expect(widgetTransportHelper.nextTrackedRequest()).toEqual({ + action: WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, + data: {}, + } satisfies SendRequestArgs); }); it('should reject the request if the api is not supported', async () => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + widgetTransportHelper.queueResponse( { supported_versions: [] } as ISupportedVersionsActionResponseData, ); @@ -318,30 +627,65 @@ describe('WidgetApi', () => { "The get_media_config action is not supported by the client.", ); - expect(PostmessageTransport.prototype.send) - .not.toBeCalledWith(WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, expect.anything()); + const request = widgetTransportHelper.nextTrackedRequest(); + expect(request).not.toBeUndefined(); + expect(request).not.toEqual({ + action: WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, + data: expect.anything(), + } satisfies SendRequestArgs); }); it('should handle an error', async () => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + widgetTransportHelper.queueResponse( { supported_versions: [UnstableApiVersion.MSC4039] } as ISupportedVersionsActionResponseData, ); - jest.mocked(PostmessageTransport.prototype.send).mockRejectedValue( - new Error('An error occurred'), + widgetTransportHelper.queueResponse( + { error: { message: 'An error occurred' } }, ); await expect(widgetApi.getMediaConfig()).rejects.toThrow( 'An error occurred', ); }); + + it('should handle an error with details', async () => { + widgetTransportHelper.queueResponse( + { supported_versions: [UnstableApiVersion.MSC4039] } as ISupportedVersionsActionResponseData, + ); + + const errorDetails: IWidgetApiErrorResponseDataDetails = { + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: "", + response: { + errcode: "M_UNKNOWN", + error: 'Unknown error', + }, + }, + }; + + widgetTransportHelper.queueResponse( + { + error: { + message: 'An error occurred', + ...errorDetails, + }, + } as IWidgetApiErrorResponseData, + ); + + await expect(widgetApi.getMediaConfig()).rejects.toThrow( + new WidgetApiResponseError('An error occurred', errorDetails), + ); + }); }); describe('uploadFile', () => { it('should forward the request to the ClientWidgetApi', async () => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + widgetTransportHelper.queueResponse( { supported_versions: [UnstableApiVersion.MSC4039] } as ISupportedVersionsActionResponseData, ); - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValue( + widgetTransportHelper.queueResponse( { content_uri: 'mxc://...' } as IUploadFileActionFromWidgetResponseData, ); @@ -349,14 +693,15 @@ describe('WidgetApi', () => { content_uri: 'mxc://...', }); - expect(PostmessageTransport.prototype.send).toBeCalledWith( - WidgetApiFromWidgetAction.MSC4039UploadFileAction, - { file: "data" }, - ); + expect(widgetTransportHelper.nextTrackedRequest()).not.toBeUndefined(); + expect(widgetTransportHelper.nextTrackedRequest()).toEqual({ + action: WidgetApiFromWidgetAction.MSC4039UploadFileAction, + data: { file: "data" }, + } satisfies SendRequestArgs); }); it('should reject the request if the api is not supported', async () => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + widgetTransportHelper.queueResponse( { supported_versions: [] } as ISupportedVersionsActionResponseData, ); @@ -364,30 +709,65 @@ describe('WidgetApi', () => { "The upload_file action is not supported by the client.", ); - expect(PostmessageTransport.prototype.send) - .not.toBeCalledWith(WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, expect.anything()); + const request = widgetTransportHelper.nextTrackedRequest(); + expect(request).not.toBeUndefined(); + expect(request).not.toEqual({ + action: WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, + data: expect.anything(), + } satisfies SendRequestArgs); }); it('should handle an error', async () => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + widgetTransportHelper.queueResponse( { supported_versions: [UnstableApiVersion.MSC4039] } as ISupportedVersionsActionResponseData, ); - jest.mocked(PostmessageTransport.prototype.send).mockRejectedValue( - new Error('An error occurred'), + widgetTransportHelper.queueResponse( + { error: { message: 'An error occurred' } }, ); await expect(widgetApi.uploadFile("data")).rejects.toThrow( 'An error occurred', ); }); + + it('should handle an error with details', async () => { + widgetTransportHelper.queueResponse( + { supported_versions: [UnstableApiVersion.MSC4039] } as ISupportedVersionsActionResponseData, + ); + + const errorDetails: IWidgetApiErrorResponseDataDetails = { + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: "", + response: { + errcode: "M_UNKNOWN", + error: 'Unknown error', + }, + }, + }; + + widgetTransportHelper.queueResponse( + { + error: { + message: 'An error occurred', + ...errorDetails, + }, + } as IWidgetApiErrorResponseData, + ); + + await expect(widgetApi.uploadFile("data")).rejects.toThrow( + new WidgetApiResponseError('An error occurred', errorDetails), + ); + }); }); describe('downloadFile', () => { it('should forward the request to the ClientWidgetApi', async () => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + widgetTransportHelper.queueResponse( { supported_versions: [UnstableApiVersion.MSC4039] } as ISupportedVersionsActionResponseData, ); - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValue( + widgetTransportHelper.queueResponse( { file: 'test contents' } as IDownloadFileActionFromWidgetResponseData, ); @@ -395,14 +775,15 @@ describe('WidgetApi', () => { file: 'test contents', }); - expect(PostmessageTransport.prototype.send).toHaveBeenCalledWith( - WidgetApiFromWidgetAction.MSC4039DownloadFileAction, - { content_uri: "mxc://example.com/test_file" }, - ); + expect(widgetTransportHelper.nextTrackedRequest()).not.toBeUndefined(); + expect(widgetTransportHelper.nextTrackedRequest()).toEqual({ + action: WidgetApiFromWidgetAction.MSC4039DownloadFileAction, + data: { content_uri: "mxc://example.com/test_file" }, + } satisfies SendRequestArgs); }); it('should reject the request if the api is not supported', async () => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + widgetTransportHelper.queueResponse( { supported_versions: [] } as ISupportedVersionsActionResponseData, ); @@ -410,21 +791,56 @@ describe('WidgetApi', () => { "The download_file action is not supported by the client.", ); - expect(PostmessageTransport.prototype.send) - .not.toHaveBeenCalledWith(WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, expect.anything()); + const request = widgetTransportHelper.nextTrackedRequest(); + expect(request).not.toBeUndefined(); + expect(request).not.toEqual({ + action: WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, + data: expect.anything(), + } satisfies SendRequestArgs); }); it('should handle an error', async () => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + widgetTransportHelper.queueResponse( { supported_versions: [UnstableApiVersion.MSC4039] } as ISupportedVersionsActionResponseData, ); - jest.mocked(PostmessageTransport.prototype.send).mockRejectedValue( - new Error('An error occurred'), + widgetTransportHelper.queueResponse( + { error: { message: 'An error occurred' } }, ); await expect(widgetApi.downloadFile("mxc://example.com/test_file")).rejects.toThrow( 'An error occurred', ); }); + + it('should handle an error with details', async () => { + widgetTransportHelper.queueResponse( + { supported_versions: [UnstableApiVersion.MSC4039] } as ISupportedVersionsActionResponseData, + ); + + const errorDetails: IWidgetApiErrorResponseDataDetails = { + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: "", + response: { + errcode: "M_UNKNOWN", + error: 'Unknown error', + }, + }, + }; + + widgetTransportHelper.queueResponse( + { + error: { + message: 'An error occurred', + ...errorDetails, + }, + } as IWidgetApiErrorResponseData, + ); + + await expect(widgetApi.downloadFile("mxc://example.com/test_file")).rejects.toThrow( + new WidgetApiResponseError('An error occurred', errorDetails), + ); + }); }); }); From fc48df3b20a49456243b0116825a9c810febeb98 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Tue, 5 Nov 2024 12:05:42 -0500 Subject: [PATCH 06/12] Increase test coverage some more --- test/ClientWidgetApi-test.ts | 162 +++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) diff --git a/test/ClientWidgetApi-test.ts b/test/ClientWidgetApi-test.ts index fb9d7a4..d9ce7dc 100644 --- a/test/ClientWidgetApi-test.ts +++ b/test/ClientWidgetApi-test.ts @@ -453,6 +453,93 @@ describe('ClientWidgetApi', () => { roomId, ); }); + + it('should reject requests when the driver throws an exception', async () => { + driver.sendDelayedEvent.mockRejectedValue( + new Error("M_BAD_JSON: Content must be a JSON object"), + ); + + const event: ISendEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.SendEvent, + data: { + type: 'm.room.message', + content: 'hello', + delay: 5000, + parent_delay_id: 'fp', + }, + }; + + await loadIframe([ + `org.matrix.msc2762.timeline:${event.data.room_id}`, + `org.matrix.msc2762.send.event:${event.data.type}`, + 'org.matrix.msc4157.send.delayed_event', + ]); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: 'Error sending event' }, + }); + }); + }); + + it('should reject with Matrix API error response thrown by driver', async () => { + driver.processError.mockImplementation(processCustomMatrixError); + + driver.sendDelayedEvent.mockRejectedValue( + new CustomMatrixError( + 'failed to send event', + 400, + 'M_NOT_JSON', + { + reason: 'Content must be a JSON object.', + }, + ), + ); + + const event: ISendEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.SendEvent, + data: { + type: 'm.room.message', + content: 'hello', + delay: 5000, + parent_delay_id: 'fp', + }, + }; + + await loadIframe([ + `org.matrix.msc2762.timeline:${event.data.room_id}`, + `org.matrix.msc2762.send.event:${event.data.type}`, + 'org.matrix.msc4157.send.delayed_event', + ]); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { + message: 'Error sending event', + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: '', + response: { + errcode: 'M_NOT_JSON', + error: 'failed to send event', + reason: 'Content must be a JSON object.', + }, + } satisfies IMatrixApiError, + }, + }); + }); + }); }); describe('update_delayed_event action', () => { @@ -539,6 +626,81 @@ describe('ClientWidgetApi', () => { ); } }); + + it('should reject requests when the driver throws an exception', async () => { + driver.updateDelayedEvent.mockRejectedValue( + new Error("M_BAD_JSON: Content must be a JSON object"), + ); + + const event: IUpdateDelayedEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.MSC4157UpdateDelayedEvent, + data: { + delay_id: 'f', + action: UpdateDelayedEventAction.Send, + }, + }; + + await loadIframe(['org.matrix.msc4157.update_delayed_event']); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: 'Error updating delayed event' }, + }); + }); + }); + + it('should reject with Matrix API error response thrown by driver', async () => { + driver.processError.mockImplementation(processCustomMatrixError); + + driver.updateDelayedEvent.mockRejectedValue( + new CustomMatrixError( + 'failed to update delayed event', + 400, + 'M_NOT_JSON', + { + reason: 'Content must be a JSON object.', + }, + ), + ); + + const event: IUpdateDelayedEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.MSC4157UpdateDelayedEvent, + data: { + delay_id: 'f', + action: UpdateDelayedEventAction.Send, + }, + }; + + await loadIframe(['org.matrix.msc4157.update_delayed_event']); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { + message: 'Error updating delayed event', + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: '', + response: { + errcode: 'M_NOT_JSON', + error: 'failed to update delayed event', + reason: 'Content must be a JSON object.', + }, + } satisfies IMatrixApiError, + }, + }); + }); + }); }); describe('org.matrix.msc2876.read_events action', () => { From 9ea19f6831c2edef4cc05a8a25fb25f9093337e5 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Tue, 5 Nov 2024 12:17:06 -0500 Subject: [PATCH 07/12] Accept more than just Matrix API error details As long as the error details payload is extensible, let drivers put more data in them than just the key for Matrix API error responses. --- src/ClientWidgetApi.ts | 4 ++-- src/driver/WidgetDriver.ts | 9 ++++----- test/ClientWidgetApi-test.ts | 19 +++++++++++-------- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/ClientWidgetApi.ts b/src/ClientWidgetApi.ts index 59634cd..30073c6 100644 --- a/src/ClientWidgetApi.ts +++ b/src/ClientWidgetApi.ts @@ -833,11 +833,11 @@ export class ClientWidgetApi extends EventEmitter { } private handleDriverError(e: unknown, request: IWidgetApiRequest, message: string) { - const matrixApiError = this.driver.processError(e); + const errorDetails = this.driver.processError(e); this.transport.reply(request, { error: { message, - ...(matrixApiError && { matrix_api_error: { ...matrixApiError } }), + ...errorDetails, }, }); } diff --git a/src/driver/WidgetDriver.ts b/src/driver/WidgetDriver.ts index e4e7ca5..4edc933 100644 --- a/src/driver/WidgetDriver.ts +++ b/src/driver/WidgetDriver.ts @@ -19,10 +19,10 @@ import { IOpenIDCredentials, OpenIDRequestState, SimpleObservable, - IMatrixApiError, IRoomEvent, IRoomAccountData, ITurnServer, + IWidgetApiErrorResponseDataDetails, UpdateDelayedEventAction, } from ".."; @@ -361,13 +361,12 @@ export abstract class WidgetDriver { } /** - * Expresses an error thrown by a Matrix API request made by this driver - * in a format compatible with the Widget API. + * Expresses an error thrown by this driver in a format compatible with the Widget API. * @param error The error to handle. - * @returns The error expressed as a {@link IMatrixApiError}, + * @returns The error expressed as a {@link IWidgetApiErrorResponseDataDetails}, * or undefined if it cannot be expressed as one. */ - public processError(error: unknown): IMatrixApiError | undefined { + public processError(error: unknown): IWidgetApiErrorResponseDataDetails | undefined { return undefined; } } diff --git a/test/ClientWidgetApi-test.ts b/test/ClientWidgetApi-test.ts index d9ce7dc..e56848a 100644 --- a/test/ClientWidgetApi-test.ts +++ b/test/ClientWidgetApi-test.ts @@ -36,6 +36,7 @@ import { ISendEventFromWidgetActionRequest, IUpdateDelayedEventFromWidgetActionRequest, IUploadFileActionFromWidgetActionRequest, + IWidgetApiErrorResponseDataDetails, UpdateDelayedEventAction, } from '../src'; import { IGetMediaConfigActionFromWidgetActionRequest } from '../src/interfaces/GetMediaConfigAction'; @@ -70,15 +71,17 @@ class CustomMatrixError extends Error { } } -function processCustomMatrixError(e: unknown): IMatrixApiError | undefined { +function processCustomMatrixError(e: unknown): IWidgetApiErrorResponseDataDetails | undefined { return e instanceof CustomMatrixError ? { - http_status: e.httpStatus, - http_headers: {}, - url: '', - response: { - errcode: e.name, - error: e.message, - ...e.data, + matrix_api_error: { + http_status: e.httpStatus, + http_headers: {}, + url: '', + response: { + errcode: e.name, + error: e.message, + ...e.data, + }, }, } : undefined; } From 9341bf0ca645fbe4d82095dc1b9630bc16d72cd9 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Tue, 5 Nov 2024 15:55:51 -0500 Subject: [PATCH 08/12] Don't make error data payload extensible as this makes it too easy for drivers to put data in the wrong section. Still define the payload type as an interface so that it can be extended in a future version of the API. Also don't use a subfield now that non-extensibility makes the format of the details fields unambiguous. --- src/ClientWidgetApi.ts | 4 ++-- src/interfaces/IWidgetApiErrorResponse.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ClientWidgetApi.ts b/src/ClientWidgetApi.ts index 30073c6..8fcc662 100644 --- a/src/ClientWidgetApi.ts +++ b/src/ClientWidgetApi.ts @@ -833,11 +833,11 @@ export class ClientWidgetApi extends EventEmitter { } private handleDriverError(e: unknown, request: IWidgetApiRequest, message: string) { - const errorDetails = this.driver.processError(e); + const data = this.driver.processError(e); this.transport.reply(request, { error: { message, - ...errorDetails, + ...data, }, }); } diff --git a/src/interfaces/IWidgetApiErrorResponse.ts b/src/interfaces/IWidgetApiErrorResponse.ts index a44d4d6..89a29de 100644 --- a/src/interfaces/IWidgetApiErrorResponse.ts +++ b/src/interfaces/IWidgetApiErrorResponse.ts @@ -34,7 +34,7 @@ export interface IMatrixApiError { } & IWidgetApiResponseData; // extensible } -export interface IWidgetApiErrorResponseDataDetails extends IWidgetApiResponseData { +export interface IWidgetApiErrorResponseDataDetails { /** Set if the error came from a Matrix API request made by a widget driver */ matrix_api_error?: IMatrixApiError; // eslint-disable-line camelcase } From 7a5a2bc308603b33ddfb4ea38b36283bd145f785 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Tue, 5 Nov 2024 16:26:45 -0500 Subject: [PATCH 09/12] Set some missing fields in test --- test/ClientWidgetApi-test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/ClientWidgetApi-test.ts b/test/ClientWidgetApi-test.ts index e56848a..95fa6eb 100644 --- a/test/ClientWidgetApi-test.ts +++ b/test/ClientWidgetApi-test.ts @@ -246,6 +246,8 @@ describe('ClientWidgetApi', () => { }); it('should reject requests when the driver throws an exception', async () => { + const roomId = '!room:example.org'; + driver.sendEvent.mockRejectedValue( new Error("M_BAD_JSON: Content must be a JSON object"), ); @@ -258,6 +260,7 @@ describe('ClientWidgetApi', () => { data: { type: 'm.room.message', content: 'hello', + room_id: roomId, }, }; @@ -276,6 +279,8 @@ describe('ClientWidgetApi', () => { }); it('should reject with Matrix API error response thrown by driver', async () => { + const roomId = '!room:example.org'; + driver.processError.mockImplementation(processCustomMatrixError); driver.sendEvent.mockRejectedValue( @@ -297,6 +302,7 @@ describe('ClientWidgetApi', () => { data: { type: 'm.room.message', content: 'hello', + room_id: roomId, }, }; @@ -329,6 +335,8 @@ describe('ClientWidgetApi', () => { describe('send_event action for delayed events', () => { it('fails to send delayed events', async () => { + const roomId = '!room:example.org'; + const event: ISendEventFromWidgetActionRequest = { api: WidgetApiDirection.FromWidget, widgetId: 'test', @@ -338,6 +346,7 @@ describe('ClientWidgetApi', () => { type: 'm.room.message', content: {}, delay: 5000, + room_id: roomId, }, }; @@ -458,6 +467,8 @@ describe('ClientWidgetApi', () => { }); it('should reject requests when the driver throws an exception', async () => { + const roomId = '!room:example.org'; + driver.sendDelayedEvent.mockRejectedValue( new Error("M_BAD_JSON: Content must be a JSON object"), ); @@ -470,6 +481,7 @@ describe('ClientWidgetApi', () => { data: { type: 'm.room.message', content: 'hello', + room_id: roomId, delay: 5000, parent_delay_id: 'fp', }, @@ -491,6 +503,8 @@ describe('ClientWidgetApi', () => { }); it('should reject with Matrix API error response thrown by driver', async () => { + const roomId = '!room:example.org'; + driver.processError.mockImplementation(processCustomMatrixError); driver.sendDelayedEvent.mockRejectedValue( @@ -512,6 +526,7 @@ describe('ClientWidgetApi', () => { data: { type: 'm.room.message', content: 'hello', + room_id: roomId, delay: 5000, parent_delay_id: 'fp', }, From e993d327d5ec1f9a74ea2da021122f03972de210 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Tue, 5 Nov 2024 16:27:08 -0500 Subject: [PATCH 10/12] Test sendToDevice in ClientWidgetApi --- test/ClientWidgetApi-test.ts | 246 +++++++++++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) diff --git a/test/ClientWidgetApi-test.ts b/test/ClientWidgetApi-test.ts index 95fa6eb..783f7de 100644 --- a/test/ClientWidgetApi-test.ts +++ b/test/ClientWidgetApi-test.ts @@ -34,6 +34,7 @@ import { IMatrixApiError, IReadEventFromWidgetActionRequest, ISendEventFromWidgetActionRequest, + ISendToDeviceFromWidgetActionRequest, IUpdateDelayedEventFromWidgetActionRequest, IUploadFileActionFromWidgetActionRequest, IWidgetApiErrorResponseDataDetails, @@ -117,6 +118,7 @@ describe('ClientWidgetApi', () => { sendEvent: jest.fn(), sendDelayedEvent: jest.fn(), updateDelayedEvent: jest.fn(), + sendToDevice: jest.fn(), validateCapabilities: jest.fn(), searchUserDirectory: jest.fn(), getMediaConfig: jest.fn(), @@ -721,6 +723,250 @@ describe('ClientWidgetApi', () => { }); }); + describe('send_to_device action', () => { + it('sends unencrypted to-device events', async () => { + const event: ISendToDeviceFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.SendToDevice, + data: { + type: 'net.example.test', + encrypted: false, + messages: { + '@foo:bar.com': { + 'DEVICEID': { + 'example_content_key': 'value', + }, + }, + }, + }, + }; + + await loadIframe([`org.matrix.msc3819.send.to_device:${event.data.type}`]); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toHaveBeenCalledWith(event, {}); + }); + + expect(driver.sendToDevice).toHaveBeenCalledWith( + event.data.type, + event.data.encrypted, + event.data.messages, + ); + }); + + it('fails to send to-device events without event type', async () => { + const event: IWidgetApiRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.SendToDevice, + data: { + encrypted: false, + messages: { + '@foo:bar.com': { + 'DEVICEID': { + 'example_content_key': 'value', + }, + }, + }, + }, + }; + + await loadIframe([`org.matrix.msc3819.send.to_device:${event.data.type}`]); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: 'Invalid request - missing event type' }, + }); + }); + + expect(driver.sendToDevice).not.toBeCalled(); + }); + + it('fails to send to-device events without event contents', async () => { + const event: IWidgetApiRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.SendToDevice, + data: { + type: 'net.example.test', + encrypted: false, + }, + }; + + await loadIframe([`org.matrix.msc3819.send.to_device:${event.data.type}`]); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: 'Invalid request - missing event contents' }, + }); + }); + + expect(driver.sendToDevice).not.toBeCalled(); + }); + + it('fails to send to-device events without encryption flag', async () => { + const event: IWidgetApiRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.SendToDevice, + data: { + type: 'net.example.test', + messages: { + '@foo:bar.com': { + 'DEVICEID': { + 'example_content_key': 'value', + }, + }, + }, + }, + }; + + await loadIframe([`org.matrix.msc3819.send.to_device:${event.data.type}`]); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: 'Invalid request - missing encryption flag' }, + }); + }); + + expect(driver.sendToDevice).not.toBeCalled(); + }); + + it('fails to send to-device events with any event type', async () => { + const event: ISendToDeviceFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.SendToDevice, + data: { + type: 'net.example.test', + encrypted: false, + messages: { + '@foo:bar.com': { + 'DEVICEID': { + 'example_content_key': 'value', + }, + }, + }, + }, + }; + + await loadIframe([`org.matrix.msc3819.send.to_device:${event.data.type}_different`]); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: 'Cannot send to-device events of this type' }, + }); + }); + + expect(driver.sendToDevice).not.toBeCalled(); + }); + + it('should reject requests when the driver throws an exception', async () => { + driver.sendToDevice.mockRejectedValue( + new Error("M_FORBIDDEN: You don't have permission to send to-device events"), + ); + + const event: ISendToDeviceFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.SendToDevice, + data: { + type: 'net.example.test', + encrypted: false, + messages: { + '@foo:bar.com': { + 'DEVICEID': { + 'example_content_key': 'value', + }, + }, + }, + }, + }; + + await loadIframe([`org.matrix.msc3819.send.to_device:${event.data.type}`]); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: 'Error sending event' }, + }); + }); + }); + + it('should reject with Matrix API error response thrown by driver', async () => { + driver.processError.mockImplementation(processCustomMatrixError); + + driver.sendToDevice.mockRejectedValue( + new CustomMatrixError( + 'failed to send event', + 400, + 'M_FORBIDDEN', + { + reason: "You don't have permission to send to-device events", + }, + ), + ); + + const event: ISendToDeviceFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.SendToDevice, + data: { + type: 'net.example.test', + encrypted: false, + messages: { + '@foo:bar.com': { + 'DEVICEID': { + 'example_content_key': 'value', + }, + }, + }, + }, + }; + + await loadIframe([`org.matrix.msc3819.send.to_device:${event.data.type}`]); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { + message: 'Error sending event', + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: '', + response: { + errcode: 'M_FORBIDDEN', + error: 'failed to send event', + reason: "You don't have permission to send to-device events", + }, + } satisfies IMatrixApiError, + }, + }); + }); + }); + }); + describe('org.matrix.msc2876.read_events action', () => { it('reads state events with any state key', async () => { driver.readStateEvents.mockResolvedValue([ From 2fa6ba1fd3de7757b947e651015506eda769d456 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Wed, 6 Nov 2024 10:04:07 -0500 Subject: [PATCH 11/12] Test navigation in ClientWidgetApi --- test/ClientWidgetApi-test.ts | 151 +++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/test/ClientWidgetApi-test.ts b/test/ClientWidgetApi-test.ts index 783f7de..c8c9b11 100644 --- a/test/ClientWidgetApi-test.ts +++ b/test/ClientWidgetApi-test.ts @@ -32,6 +32,7 @@ import { PostmessageTransport } from '../src/transport/PostmessageTransport'; import { IDownloadFileActionFromWidgetActionRequest, IMatrixApiError, + INavigateActionRequest, IReadEventFromWidgetActionRequest, ISendEventFromWidgetActionRequest, ISendToDeviceFromWidgetActionRequest, @@ -113,6 +114,7 @@ describe('ClientWidgetApi', () => { document.body.appendChild(iframe); driver = { + navigate: jest.fn(), readStateEvents: jest.fn(), readEventRelations: jest.fn(), sendEvent: jest.fn(), @@ -159,6 +161,155 @@ describe('ClientWidgetApi', () => { expect(clientWidgetApi.hasCapability('m.sticker')).toBe(false); }); + describe('navigate action', () => { + it('navigates', async () => { + driver.navigate.mockResolvedValue(Promise.resolve()); + + const event: INavigateActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.MSC2931Navigate, + data: { + uri: 'https://matrix.to/#/#room:example.net', + }, + }; + + await loadIframe(['org.matrix.msc2931.navigate']); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toHaveBeenCalledWith(event, {}); + }); + + expect(driver.navigate).toHaveBeenCalledWith( + event.data.uri, + ); + }); + + it('fails to navigate', async () => { + const event: INavigateActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.MSC2931Navigate, + data: { + uri: 'https://matrix.to/#/#room:example.net', + }, + }; + + await loadIframe([]); // Without the required capability + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: 'Missing capability' }, + }); + }); + + expect(driver.navigate).not.toBeCalled(); + }); + + it('fails to navigate to an unsupported URI', async () => { + const event: INavigateActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.MSC2931Navigate, + data: { + uri: 'https://example.net', + }, + }; + + await loadIframe(['org.matrix.msc2931.navigate']); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: 'Invalid matrix.to URI' }, + }); + }); + + expect(driver.navigate).not.toBeCalled(); + }); + + it('should reject requests when the driver throws an exception', async () => { + driver.navigate.mockRejectedValue( + new Error("M_UNKNOWN: Unknown error"), + ); + + const event: INavigateActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.MSC2931Navigate, + data: { + uri: 'https://matrix.to/#/#room:example.net', + }, + }; + + await loadIframe(['org.matrix.msc2931.navigate']); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: 'Error handling navigation' }, + }); + }); + }); + + it('should reject with Matrix API error response thrown by driver', async () => { + driver.processError.mockImplementation(processCustomMatrixError); + + driver.navigate.mockRejectedValue( + new CustomMatrixError( + 'failed to navigate', + 400, + 'M_UNKNOWN', + { + reason: 'Unknown error', + }, + ), + ); + + const event: INavigateActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.MSC2931Navigate, + data: { + uri: 'https://matrix.to/#/#room:example.net', + }, + }; + + await loadIframe(['org.matrix.msc2931.navigate']); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { + message: 'Error handling navigation', + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: '', + response: { + errcode: 'M_UNKNOWN', + error: 'failed to navigate', + reason: 'Unknown error', + }, + } satisfies IMatrixApiError, + }, + }); + }); + }); + }); + describe('send_event action', () => { it('sends message events', async () => { const roomId = '!room:example.org'; From 4aa65387f741c0451a9b221631478b6c55686dc9 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Wed, 6 Nov 2024 12:14:59 -0500 Subject: [PATCH 12/12] Add missing license year --- test/WidgetApi-test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/WidgetApi-test.ts b/test/WidgetApi-test.ts index 668c2cb..9bf7e02 100644 --- a/test/WidgetApi-test.ts +++ b/test/WidgetApi-test.ts @@ -1,5 +1,6 @@ /* * Copyright 2022 Nordeck IT + Consulting GmbH. + * Copyright 2024 The Matrix.org Foundation C.I.C. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License.