From fd2e5cd4a170c7780549e8a2096dee3cbc32c488 Mon Sep 17 00:00:00 2001 From: Dmitry Ostrikov Date: Thu, 16 May 2024 17:12:36 +0400 Subject: [PATCH 01/17] feat(repeater): get rid of supporting web sockets closes #196 --- .../api/ExecuteRequestEventHandler.spec.ts | 23 +- .../repeater/src/lib/RepeaterFactory.spec.ts | 29 +-- packages/repeater/src/models/Protocol.ts | 3 +- packages/repeater/src/register.ts | 7 +- .../src/request-runner/Request.spec.ts | 11 - .../repeater/src/request-runner/Request.ts | 25 +-- .../src/request-runner/Response.spec.ts | 4 +- .../protocols/WsRequestRunner.spec.ts | 133 ----------- .../protocols/WsRequestRunner.ts | 207 ------------------ .../src/request-runner/protocols/index.ts | 1 - 10 files changed, 10 insertions(+), 433 deletions(-) delete mode 100644 packages/repeater/src/request-runner/protocols/WsRequestRunner.spec.ts delete mode 100644 packages/repeater/src/request-runner/protocols/WsRequestRunner.ts diff --git a/packages/repeater/src/api/ExecuteRequestEventHandler.spec.ts b/packages/repeater/src/api/ExecuteRequestEventHandler.spec.ts index 0807442d..6897eb9a 100644 --- a/packages/repeater/src/api/ExecuteRequestEventHandler.spec.ts +++ b/packages/repeater/src/api/ExecuteRequestEventHandler.spec.ts @@ -1,8 +1,8 @@ import 'reflect-metadata'; import { ExecuteRequestEventHandler } from './ExecuteRequestEventHandler'; import { Protocol } from '../models'; -import { Request, RequestRunner } from '../request-runner'; -import { anything, capture, instance, mock, reset, when } from 'ts-mockito'; +import { RequestRunner } from '../request-runner'; +import { anything, instance, mock, reset, when } from 'ts-mockito'; describe('ExecuteRequestEventHandler', () => { const requestRunnerResponse = { @@ -65,24 +65,5 @@ describe('ExecuteRequestEventHandler', () => { await expect(res).rejects.toThrow(`Unsupported protocol "http"`); }); - - it('`correlation_id_regex` should become `correlationIdRegex` in runner input', async () => { - const payload = { - protocol: Protocol.HTTP, - url: 'http://foo.bar/', - headers: {}, - correlation_id_regex: 'baz' - }; - const handler = new ExecuteRequestEventHandler([ - instance(mockedRequestRunner) - ]); - - await handler.handle(payload); - - const [request]: [Request] = capture( - mockedRequestRunner.run - ).first(); - expect(request.correlationIdRegex).toBeInstanceOf(RegExp); - }); }); }); diff --git a/packages/repeater/src/lib/RepeaterFactory.spec.ts b/packages/repeater/src/lib/RepeaterFactory.spec.ts index cc7281ed..d9c3d6b7 100644 --- a/packages/repeater/src/lib/RepeaterFactory.spec.ts +++ b/packages/repeater/src/lib/RepeaterFactory.spec.ts @@ -3,8 +3,7 @@ import { RepeaterFactory } from './RepeaterFactory'; import { HttpRequestRunner, RequestRunner, - RequestRunnerOptions, - WsRequestRunner + RequestRunnerOptions } from '../request-runner'; import { Repeater } from './Repeater'; import { RepeatersManager } from '../api'; @@ -215,7 +214,7 @@ describe('RepeaterFactory', () => { const factory = new RepeaterFactory(configuration); await factory.createRepeater({ - requestRunners: [HttpRequestRunner, WsRequestRunner] + requestRunners: [HttpRequestRunner] }); verify( @@ -229,17 +228,6 @@ describe('RepeaterFactory', () => { }) ) ).once(); - verify( - mockedChildContainer.register( - RequestRunner, - deepEqual({ - useClass: WsRequestRunner - }), - deepEqual({ - lifecycle: Lifecycle.ContainerScoped - }) - ) - ).once(); }); it('should throw an error if name prefix is too long', async () => { @@ -331,7 +319,7 @@ describe('RepeaterFactory', () => { const existingRepeaterId = '123'; await factory.createRepeaterFromExisting(existingRepeaterId, { - requestRunners: [HttpRequestRunner, WsRequestRunner] + requestRunners: [HttpRequestRunner] }); verify( @@ -345,17 +333,6 @@ describe('RepeaterFactory', () => { }) ) ).once(); - verify( - mockedChildContainer.register( - RequestRunner, - deepEqual({ - useClass: WsRequestRunner - }), - deepEqual({ - lifecycle: Lifecycle.ContainerScoped - }) - ) - ).once(); }); }); }); diff --git a/packages/repeater/src/models/Protocol.ts b/packages/repeater/src/models/Protocol.ts index 2fa5e095..e513e337 100644 --- a/packages/repeater/src/models/Protocol.ts +++ b/packages/repeater/src/models/Protocol.ts @@ -1,4 +1,3 @@ export enum Protocol { - HTTP = 'http', - WS = 'ws' + HTTP = 'http' } diff --git a/packages/repeater/src/register.ts b/packages/repeater/src/register.ts index d42c6b8e..a1d8f55b 100644 --- a/packages/repeater/src/register.ts +++ b/packages/repeater/src/register.ts @@ -3,8 +3,7 @@ import { DefaultRepeatersManager, RepeatersManager } from './api'; import { HttpRequestRunner, RequestRunner, - RequestRunnerOptions, - WsRequestRunner + RequestRunnerOptions } from './request-runner'; import { container, @@ -27,10 +26,6 @@ container.register(RequestRunner, { useClass: HttpRequestRunner }); -container.register(RequestRunner, { - useClass: WsRequestRunner -}); - container.register(RequestRunnerOptions, { useValue: { timeout: 30000, diff --git a/packages/repeater/src/request-runner/Request.spec.ts b/packages/repeater/src/request-runner/Request.spec.ts index 61df7918..6774cafa 100644 --- a/packages/repeater/src/request-runner/Request.spec.ts +++ b/packages/repeater/src/request-runner/Request.spec.ts @@ -34,17 +34,6 @@ describe('Request', () => { ).toThrow('Body must be string.'); }); - it('should throw Error on invalid correlationIdRegex', () => { - expect( - () => - new Request({ - url: 'http://foo.bar', - correlationIdRegex: '(', - protocol: Protocol.HTTP - }) - ).toThrow('Correlation id must be regular expression.'); - }); - it('should create an instance', () => { expect( () => diff --git a/packages/repeater/src/request-runner/Request.ts b/packages/repeater/src/request-runner/Request.ts index 176ba768..ad57de5b 100644 --- a/packages/repeater/src/request-runner/Request.ts +++ b/packages/repeater/src/request-runner/Request.ts @@ -7,7 +7,6 @@ export interface RequestOptions { method?: string; headers?: Record; body?: string; - correlationIdRegex?: string | RegExp; } export class Request { @@ -30,7 +29,6 @@ export class Request { public readonly protocol: Protocol; public readonly url: string; public readonly body?: string; - public readonly correlationIdRegex?: RegExp; private readonly _method?: string; @@ -48,20 +46,11 @@ export class Request { return this.url.startsWith('https'); } - constructor({ - protocol, - method, - url, - body, - correlationIdRegex, - headers = {} - }: RequestOptions) { + constructor({ protocol, method, url, body, headers = {} }: RequestOptions) { this.protocol = protocol; this._method = method?.toUpperCase() ?? 'GET'; this.validateUrl(url); this.url = url; - this.correlationIdRegex = - this.normalizeCorrelationIdRegex(correlationIdRegex); this.setHeaders(headers); this.precheckBody(body); this.body = body; @@ -99,16 +88,4 @@ export class Request { throw new Error('Body must be string.'); } } - - private normalizeCorrelationIdRegex( - correlationIdRegex: RegExp | string | undefined - ): RegExp | undefined { - if (correlationIdRegex) { - try { - return new RegExp(correlationIdRegex, 'i'); - } catch { - throw new Error('Correlation id must be regular expression.'); - } - } - } } diff --git a/packages/repeater/src/request-runner/Response.spec.ts b/packages/repeater/src/request-runner/Response.spec.ts index 04d99047..509f5c07 100644 --- a/packages/repeater/src/request-runner/Response.spec.ts +++ b/packages/repeater/src/request-runner/Response.spec.ts @@ -5,7 +5,7 @@ describe('Response', () => { describe('constructor', () => { it('should create an instance having only protocol', () => { const responseOptions = { - protocol: Protocol.WS + protocol: Protocol.HTTP }; const response = new Response(responseOptions); @@ -15,7 +15,7 @@ describe('Response', () => { it('should create an instance with full fieldset', () => { const responseOptions = { - protocol: Protocol.WS, + protocol: Protocol.HTTP, statusCode: 200, headers: { 'x-key': ['x-value'] }, body: '{}', diff --git a/packages/repeater/src/request-runner/protocols/WsRequestRunner.spec.ts b/packages/repeater/src/request-runner/protocols/WsRequestRunner.spec.ts deleted file mode 100644 index f8ee1a01..00000000 --- a/packages/repeater/src/request-runner/protocols/WsRequestRunner.spec.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { WsRequestRunner } from './WsRequestRunner'; -import { Request } from '../Request'; -import { RequestRunnerOptions } from '../RequestRunnerOptions'; -import { Protocol } from '../../models'; -import 'reflect-metadata'; -import { reset, spy, when } from 'ts-mockito'; -import { Server } from 'ws'; -import { Logger, LogLevel } from '@sectester/core'; -import { once } from 'events'; - -describe('WsRequestRunner', () => { - const executorOptions: RequestRunnerOptions = { timeout: 2000 }; - const spiedExecutorOptions = spy(executorOptions); - - let runner!: WsRequestRunner; - - beforeEach(() => { - // ADHOC: ts-mockito resets object's property descriptor as well - Object.assign(executorOptions, { timeout: 2000 }); - runner = new WsRequestRunner(executorOptions, new Logger(LogLevel.SILENT)); - }); - - afterEach(() => reset(spiedExecutorOptions)); - - describe('protocol', () => { - it('should use WS protocol', () => - expect(runner.protocol).toBe(Protocol.WS)); - }); - - describe('execute', () => { - let server: Server; - let wsPort: number; - - beforeEach(async () => { - server = new Server({ port: 0 }); - await once(server, 'listening'); - - const address = server.address(); - if (typeof address === 'string') { - throw new Error('Unsupported server address type'); - } - - wsPort = address.port; - }); - - afterEach( - () => - new Promise(done => { - wsPort = NaN; - server.close(done); - }) - ); - - it('should send request body to a web-socket server', () => { - const url = `ws://localhost:${wsPort}`; - const headers = {}; - const body = 'test request body'; - const request = new Request({ - url, - headers, - body, - protocol: Protocol.WS - }); - - server.on('connection', socket => { - socket.on('message', data => { - expect(data).toBeInstanceOf(Buffer); - expect(data.toString()).toBe(body); - socket.send('test reply'); - }); - }); - - return runner.run(request); - }); - - it('should fail sending request by timeout', async () => { - when(spiedExecutorOptions.timeout).thenReturn(100); - - const url = `ws://localhost:${wsPort}`; - const request = new Request({ url, protocol: Protocol.WS, headers: {} }); - - const response = await runner.run(request); - - expect(response).toEqual({ - body: undefined, - errorCode: 'ETIMEDOUT', - headers: undefined, - message: 'Waiting frame has timed out', - protocol: 'ws', - statusCode: undefined - }); - }); - - it('should not allow setting forbidden headers', () => { - const url = `ws://localhost:${wsPort}`; - const headers = { 'test-header': 'test-header-value' }; - WsRequestRunner.FORBIDDEN_HEADERS.forEach( - headerName => (headers[headerName] = 'forbidden-header-value') - ); - const request = new Request({ url, headers, protocol: Protocol.WS }); - - server.on('connection', (socket, req) => { - WsRequestRunner.FORBIDDEN_HEADERS.forEach(headerName => { - expect(req.headers[headerName]).not.toBe('forbidden-header-value'); - }); - - expect(req.headers['test-header']).toBe('test-header-value'); - - socket.on('message', () => { - socket.send('test reply'); - }); - }); - - return runner.run(request); - }); - - it('should get the response from server', async () => { - const url = `ws://localhost:${wsPort}`; - const data = 'test reply'; - const request = new Request({ url, headers: {}, protocol: Protocol.WS }); - - server.on('connection', socket => { - socket.on('message', () => { - socket.send(data, { binary: false, compress: false }); - }); - }); - - const response = await runner.run(request); - - expect(response.body).toBe(data); - }); - }); -}); diff --git a/packages/repeater/src/request-runner/protocols/WsRequestRunner.ts b/packages/repeater/src/request-runner/protocols/WsRequestRunner.ts deleted file mode 100644 index f60d632c..00000000 --- a/packages/repeater/src/request-runner/protocols/WsRequestRunner.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { Request } from '../Request'; -import { RequestRunner } from '../RequestRunner'; -import { RequestRunnerOptions } from '../RequestRunnerOptions'; -import { Response } from '../Response'; -import { Protocol } from '../../models'; -import { Logger } from '@sectester/core'; -import WebSocket from 'ws'; -import { inject, injectable } from 'tsyringe'; -import { SocksProxyAgent } from 'socks-proxy-agent'; -import { once } from 'events'; -import { IncomingMessage } from 'http'; -import { parse } from 'url'; -import { promisify } from 'util'; - -interface WSMessage { - body: string | undefined; - code?: number; -} - -@injectable() -export class WsRequestRunner implements RequestRunner { - public static readonly FORBIDDEN_HEADERS: ReadonlySet = new Set([ - 'sec-websocket-version', - 'sec-websocket-key' - ]); - - private readonly agent?: SocksProxyAgent; - - constructor( - @inject(RequestRunnerOptions) - private readonly options: RequestRunnerOptions, - private readonly logger: Logger - ) { - this.agent = this.options.proxyUrl - ? new SocksProxyAgent({ - ...parse(this.options.proxyUrl) - }) - : undefined; - } - - get protocol(): Protocol { - return Protocol.WS; - } - - public async run(options: Request): Promise { - let timeout: NodeJS.Timeout | undefined; - let client: WebSocket | undefined; - - try { - this.logger.debug( - 'Executing WS request with following params: %j', - options - ); - - client = this.createWebSocketClient(options); - const connectRes: IncomingMessage = await this.connect(client); - - timeout = this.setTimeout(client); - const msg = await this.sendMessage(client, options); - - return this.createWsResponse(msg, connectRes); - } catch (err) { - return this.handleRequestError(err, options); - } finally { - if (timeout) { - clearTimeout(timeout); - } - - if (client?.readyState === WebSocket.OPEN) { - client.close(1000); - } - } - } - - private createWebSocketClient(options: Request): WebSocket { - return new WebSocket(options.url, { - agent: this.agent, - rejectUnauthorized: false, - handshakeTimeout: this.options.timeout, - headers: this.normalizeHeaders(options.headers ?? {}) - }); - } - - private async sendMessage( - client: WebSocket, - options: Request - ): Promise { - // @ts-expect-error TS infers a wrong type here - await promisify(client.send.bind(client))(options.body); - - const message = await this.consume(client, options.correlationIdRegex); - - return message; - } - - private createWsResponse( - msg: WSMessage | undefined, - connectRes: IncomingMessage - ): Response { - return new Response({ - protocol: this.protocol, - statusCode: msg?.code ?? connectRes.statusCode, - headers: connectRes.headers, - body: msg?.body - }); - } - - private handleRequestError(err: any, options: Request): Response { - const message = err.info ?? err.message; - const errorCode = err.code ?? err.syscall; - - this.logger.error('Error executing request: %s', options.url); - this.logger.error('Cause: %s', message); - - return new Response({ - message, - errorCode, - protocol: this.protocol - }); - } - - private setTimeout(client: WebSocket): NodeJS.Timeout { - const timeout = setTimeout( - () => - client.emit( - 'error', - Object.assign(new Error('Waiting frame has timed out'), { - code: 'ETIMEDOUT' - }) - ), - this.options.timeout - ); - - timeout.unref(); - - return timeout; - } - - private async consume( - client: WebSocket, - matcher?: RegExp - ): Promise { - const result = (await Promise.race([ - this.waitForResponse(client, matcher), - once(client, 'close') - ])) as [string | number, string | undefined]; - - let msg: WSMessage | undefined; - - if (result.length) { - const [data, reason]: [string | number, string | undefined] = result; - const body = typeof data === 'string' ? data : reason; - const code = typeof data === 'number' ? data : undefined; - - msg = { - body, - code - }; - } - - return msg; - } - - private waitForResponse( - client: WebSocket, - matcher: RegExp | undefined - ): Promise<[string]> { - return new Promise(resolve => { - client.on('message', (data: WebSocket.Data) => { - const dataString = String(data); - !matcher || matcher.test(dataString) - ? resolve([dataString]) - : undefined; - }); - }); - } - - private async connect(client: WebSocket): Promise { - const opening = once(client, 'open'); - const upgrading = once(client, 'upgrade') as Promise<[IncomingMessage]>; - - await opening; - - const [res]: [IncomingMessage] = await upgrading; - - return res; - } - - private normalizeHeaders( - headers: Record - ): Record { - return Object.entries(headers).reduce( - ( - result: Record, - [key, value]: [string, string | string[]] - ) => { - const headerName = key.trim().toLowerCase(); - if (!WsRequestRunner.FORBIDDEN_HEADERS.has(headerName)) { - result[key] = value; - } - - return result; - }, - {} - ); - } -} diff --git a/packages/repeater/src/request-runner/protocols/index.ts b/packages/repeater/src/request-runner/protocols/index.ts index 39b4ce2e..e274526b 100644 --- a/packages/repeater/src/request-runner/protocols/index.ts +++ b/packages/repeater/src/request-runner/protocols/index.ts @@ -1,2 +1 @@ export * from './HttpRequestRunner'; -export * from './WsRequestRunner'; From 589c90e214a5976d7e3953efc80b42627666c8fb Mon Sep 17 00:00:00 2001 From: Dmitry Ostrikov Date: Mon, 20 May 2024 19:08:28 +0400 Subject: [PATCH 02/17] feat(repeater): replace rmq with web-sockets closes #196 --- package-lock.json | 360 +++++++++++++++-- package.json | 6 + packages/core/src/logger/Logger.ts | 8 +- .../src/api/DefaultRepeatersManager.spec.ts | 32 +- .../src/api/DefaultRepeatersManager.ts | 20 +- packages/repeater/src/api/RepeatersManager.ts | 2 - .../src/api/commands/GetRepeaterRequest.ts | 20 - packages/repeater/src/api/commands/index.ts | 1 - .../repeater/src/bus/DefaultRepeaterBus.ts | 157 ++++++++ .../src/bus/DefaultRepeaterBusFactory.ts | 33 ++ .../src/bus/DefaultRepeaterCommandHub.ts | 25 ++ .../repeater/src/bus/DefaultRepeaterServer.ts | 378 ++++++++++++++++++ packages/repeater/src/bus/RepeaterBus.ts | 6 + .../repeater/src/bus/RepeaterBusFactory.ts | 7 + .../repeater/src/bus/RepeaterCommandHub.ts | 7 + packages/repeater/src/bus/RepeaterServer.ts | 132 ++++++ packages/repeater/src/bus/index.ts | 7 + packages/repeater/src/lib/Repeater.spec.ts | 207 ++-------- packages/repeater/src/lib/Repeater.ts | 114 +----- .../repeater/src/lib/RepeaterFactory.spec.ts | 163 +++----- packages/repeater/src/lib/RepeaterFactory.ts | 41 +- packages/repeater/src/register.ts | 11 + .../repeater/src/request-runner/Request.ts | 94 ++++- .../repeater/src/request-runner/Response.ts | 10 +- .../protocols/HttpRequestRunner.ts | 5 +- 25 files changed, 1302 insertions(+), 544 deletions(-) delete mode 100644 packages/repeater/src/api/commands/GetRepeaterRequest.ts create mode 100644 packages/repeater/src/bus/DefaultRepeaterBus.ts create mode 100644 packages/repeater/src/bus/DefaultRepeaterBusFactory.ts create mode 100644 packages/repeater/src/bus/DefaultRepeaterCommandHub.ts create mode 100644 packages/repeater/src/bus/DefaultRepeaterServer.ts create mode 100644 packages/repeater/src/bus/RepeaterBus.ts create mode 100644 packages/repeater/src/bus/RepeaterBusFactory.ts create mode 100644 packages/repeater/src/bus/RepeaterCommandHub.ts create mode 100644 packages/repeater/src/bus/RepeaterServer.ts create mode 100644 packages/repeater/src/bus/index.ts diff --git a/package-lock.json b/package-lock.json index 1ac8d75a..469dc98f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,14 +15,20 @@ "@har-sdk/core": "^1.4.3", "amqp-connection-manager": "^4.1.13", "amqplib": "^0.10.3", + "arch": "^3.0.0", "axios": "^0.26.1", "axios-rate-limit": "^1.3.0", "chalk": "^4.1.2", "ci-info": "^3.3.0", "content-type": "^1.0.4", + "find-up": "^5.0.0", "form-data": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.4", "reflect-metadata": "^0.1.13", "semver": "^7.5.2", + "socket.io-client": "^4.7.5", + "socket.io-msgpack-parser": "^3.0.2", "socks-proxy-agent": "^6.2.0-beta.0", "tslib": "~2.3.1", "tsyringe": "^4.6.0", @@ -2287,6 +2293,19 @@ "node": ">= 6" } }, + "node_modules/@semantic-release/github/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/@semantic-release/npm": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/@semantic-release/npm/-/npm-9.0.1.tgz", @@ -2356,6 +2375,11 @@ "@sinonjs/commons": "^1.7.0" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==" + }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -3249,6 +3273,25 @@ "node": ">= 8" } }, + "node_modules/arch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/arch/-/arch-3.0.0.tgz", + "integrity": "sha512-AmIAC+Wtm2AU8lGfTtHsw0Y9Qtftx2YXEEtiBP10xFUtMOA+sHHx6OAddyL52mUKh1vsXQ6/w1mVDptZCyUt4Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -4055,6 +4098,14 @@ "dot-prop": "^5.1.0" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -4754,6 +4805,26 @@ "once": "^1.4.0" } }, + "node_modules/engine.io-client": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.3.tgz", + "integrity": "sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.11.0", + "xmlhttprequest-ssl": "~2.0.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.2.tgz", + "integrity": "sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.10.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.10.0.tgz", @@ -5705,7 +5776,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -6360,30 +6430,49 @@ "dev": true }, "node_modules/http-proxy-agent": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", - "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", - "dev": true, + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dependencies": { - "@tootallnate/once": "1", - "agent-base": "6", - "debug": "4" + "agent-base": "^7.1.0", + "debug": "^4.3.4" }, "engines": { - "node": ">= 6" + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" } }, "node_modules/https-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", - "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==", - "dev": true, + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", + "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", "dependencies": { - "agent-base": "6", + "agent-base": "^7.0.2", "debug": "4" }, "engines": { - "node": ">= 6" + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" } }, "node_modules/human-signals": { @@ -7820,6 +7909,33 @@ "node": ">= 6" } }, + "node_modules/jsdom/node_modules/http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "dev": true, + "dependencies": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/jsdom/node_modules/ws": { "version": "7.5.7", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.7.tgz", @@ -8304,7 +8420,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, "dependencies": { "p-locate": "^5.0.0" }, @@ -8924,6 +9039,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/notepack.io": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/notepack.io/-/notepack.io-2.2.0.tgz", + "integrity": "sha512-9b5w3t5VSH6ZPosoYnyDONnUTF8o0UkBw7JLA6eBlYJWyGT1Q3vQa8Hmuj1/X6RYvHjjygBDgw6fJhe0JEojfw==" + }, "node_modules/npm": { "version": "8.12.0", "resolved": "https://registry.npmjs.org/npm/-/npm-8.12.0.tgz", @@ -11711,7 +11831,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, "dependencies": { "yocto-queue": "^0.1.0" }, @@ -11726,7 +11845,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, "dependencies": { "p-limit": "^3.0.2" }, @@ -13220,6 +13338,41 @@ "node": ">=6" } }, + "node_modules/socket.io-client": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.5.tgz", + "integrity": "sha512-sJ/tqHOCe7Z50JCBCXrsY3I2k03iOiUe+tj1OmKeD2lXPiGH/RUCdTZFoqVyN7l1MnpIzPrGtLcijffmeouNlQ==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-msgpack-parser": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/socket.io-msgpack-parser/-/socket.io-msgpack-parser-3.0.2.tgz", + "integrity": "sha512-1e76bJ1PCKi9H+JiYk+S29PBJvknHjQWM7Mtj0hjF2KxDA6b6rQxv3rTsnwBoz/haZOhlCDIMQvPATbqYeuMxg==", + "dependencies": { + "component-emitter": "~1.3.0", + "notepack.io": "~2.2.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/socks": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/socks/-/socks-2.6.2.tgz", @@ -14754,9 +14907,9 @@ } }, "node_modules/ws": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.5.0.tgz", - "integrity": "sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", + "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", "engines": { "node": ">=10.0.0" }, @@ -14785,6 +14938,14 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", + "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -14889,7 +15050,6 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, "engines": { "node": ">=10" }, @@ -16726,6 +16886,16 @@ "agent-base": "6", "debug": "4" } + }, + "https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "requires": { + "agent-base": "6", + "debug": "4" + } } } }, @@ -16786,6 +16956,11 @@ "@sinonjs/commons": "^1.7.0" } }, + "@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==" + }, "@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -17516,6 +17691,11 @@ "picomatch": "^2.0.4" } }, + "arch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/arch/-/arch-3.0.0.tgz", + "integrity": "sha512-AmIAC+Wtm2AU8lGfTtHsw0Y9Qtftx2YXEEtiBP10xFUtMOA+sHHx6OAddyL52mUKh1vsXQ6/w1mVDptZCyUt4Q==" + }, "arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -18116,6 +18296,11 @@ "dot-prop": "^5.1.0" } }, + "component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==" + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -18661,6 +18846,23 @@ "once": "^1.4.0" } }, + "engine.io-client": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.3.tgz", + "integrity": "sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==", + "requires": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.11.0", + "xmlhttprequest-ssl": "~2.0.0" + } + }, + "engine.io-parser": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.2.tgz", + "integrity": "sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw==" + }, "enhanced-resolve": { "version": "5.10.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.10.0.tgz", @@ -19384,7 +19586,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, "requires": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -19870,24 +20071,41 @@ "dev": true }, "http-proxy-agent": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", - "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", - "dev": true, + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "requires": { - "@tootallnate/once": "1", - "agent-base": "6", - "debug": "4" + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "dependencies": { + "agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "requires": { + "debug": "^4.3.4" + } + } } }, "https-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", - "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==", - "dev": true, + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", + "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", "requires": { - "agent-base": "6", + "agent-base": "^7.0.2", "debug": "4" + }, + "dependencies": { + "agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "requires": { + "debug": "^4.3.4" + } + } } }, "human-signals": { @@ -20940,6 +21158,27 @@ "mime-types": "^2.1.12" } }, + "http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "dev": true, + "requires": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + } + }, + "https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "requires": { + "agent-base": "6", + "debug": "4" + } + }, "ws": { "version": "7.5.7", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.7.tgz", @@ -21277,7 +21516,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, "requires": { "p-locate": "^5.0.0" } @@ -21747,6 +21985,11 @@ "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", "dev": true }, + "notepack.io": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/notepack.io/-/notepack.io-2.2.0.tgz", + "integrity": "sha512-9b5w3t5VSH6ZPosoYnyDONnUTF8o0UkBw7JLA6eBlYJWyGT1Q3vQa8Hmuj1/X6RYvHjjygBDgw6fJhe0JEojfw==" + }, "npm": { "version": "8.12.0", "resolved": "https://registry.npmjs.org/npm/-/npm-8.12.0.tgz", @@ -23707,7 +23950,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, "requires": { "yocto-queue": "^0.1.0" } @@ -23716,7 +23958,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, "requires": { "p-limit": "^3.0.2" } @@ -24820,6 +25061,35 @@ } } }, + "socket.io-client": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.5.tgz", + "integrity": "sha512-sJ/tqHOCe7Z50JCBCXrsY3I2k03iOiUe+tj1OmKeD2lXPiGH/RUCdTZFoqVyN7l1MnpIzPrGtLcijffmeouNlQ==", + "requires": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.5.2", + "socket.io-parser": "~4.2.4" + } + }, + "socket.io-msgpack-parser": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/socket.io-msgpack-parser/-/socket.io-msgpack-parser-3.0.2.tgz", + "integrity": "sha512-1e76bJ1PCKi9H+JiYk+S29PBJvknHjQWM7Mtj0hjF2KxDA6b6rQxv3rTsnwBoz/haZOhlCDIMQvPATbqYeuMxg==", + "requires": { + "component-emitter": "~1.3.0", + "notepack.io": "~2.2.0" + } + }, + "socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "requires": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + } + }, "socks": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/socks/-/socks-2.6.2.tgz", @@ -26014,9 +26284,9 @@ } }, "ws": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.5.0.tgz", - "integrity": "sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", + "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", "requires": {} }, "xml-name-validator": { @@ -26031,6 +26301,11 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true }, + "xmlhttprequest-ssl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", + "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==" + }, "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -26109,8 +26384,7 @@ "yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" } } } diff --git a/package.json b/package.json index 51e2dfbf..4f16f37e 100644 --- a/package.json +++ b/package.json @@ -78,14 +78,20 @@ "@har-sdk/core": "^1.4.3", "amqp-connection-manager": "^4.1.13", "amqplib": "^0.10.3", + "arch": "^3.0.0", "axios": "^0.26.1", "axios-rate-limit": "^1.3.0", "chalk": "^4.1.2", "ci-info": "^3.3.0", "content-type": "^1.0.4", + "find-up": "^5.0.0", "form-data": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.4", "reflect-metadata": "^0.1.13", "semver": "^7.5.2", + "socket.io-client": "^4.7.5", + "socket.io-msgpack-parser": "^3.0.2", "socks-proxy-agent": "^6.2.0-beta.0", "tslib": "~2.3.1", "tsyringe": "^4.6.0", diff --git a/packages/core/src/logger/Logger.ts b/packages/core/src/logger/Logger.ts index cf0fb13b..af9349eb 100644 --- a/packages/core/src/logger/Logger.ts +++ b/packages/core/src/logger/Logger.ts @@ -28,8 +28,12 @@ export class Logger { this._logLevel = logLevel; } - public error(message: string, ...args: any[]): void { - this.write(message, LogLevel.ERROR, ...args); + public error(errorOrMessage: string | Error, ...args: any[]): void { + if (typeof errorOrMessage === 'string') { + this.write(errorOrMessage, LogLevel.ERROR, ...args); + } else { + this.write(errorOrMessage.message, LogLevel.ERROR, ...args); + } } public warn(message: string, ...args: any[]): void { diff --git a/packages/repeater/src/api/DefaultRepeatersManager.spec.ts b/packages/repeater/src/api/DefaultRepeatersManager.spec.ts index c2c449e6..6b2dcd0e 100644 --- a/packages/repeater/src/api/DefaultRepeatersManager.spec.ts +++ b/packages/repeater/src/api/DefaultRepeatersManager.spec.ts @@ -1,9 +1,5 @@ import 'reflect-metadata'; -import { - CreateRepeaterRequest, - DeleteRepeaterRequest, - GetRepeaterRequest -} from './commands'; +import { CreateRepeaterRequest, DeleteRepeaterRequest } from './commands'; import { DefaultRepeatersManager } from './DefaultRepeatersManager'; import { RepeatersManager } from './RepeatersManager'; import { CommandDispatcher } from '@sectester/core'; @@ -67,32 +63,6 @@ describe('DefaultRepeatersManager', () => { }); }); - describe('getRepeater', () => { - it('should return repeater', async () => { - const repeaterId = '142'; - when( - mockedCommandDispatcher.execute(anyOfClass(GetRepeaterRequest)) - ).thenResolve({ id: repeaterId }); - - const result = await manager.getRepeater(repeaterId); - - verify( - mockedCommandDispatcher.execute(anyOfClass(GetRepeaterRequest)) - ).once(); - expect(result).toMatchObject({ repeaterId }); - }); - - it('should throw an error if cannot find repeater', async () => { - when( - mockedCommandDispatcher.execute(anyOfClass(GetRepeaterRequest)) - ).thenResolve(); - - const act = manager.getRepeater('123'); - - await expect(act).rejects.toThrow('Cannot find repeater'); - }); - }); - describe('deleteRepeater', () => { it('should remove repeater', async () => { when( diff --git a/packages/repeater/src/api/DefaultRepeatersManager.ts b/packages/repeater/src/api/DefaultRepeatersManager.ts index a2da95b3..630ec035 100644 --- a/packages/repeater/src/api/DefaultRepeatersManager.ts +++ b/packages/repeater/src/api/DefaultRepeatersManager.ts @@ -1,9 +1,5 @@ import { RepeatersManager } from './RepeatersManager'; -import { - CreateRepeaterRequest, - DeleteRepeaterRequest, - GetRepeaterRequest -} from './commands'; +import { CreateRepeaterRequest, DeleteRepeaterRequest } from './commands'; import { inject, injectable } from 'tsyringe'; import { CommandDispatcher } from '@sectester/core'; @@ -14,20 +10,6 @@ export class DefaultRepeatersManager implements RepeatersManager { private readonly commandDispatcher: CommandDispatcher ) {} - public async getRepeater( - repeaterId: string - ): Promise<{ repeaterId: string }> { - const repeater = await this.commandDispatcher.execute( - new GetRepeaterRequest(repeaterId) - ); - - if (!repeater?.id) { - throw new Error('Cannot find repeater'); - } - - return { repeaterId: repeater.id }; - } - public async createRepeater({ projectId, ...options diff --git a/packages/repeater/src/api/RepeatersManager.ts b/packages/repeater/src/api/RepeatersManager.ts index 845c5bc0..c53bdd3e 100644 --- a/packages/repeater/src/api/RepeatersManager.ts +++ b/packages/repeater/src/api/RepeatersManager.ts @@ -1,6 +1,4 @@ export interface RepeatersManager { - getRepeater(repeaterId: string): Promise<{ repeaterId: string }>; - createRepeater(options: { name: string; projectId?: string; diff --git a/packages/repeater/src/api/commands/GetRepeaterRequest.ts b/packages/repeater/src/api/commands/GetRepeaterRequest.ts deleted file mode 100644 index 05882ff5..00000000 --- a/packages/repeater/src/api/commands/GetRepeaterRequest.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { HttpRequest } from '@sectester/bus'; - -export interface GetRepeaterResponsePayload { - id: string; - name: string; - projectIds: string[]; -} - -export class GetRepeaterRequest extends HttpRequest< - undefined, - GetRepeaterResponsePayload -> { - constructor(repeaterId: string) { - super({ - url: `/api/v1/repeaters/${repeaterId}`, - method: 'GET', - payload: undefined - }); - } -} diff --git a/packages/repeater/src/api/commands/index.ts b/packages/repeater/src/api/commands/index.ts index aea128ac..08f6202f 100644 --- a/packages/repeater/src/api/commands/index.ts +++ b/packages/repeater/src/api/commands/index.ts @@ -1,6 +1,5 @@ export { CreateRepeaterRequest } from './CreateRepeaterRequest'; export { DeleteRepeaterRequest } from './DeleteRepeaterRequest'; -export { GetRepeaterRequest } from './GetRepeaterRequest'; export { RegisterRepeaterCommand, RegisterRepeaterCommandPayload, diff --git a/packages/repeater/src/bus/DefaultRepeaterBus.ts b/packages/repeater/src/bus/DefaultRepeaterBus.ts new file mode 100644 index 00000000..f17b1209 --- /dev/null +++ b/packages/repeater/src/bus/DefaultRepeaterBus.ts @@ -0,0 +1,157 @@ +import { RepeaterBus } from './RepeaterBus'; +import { RepeaterCommandHub } from './RepeaterCommandHub'; +import { + RepeaterErrorCodes, + RepeaterServer, + RepeaterServerErrorEvent, + RepeaterServerEvents, + RepeaterServerReconnectionFailedEvent, + RepeaterServerRequestEvent +} from './RepeaterServer'; +import { Request } from '../request-runner/Request'; +import { Logger } from '@sectester/core'; +import chalk from 'chalk'; + +export class DefaultRepeaterBus implements RepeaterBus { + private repeaterRunning: boolean = false; + + constructor( + private readonly repeaterId: string, + private readonly logger: Logger, + private readonly repeaterServer: RepeaterServer, + private readonly commandHub: RepeaterCommandHub + ) {} + + public close() { + this.repeaterRunning = false; + + this.repeaterServer.disconnect(); + + return Promise.resolve(); + } + + public async connect(): Promise { + if (this.repeaterRunning) { + return; + } + + this.repeaterRunning = true; + + this.logger.log('Connecting the Repeater (%s)...', this.repeaterId); + + this.subscribeToEvents(); + + await this.repeaterServer.connect(this.repeaterId); + + this.logger.log('Deploying the Repeater (%s)...', this.repeaterId); + + await this.repeaterServer.deploy({ + repeaterId: this.repeaterId + }); + + this.logger.log('The Repeater (%s) started', this.repeaterId); + } + + private subscribeToEvents() { + this.repeaterServer.on(RepeaterServerEvents.ERROR, this.handleError); + this.repeaterServer.on( + RepeaterServerEvents.RECONNECTION_FAILED, + this.reconnectionFailed + ); + this.repeaterServer.on(RepeaterServerEvents.REQUEST, this.requestReceived); + this.repeaterServer.on(RepeaterServerEvents.UPDATE_AVAILABLE, payload => + this.logger.warn( + '%s: A new Repeater version (%s) is available, for update instruction visit https://docs.brightsec.com/docs/installation-options', + chalk.yellow('(!) IMPORTANT'), + payload.version + ) + ); + this.repeaterServer.on( + RepeaterServerEvents.RECONNECT_ATTEMPT, + ({ attempt, maxAttempts }) => + this.logger.warn( + 'Failed to connect to Bright cloud (attempt %d/%d)', + attempt, + maxAttempts + ) + ); + this.repeaterServer.on(RepeaterServerEvents.RECONNECTION_SUCCEEDED, () => + this.logger.log('The Repeater (%s) connected', this.repeaterId) + ); + } + + private handleError = ({ + code, + message, + remediation + }: RepeaterServerErrorEvent) => { + const normalizedMessage = this.normalizeMessage(message); + const normalizedRemediation = this.normalizeMessage(remediation ?? ''); + + if (this.isCriticalError(code)) { + this.handleCriticalError(normalizedMessage, normalizedRemediation); + } else { + this.logger.error(normalizedMessage); + } + }; + + private normalizeMessage(message: string): string { + return message.replace(/\.$/, ''); + } + + private isCriticalError(code: RepeaterErrorCodes): boolean { + return [ + RepeaterErrorCodes.REPEATER_DEACTIVATED, + RepeaterErrorCodes.REPEATER_NO_LONGER_SUPPORTED, + RepeaterErrorCodes.REPEATER_UNAUTHORIZED, + RepeaterErrorCodes.REPEATER_ALREADY_STARTED, + RepeaterErrorCodes.REPEATER_NOT_PERMITTED, + RepeaterErrorCodes.UNEXPECTED_ERROR + ].includes(code); + } + + private handleCriticalError(message: string, remediation: string): void { + this.logger.error( + '%s: %s. %s', + chalk.red('(!) CRITICAL'), + message, + remediation + ); + this.close().catch(this.logger.error); + process.exitCode = 1; + } + + private reconnectionFailed = ({ + error + }: RepeaterServerReconnectionFailedEvent) => { + this.logger.error(error); + this.close().catch(this.logger.error); + process.exitCode = 1; + }; + + private requestReceived = async (event: RepeaterServerRequestEvent) => { + const response = await this.commandHub.sendRequest( + new Request({ ...event }) + ); + + const { + statusCode, + message, + errorCode, + body, + headers, + protocol, + encoding + } = response; + + return { + protocol, + body, + headers, + statusCode, + errorCode, + message, + encoding + }; + }; +} diff --git a/packages/repeater/src/bus/DefaultRepeaterBusFactory.ts b/packages/repeater/src/bus/DefaultRepeaterBusFactory.ts new file mode 100644 index 00000000..7c9f3537 --- /dev/null +++ b/packages/repeater/src/bus/DefaultRepeaterBusFactory.ts @@ -0,0 +1,33 @@ +import { RepeaterBus } from './RepeaterBus'; +import { DefaultRepeaterBus } from './DefaultRepeaterBus'; +import { RepeaterBusFactory } from './RepeaterBusFactory'; +import { RepeaterCommandHub } from './RepeaterCommandHub'; +import { RepeaterServer } from './RepeaterServer'; +import { Configuration, Logger } from '@sectester/core'; +import { inject, injectable } from 'tsyringe'; + +@injectable() +export class DefaultRepeaterBusFactory implements RepeaterBusFactory { + constructor( + private readonly logger: Logger, + private readonly configuration: Configuration, + @inject(RepeaterServer) private readonly repeaterServer: RepeaterServer, + @inject(RepeaterCommandHub) + private readonly commandHub: RepeaterCommandHub + ) {} + + public create(repeaterId: string): RepeaterBus { + this.logger.log( + 'Creating the repeater (%s, %s)...', + repeaterId, + this.configuration.version + ); + + return new DefaultRepeaterBus( + repeaterId, + this.logger, + this.repeaterServer, + this.commandHub + ); + } +} diff --git a/packages/repeater/src/bus/DefaultRepeaterCommandHub.ts b/packages/repeater/src/bus/DefaultRepeaterCommandHub.ts new file mode 100644 index 00000000..5f147207 --- /dev/null +++ b/packages/repeater/src/bus/DefaultRepeaterCommandHub.ts @@ -0,0 +1,25 @@ +import { RepeaterCommandHub } from './RepeaterCommandHub'; +import { Request, Response, RequestRunner } from '../request-runner'; +import { injectable, injectAll } from 'tsyringe'; + +@injectable() +export class DefaultRepeaterCommandHub implements RepeaterCommandHub { + constructor( + @injectAll(RequestRunner) + private readonly requestRunners: RequestRunner[] + ) {} + + public sendRequest(request: Request): Promise { + const { protocol } = request; + + const requestRunner = this.requestRunners.find( + x => x.protocol === protocol + ); + + if (!requestRunner) { + throw new Error(`Unsupported protocol "${protocol}"`); + } + + return requestRunner.run(request); + } +} diff --git a/packages/repeater/src/bus/DefaultRepeaterServer.ts b/packages/repeater/src/bus/DefaultRepeaterServer.ts new file mode 100644 index 00000000..a62accb6 --- /dev/null +++ b/packages/repeater/src/bus/DefaultRepeaterServer.ts @@ -0,0 +1,378 @@ +import { + DeployCommandOptions, + DeploymentRuntime, + RepeaterErrorCodes, + RepeaterServer, + RepeaterServerDeployedEvent, + RepeaterServerErrorEvent, + RepeaterServerEventHandler, + RepeaterServerEvents, + RepeaterServerEventsMap, + RepeaterServerReconnectionAttemptedEvent, + RepeaterServerReconnectionFailedEvent, + RepeaterServerRequestEvent, + RepeaterServerRequestResponse, + RepeaterUpgradeAvailableEvent +} from './RepeaterServer'; +import { Logger } from '@sectester/core'; +import { inject, injectable } from 'tsyringe'; +import io, { Socket } from 'socket.io-client'; +import parser from 'socket.io-msgpack-parser'; +import { ErrorEvent } from 'ws'; +import { EventEmitter, once } from 'events'; +import Timer = NodeJS.Timer; + +export interface DefaultRepeaterServerOptions { + readonly uri: string; + readonly token: string; + readonly connectTimeout?: number; + readonly proxyUrl?: string; + readonly insecure?: boolean; +} + +export const DefaultRepeaterServerOptions: unique symbol = Symbol( + 'DefaultRepeaterServerOptions' +); + +type CallbackFunction = (arg: T) => unknown; +type HandlerFunction = (args: unknown[]) => unknown; + +const enum SocketEvents { + DEPLOYED = 'deployed', + DEPLOY = 'deploy', + UNDEPLOY = 'undeploy', + UNDEPLOYED = 'undeployed', + ERROR = 'error', + UPDATE_AVAILABLE = 'update-available', + PING = 'ping', + REQUEST = 'request' +} + +interface SocketListeningEventMap { + [SocketEvents.DEPLOYED]: (event: RepeaterServerDeployedEvent) => void; + [SocketEvents.UNDEPLOYED]: () => void; + [SocketEvents.ERROR]: (event: RepeaterServerErrorEvent) => void; + [SocketEvents.UPDATE_AVAILABLE]: ( + event: RepeaterUpgradeAvailableEvent + ) => void; + [SocketEvents.REQUEST]: ( + request: RepeaterServerRequestEvent, + callback: CallbackFunction + ) => void; +} + +interface SocketEmitEventMap { + [SocketEvents.DEPLOY]: ( + options: DeployCommandOptions, + runtime?: DeploymentRuntime + ) => void; + [SocketEvents.UNDEPLOY]: () => void; + [SocketEvents.PING]: () => void; +} + +@injectable() +export class DefaultRepeaterServer implements RepeaterServer { + private readonly MAX_DEPLOYMENT_TIMEOUT = 60_000; + private readonly MAX_PING_INTERVAL = 10_000; + private readonly MAX_RECONNECTION_ATTEMPTS = 20; + private readonly MIN_RECONNECTION_DELAY = 1000; + private readonly MAX_RECONNECTION_DELAY = 86_400_000; + private readonly events = new EventEmitter(); + private readonly handlerMap = new WeakMap< + RepeaterServerEventHandler, + HandlerFunction + >(); + private latestReconnectionError?: Error; + private pingTimer?: Timer; + private connectionTimer?: Timer; + private _socket?: Socket; + private connectionAttempts = 0; + + private get socket() { + if (!this._socket) { + throw new Error( + 'Please make sure that repeater established a connection with host.' + ); + } + + return this._socket; + } + + constructor( + private readonly logger: Logger, + @inject(DefaultRepeaterServerOptions) + private readonly options: DefaultRepeaterServerOptions + ) {} + + public disconnect() { + this.events.removeAllListeners(); + this.clearPingTimer(); + this.clearConnectionTimer(); + + this._socket?.disconnect(); + this._socket?.removeAllListeners(); + this._socket = undefined; + } + + public async deploy( + options: DeployCommandOptions + ): Promise { + process.nextTick(() => this.socket.emit(SocketEvents.DEPLOY, options)); + + const [result]: RepeaterServerDeployedEvent[] = await Promise.race([ + once(this.socket, SocketEvents.DEPLOYED), + new Promise((_, reject) => + setTimeout( + reject, + this.MAX_DEPLOYMENT_TIMEOUT, + new Error('No response.') + ).unref() + ) + ]); + + this.createPingTimer(); + + return result; + } + + public async connect(hostname: string) { + this._socket = io(this.options.uri, { + parser, + path: '/api/ws/v1', + transports: ['websocket'], + reconnectionDelayMax: this.MAX_RECONNECTION_DELAY, + reconnectionDelay: this.MIN_RECONNECTION_DELAY, + timeout: this.options?.connectTimeout, + rejectUnauthorized: !this.options.insecure, + reconnectionAttempts: this.MAX_RECONNECTION_ATTEMPTS, + auth: { + token: this.options.token, + domain: hostname + } + }); + + this.listenToReservedEvents(); + this.listenToApplicationEvents(); + + await once(this.socket, 'connect'); + + this.logger.debug('Repeater connected to %s', this.options.uri); + } + + public off( + event: K, + handler: RepeaterServerEventHandler + ): void { + const wrappedHandler = this.handlerMap.get(handler); + if (wrappedHandler) { + this.events.off(event, wrappedHandler); + this.handlerMap.delete(handler); + } + } + + public on( + event: K, + handler: RepeaterServerEventHandler + ): void { + const wrappedHandler = (...args: unknown[]) => + this.wrapEventListener(event, handler, ...args); + this.handlerMap.set(handler, wrappedHandler); + this.events.on(event, wrappedHandler); + } + + private listenToApplicationEvents() { + this.socket.on(SocketEvents.DEPLOYED, event => + this.events.emit(RepeaterServerEvents.DEPLOY, event) + ); + this.socket.on(SocketEvents.REQUEST, (event, callback) => + this.events.emit(RepeaterServerEvents.REQUEST, event, callback) + ); + this.socket.on(SocketEvents.ERROR, event => { + this.events.emit(RepeaterServerEvents.ERROR, event); + }); + this.socket.on(SocketEvents.UPDATE_AVAILABLE, event => + this.events.emit(RepeaterServerEvents.UPDATE_AVAILABLE, event) + ); + } + + private listenToReservedEvents() { + this.socket.on('connect', this.handleConnect); + this.socket.on('connect_error', this.handleConnectionError); + this.socket.on('disconnect', this.handleDisconnect); + this.socket.io.on('reconnect', () => { + this.latestReconnectionError = undefined; + }); + this.socket.io.on( + 'reconnect_error', + error => (this.latestReconnectionError = error) + ); + this.socket.io.on('reconnect_failed', () => + this.events.emit(RepeaterServerEvents.RECONNECTION_FAILED, { + error: this.latestReconnectionError + } as RepeaterServerReconnectionFailedEvent) + ); + this.socket.io.on('reconnect_attempt', attempt => + this.events.emit(RepeaterServerEvents.RECONNECT_ATTEMPT, { + attempt, + maxAttempts: this.MAX_RECONNECTION_ATTEMPTS + } as RepeaterServerReconnectionAttemptedEvent) + ); + this.socket.io.on('reconnect', () => + this.events.emit(RepeaterServerEvents.RECONNECTION_SUCCEEDED) + ); + } + + private handleConnectionError = (err: Error) => { + const { data } = err as unknown as { + data?: Omit; + }; + + // If the error is not related to the repeater, we should ignore it + if (!data?.code) { + this.logConnectionError(err); + + return; + } + + if (this.suppressConnectionError(data)) { + this.events.emit(RepeaterServerEvents.ERROR, { + ...data, + message: err.message + }); + + return; + } + + if (this.connectionAttempts >= this.MAX_RECONNECTION_ATTEMPTS) { + this.events.emit(RepeaterServerEvents.RECONNECTION_FAILED, { + error: err + } as RepeaterServerReconnectionFailedEvent); + + return; + } + + // If the error is not related to the authentication, we should manually reconnect + this.scheduleReconnection(); + }; + + private suppressConnectionError( + data: Omit + ) { + return [ + RepeaterErrorCodes.REPEATER_UNAUTHORIZED, + RepeaterErrorCodes.REPEATER_NOT_PERMITTED + ].includes(data.code); + } + + private scheduleReconnection() { + let delay = Math.max( + this.MIN_RECONNECTION_DELAY * 2 ** this.connectionAttempts, + this.MIN_RECONNECTION_DELAY + ); + delay += delay * 0.3 * Math.random(); + delay = Math.min(delay, this.MAX_RECONNECTION_DELAY); + + this.connectionAttempts++; + + this.events.emit(RepeaterServerEvents.RECONNECT_ATTEMPT, { + attempt: this.connectionAttempts, + maxAttempts: this.MAX_RECONNECTION_ATTEMPTS + } as RepeaterServerReconnectionAttemptedEvent); + this.connectionTimer = setTimeout(() => this.socket.connect(), delay); + } + + private logConnectionError(err: Error) { + this.logger.debug( + 'An error occurred while connecting to the repeater: %s', + err.message + ); + + const { description, cause } = err as { + description?: ErrorEvent; + cause?: Error; + }; + const nestedError = description?.error ?? cause; + + if (nestedError) { + this.logger.debug('The error cause: %s', nestedError.message); + } + } + + private async wrapEventListener( + event: string, + handler: (...payload: TArgs) => unknown, + ...args: unknown[] + ) { + try { + const callback = this.extractLastArgument(args); + + // eslint-disable-next-line @typescript-eslint/return-await + const response = await handler(...(args as TArgs)); + + callback?.(response); + } catch (err) { + this.handleEventError(err, event, args); + } + } + + private extractLastArgument(args: unknown[]): CallbackFunction | undefined { + const lastArg = args.pop(); + if (typeof lastArg === 'function') { + return lastArg as CallbackFunction; + } else { + // If the last argument is not a function, add it back to the args array + args.push(lastArg); + + return undefined; + } + } + + private clearConnectionTimer() { + if (this.connectionTimer) { + clearTimeout(this.connectionTimer); + } + } + + private handleConnect = () => { + this.connectionAttempts = 0; + this.clearConnectionTimer(); + this.events.emit(RepeaterServerEvents.CONNECTED); + }; + + private handleDisconnect = (reason: string): void => { + this.clearPingTimer(); + + if (reason !== 'io client disconnect') { + this.events.emit(RepeaterServerEvents.DISCONNECTED); + } + + // the disconnection was initiated by the server, you need to reconnect manually + if (reason === 'io server disconnect') { + this.socket.connect(); + } + }; + + private handleEventError(error: Error, event: string, args: unknown[]): void { + this.logger.debug( + 'An error occurred while processing the %s event with the following payload: %j', + event, + args + ); + this.logger.error(error); + } + + private createPingTimer() { + this.clearPingTimer(); + + this.pingTimer = setInterval( + () => this.socket.volatile.emit(SocketEvents.PING), + this.MAX_PING_INTERVAL + ).unref(); + } + + private clearPingTimer() { + if (this.pingTimer) { + clearInterval(this.pingTimer); + } + } +} diff --git a/packages/repeater/src/bus/RepeaterBus.ts b/packages/repeater/src/bus/RepeaterBus.ts new file mode 100644 index 00000000..a3fd758f --- /dev/null +++ b/packages/repeater/src/bus/RepeaterBus.ts @@ -0,0 +1,6 @@ +export interface RepeaterBus { + connect(): Promise; + close(): Promise; +} + +export const RepeaterBus: unique symbol = Symbol('RepeaterBus'); diff --git a/packages/repeater/src/bus/RepeaterBusFactory.ts b/packages/repeater/src/bus/RepeaterBusFactory.ts new file mode 100644 index 00000000..cc65e800 --- /dev/null +++ b/packages/repeater/src/bus/RepeaterBusFactory.ts @@ -0,0 +1,7 @@ +import { RepeaterBus } from './RepeaterBus'; + +export interface RepeaterBusFactory { + create(repeaterId: string): RepeaterBus; +} + +export const RepeaterBusFactory: unique symbol = Symbol('RepeaterBusFactory'); diff --git a/packages/repeater/src/bus/RepeaterCommandHub.ts b/packages/repeater/src/bus/RepeaterCommandHub.ts new file mode 100644 index 00000000..7391bcc6 --- /dev/null +++ b/packages/repeater/src/bus/RepeaterCommandHub.ts @@ -0,0 +1,7 @@ +import { Request, Response } from '../request-runner'; + +export interface RepeaterCommandHub { + sendRequest(request: Request): Promise; +} + +export const RepeaterCommandHub: unique symbol = Symbol('RepeaterCommandHub'); diff --git a/packages/repeater/src/bus/RepeaterServer.ts b/packages/repeater/src/bus/RepeaterServer.ts new file mode 100644 index 00000000..20e1553c --- /dev/null +++ b/packages/repeater/src/bus/RepeaterServer.ts @@ -0,0 +1,132 @@ +import { Protocol } from '../models/Protocol'; + +export interface RepeaterServerDeployedEvent { + repeaterId: string; +} + +export interface RepeaterServerRequestEvent { + protocol: Protocol; + url: string; + method?: string; + headers?: Record; + correlationIdRegex?: string; + body?: string; + encoding?: 'base64'; + maxContentSize?: number; + timeout?: number; +} + +export type RepeaterServerRequestResponse = + | { + protocol: Protocol; + statusCode?: number; + message?: string; + errorCode?: string; + headers?: Record; + body?: string; + } + | { + protocol: Protocol; + message?: string; + errorCode?: string; + }; + +export interface RepeaterServerReconnectionFailedEvent { + error: Error; +} + +export interface RepeaterServerReconnectionAttemptedEvent { + attempt: number; + maxAttempts: number; +} + +export enum RepeaterErrorCodes { + REPEATER_NOT_PERMITTED = 'repeater_not_permitted', + REPEATER_ALREADY_STARTED = 'repeater_already_started', + REPEATER_DEACTIVATED = 'repeater_deactivated', + REPEATER_UNAUTHORIZED = 'repeater_unauthorized', + REPEATER_NO_LONGER_SUPPORTED = 'repeater_no_longer_supported', + UNKNOWN_ERROR = 'unknown_error', + UNEXPECTED_ERROR = 'unexpected_error' +} + +export interface RepeaterServerErrorEvent { + message: string; + code: RepeaterErrorCodes; + transaction?: string; + remediation?: string; +} + +export interface RepeaterUpgradeAvailableEvent { + version: string; +} + +export interface DeployCommandOptions { + repeaterId?: string; +} + +export interface DeploymentRuntime { + version: string; + ci?: string; + os?: string; + arch?: string; + docker?: boolean; + distribution?: string; + nodeVersion?: string; +} + +export const enum RepeaterServerEvents { + DEPLOYED = 'deployed', + DEPLOY = 'deploy', + CONNECTED = 'connected', + DISCONNECTED = 'disconnected', + REQUEST = 'request', + UPDATE_AVAILABLE = 'update_available', + RECONNECTION_FAILED = 'reconnection_failed', + RECONNECT_ATTEMPT = 'reconnect_attempt', + RECONNECTION_SUCCEEDED = 'reconnection_succeeded', + ERROR = 'error', + PING = 'ping' +} + +export interface RepeaterServerEventsMap { + [RepeaterServerEvents.DEPLOY]: [DeployCommandOptions, DeploymentRuntime?]; + [RepeaterServerEvents.DEPLOYED]: RepeaterServerDeployedEvent; + [RepeaterServerEvents.CONNECTED]: void; + [RepeaterServerEvents.DISCONNECTED]: void; + [RepeaterServerEvents.REQUEST]: RepeaterServerRequestEvent; + [RepeaterServerEvents.UPDATE_AVAILABLE]: RepeaterUpgradeAvailableEvent; + [RepeaterServerEvents.RECONNECTION_FAILED]: RepeaterServerReconnectionFailedEvent; + [RepeaterServerEvents.RECONNECT_ATTEMPT]: RepeaterServerReconnectionAttemptedEvent; + [RepeaterServerEvents.RECONNECTION_SUCCEEDED]: void; + [RepeaterServerEvents.ERROR]: RepeaterServerErrorEvent; + [RepeaterServerEvents.PING]: void; +} + +export type RepeaterServerEventHandler< + K extends keyof RepeaterServerEventsMap +> = ( + ...args: RepeaterServerEventsMap[K] extends (infer U)[] + ? U[] + : [RepeaterServerEventsMap[K]] +) => unknown; + +export interface RepeaterServer { + disconnect(): void; + + connect(hostname: string): Promise; + + deploy(options: DeployCommandOptions): Promise; + + on( + event: K, + handler: RepeaterServerEventHandler + ): void; + + off( + event: K, + handler?: RepeaterServerEventHandler + ): void; +} + +export const RepeaterServer: unique symbol = Symbol('RepeaterServer'); diff --git a/packages/repeater/src/bus/index.ts b/packages/repeater/src/bus/index.ts new file mode 100644 index 00000000..7ba307a6 --- /dev/null +++ b/packages/repeater/src/bus/index.ts @@ -0,0 +1,7 @@ +export * from './DefaultRepeaterBusFactory'; +export * from './DefaultRepeaterCommandHub'; +export * from './DefaultRepeaterServer'; +export * from './RepeaterBus'; +export * from './RepeaterBusFactory'; +export * from './RepeaterCommandHub'; +export * from './RepeaterServer'; diff --git a/packages/repeater/src/lib/Repeater.spec.ts b/packages/repeater/src/lib/Repeater.spec.ts index a26f774b..7a9d14ce 100644 --- a/packages/repeater/src/lib/Repeater.spec.ts +++ b/packages/repeater/src/lib/Repeater.spec.ts @@ -1,245 +1,118 @@ -import 'reflect-metadata'; import { Repeater, RunningStatus } from './Repeater'; -import { - RegisterRepeaterCommand, - RepeaterRegisteringError, - RepeaterStatusEvent -} from '../api'; -import { Configuration, EventBus, Logger } from '@sectester/core'; -import { - anyOfClass, - anything, - capture, - instance, - mock, - objectContaining, - reset, - verify, - when -} from 'ts-mockito'; -import { DependencyContainer } from 'tsyringe'; +import { RepeaterBus } from '../bus'; +import { instance, mock, reset, verify, when } from 'ts-mockito'; describe('Repeater', () => { - const version = '42.0.1'; const repeaterId = 'fooId'; let repeater!: Repeater; - const mockedConfiguration = mock(); - const mockedEventBus = mock(); - const mockedLogger = mock(); - const mockedContainer = mock(); + const mockedRepeaterBus = mock(); - const createRepater = () => + const createRepeater = () => new Repeater({ repeaterId, - bus: instance(mockedEventBus), - configuration: instance(mockedConfiguration) + bus: instance(mockedRepeaterBus) }); beforeEach(() => { - when(mockedContainer.resolve(Logger)).thenReturn(instance(mockedLogger)); - when(mockedContainer.isRegistered(Logger, anything())).thenReturn(true); - when(mockedConfiguration.repeaterVersion).thenReturn(version); - when(mockedConfiguration.container).thenReturn(instance(mockedContainer)); - when( - mockedEventBus.execute(anyOfClass(RegisterRepeaterCommand)) - ).thenResolve({ payload: { version } }); - when(mockedEventBus.publish(anyOfClass(RepeaterStatusEvent))).thenResolve(); - - jest.useFakeTimers(); - - repeater = createRepater(); + repeater = createRepeater(); }); - afterEach(() => { - reset( - mockedConfiguration, - mockedEventBus, - mockedLogger, - mockedContainer - ); - - jest.useRealTimers(); - }); + afterEach(() => reset(mockedRepeaterBus)); describe('start', () => { it('should start', async () => { + // act await repeater.start(); - verify( - mockedEventBus.execute( - objectContaining({ - type: 'RepeaterRegistering', - payload: { - repeaterId, - version - } - }) - ) - ).once(); - - verify( - mockedEventBus.publish( - objectContaining({ - type: 'RepeaterStatusUpdated', - payload: { - repeaterId, - status: 'connected' - } - }) - ) - ).once(); - }); - - it('should throw an error on failed registration', async () => { - when( - mockedEventBus.execute(anyOfClass(RegisterRepeaterCommand)) - ).thenResolve(); - - await expect(repeater.start()).rejects.toThrow( - 'Error registering repeater.' - ); - }); - - it('should send ping periodically', async () => { - await repeater.start(); - jest.advanceTimersByTime(15000); - jest.runOnlyPendingTimers(); - - verify( - mockedEventBus.publish( - objectContaining({ - type: 'RepeaterStatusUpdated', - payload: { - repeaterId, - status: 'connected' - } - }) - ) - ).thrice(); + // assert + verify(mockedRepeaterBus.connect()).once(); }); it('should have RunningStatus.STARTING just after start() call', () => { + // act void repeater.start(); + + // assert expect(repeater.runningStatus).toBe(RunningStatus.STARTING); }); it('should have RunningStatus.RUNNING after successful start()', async () => { + // act await repeater.start(); + + // assert expect(repeater.runningStatus).toBe(RunningStatus.RUNNING); }); it('should throw an error on start() twice', async () => { + // arrange await repeater.start(); + // act const res = repeater.start(); + // assert await expect(res).rejects.toThrow('Repeater is already active.'); }); it('should be possible to start() after start() error', async () => { - when(mockedEventBus.execute(anyOfClass(RegisterRepeaterCommand))) - .thenReject() - .thenResolve({ payload: { version } }); + // act + when(mockedRepeaterBus.connect()).thenReject().thenResolve(); + // assert await expect(repeater.start()).rejects.toThrow(); await expect(repeater.start()).resolves.not.toThrow(); }); - - it.each([ - { - error: RepeaterRegisteringError.REQUIRES_TO_BE_UPDATED, - expected: 'The current running version is no longer supported' - }, - { - error: RepeaterRegisteringError.BUSY, - expected: `There is an already running Repeater with ID ${repeaterId}` - }, - { - error: RepeaterRegisteringError.NOT_FOUND, - expected: 'Unauthorized access' - }, - { - error: RepeaterRegisteringError.NOT_ACTIVE, - expected: 'The current Repeater is not active' - } - ])( - 'should throw an error on registration error ${error}', - async ({ expected, error }) => { - when( - mockedEventBus.execute(anyOfClass(RegisterRepeaterCommand)) - ).thenResolve({ - payload: { error } - }); - - await expect(repeater.start()).rejects.toThrow(expected); - } - ); - - it('should log a warning if a new version is available', async () => { - const newVersion = version.replace(/(\d+)/, (_, x) => `${+x + 1}`); - when( - mockedEventBus.execute(anyOfClass(RegisterRepeaterCommand)) - ).thenResolve({ - payload: { version: newVersion } - }); - - await repeater.start(); - - const [arg]: string[] = capture(mockedLogger.warn).first(); - expect(arg).toContain('A new Repeater version (%s) is available'); - }); }); describe('stop', () => { it('should stop', async () => { + // arrange await repeater.start(); + + // act await repeater.stop(); - verify( - mockedEventBus.publish( - objectContaining({ - type: 'RepeaterStatusUpdated', - payload: { - repeaterId, - status: 'disconnected' - } - }) - ) - ).once(); - - jest.advanceTimersByTime(25000); - jest.runOnlyPendingTimers(); - - verify( - mockedEventBus.publish( - objectContaining({ payload: { status: 'connected' } }) - ) - ).once(); + // assert + verify(mockedRepeaterBus.close()).once(); }); it('should have RunningStatus.OFF after start() and stop()', async () => { + // arrange await repeater.start(); + + // act await repeater.stop(); + + // assert expect(repeater.runningStatus).toBe(RunningStatus.OFF); }); it('should do nothing on stop() without start()', async () => { + // act await repeater.stop(); + + // assert expect(repeater.runningStatus).toBe(RunningStatus.OFF); }); it('should do nothing on second stop() call', async () => { + // arrange await repeater.start(); await repeater.stop(); + + // assert await repeater.stop(); + // assert expect(repeater.runningStatus).toBe(RunningStatus.OFF); }); }); describe('runningStatus', () => { it('should have RunningStatus.OFF initially', () => { + // assert expect(repeater.runningStatus).toBe(RunningStatus.OFF); }); }); diff --git a/packages/repeater/src/lib/Repeater.ts b/packages/repeater/src/lib/Repeater.ts index 8c50a88f..cb9ce5ae 100644 --- a/packages/repeater/src/lib/Repeater.ts +++ b/packages/repeater/src/lib/Repeater.ts @@ -1,15 +1,4 @@ -import { - ExecuteRequestEventHandler, - RegisterRepeaterCommand, - RegisterRepeaterResult, - RepeaterRegisteringError, - RepeaterStatusEvent -} from '../api'; -import { RepeaterStatus } from '../models'; -import { Configuration, EventBus, Logger } from '@sectester/core'; -import { gt } from 'semver'; -import chalk from 'chalk'; -import Timer = NodeJS.Timer; +import { RepeaterBus } from '../bus'; export enum RunningStatus { OFF, @@ -23,11 +12,7 @@ export const RepeaterId = Symbol('RepeaterId'); export class Repeater { public readonly repeaterId: RepeaterId; - private readonly bus: EventBus; - private readonly configuration: Configuration; - private readonly logger: Logger; - - private timer?: Timer; + private readonly bus: RepeaterBus; private _runningStatus = RunningStatus.OFF; @@ -37,19 +22,13 @@ export class Repeater { constructor({ repeaterId, - bus, - configuration + bus }: { repeaterId: RepeaterId; - bus: EventBus; - configuration: Configuration; + bus: RepeaterBus; }) { this.repeaterId = repeaterId; this.bus = bus; - this.configuration = configuration; - - const { container } = this.configuration; - this.logger = container.resolve(Logger); } public async start(): Promise { @@ -60,9 +39,7 @@ export class Repeater { this._runningStatus = RunningStatus.STARTING; try { - await this.register(); - await this.subscribeToEvents(); - await this.schedulePing(); + await this.bus.connect(); this._runningStatus = RunningStatus.RUNNING; } catch (e) { @@ -78,85 +55,6 @@ export class Repeater { this._runningStatus = RunningStatus.OFF; - if (this.timer) { - clearInterval(this.timer); - } - - await this.sendStatus('disconnected'); - await this.bus.destroy?.(); - } - - private async register(): Promise { - const res = await this.bus.execute( - new RegisterRepeaterCommand({ - version: this.configuration.repeaterVersion, - repeaterId: this.repeaterId - }) - ); - - if (!res) { - throw new Error('Error registering repeater.'); - } - - this.handleRegisterResult(res); - } - - private async subscribeToEvents(): Promise { - await Promise.all( - [ - ExecuteRequestEventHandler - // TODO repeater scripts - ].map(type => this.bus.register(type)) - ); - } - - private async schedulePing(): Promise { - await this.sendStatus('connected'); - this.timer = setInterval(() => this.sendStatus('connected'), 10000); - this.timer.unref(); - } - - private async sendStatus(status: RepeaterStatus): Promise { - await this.bus.publish( - new RepeaterStatusEvent({ - status, - repeaterId: this.repeaterId - }) - ); - } - - private handleRegisterResult(res: { payload: RegisterRepeaterResult }): void { - const { payload } = res; - - if ('error' in payload) { - this.handleRegisterError(payload.error); - } else { - if (gt(payload.version, this.configuration.repeaterVersion)) { - this.logger.warn( - '%s: A new Repeater version (%s) is available, please update @sectester.', - chalk.yellow('(!) IMPORTANT'), - payload.version - ); - } - } - } - - private handleRegisterError(error: RepeaterRegisteringError): never { - switch (error) { - case RepeaterRegisteringError.NOT_ACTIVE: - throw new Error(`Access Refused: The current Repeater is not active.`); - case RepeaterRegisteringError.NOT_FOUND: - throw new Error(`Unauthorized access. Please check your credentials.`); - case RepeaterRegisteringError.BUSY: - throw new Error( - `Access Refused: There is an already running Repeater with ID ${this.repeaterId}` - ); - case RepeaterRegisteringError.REQUIRES_TO_BE_UPDATED: - throw new Error( - `${chalk.red( - '(!) CRITICAL' - )}: The current running version is no longer supported, please update @sectester.` - ); - } + await this.bus.close(); } } diff --git a/packages/repeater/src/lib/RepeaterFactory.spec.ts b/packages/repeater/src/lib/RepeaterFactory.spec.ts index d9c3d6b7..99c1403e 100644 --- a/packages/repeater/src/lib/RepeaterFactory.spec.ts +++ b/packages/repeater/src/lib/RepeaterFactory.spec.ts @@ -1,5 +1,6 @@ -import 'reflect-metadata'; import { RepeaterFactory } from './RepeaterFactory'; +import { RepeaterBus } from '../bus/RepeaterBus'; +import { RepeaterBusFactory } from '../bus/RepeaterBusFactory'; import { HttpRequestRunner, RequestRunner, @@ -7,7 +8,7 @@ import { } from '../request-runner'; import { Repeater } from './Repeater'; import { RepeatersManager } from '../api'; -import { Configuration, EventBus } from '@sectester/core'; +import { Configuration } from '@sectester/core'; import { anything, capture, @@ -21,21 +22,6 @@ import { } from 'ts-mockito'; import { DependencyContainer, Lifecycle } from 'tsyringe'; -const resolvableInstance = (m: T): T => - new Proxy(instance(m), { - get(target, prop, receiver) { - if ( - ['Symbol(Symbol.toPrimitive)', 'then', 'catch'].includes( - prop.toString() - ) - ) { - return undefined; - } - - return Reflect.get(target, prop, receiver); - } - }); - describe('RepeaterFactory', () => { const repeaterId = 'fooId'; const defaultOptions = { @@ -63,51 +49,64 @@ describe('RepeaterFactory', () => { const mockedContainer = mock(); const mockedChildContainer = mock(); const mockedConfiguration = mock(); - const mockedEventBus = mock(); + const mockedRepeaterBus = mock(); const mockedRepeaterManager = mock(); + const mockedRepeaterBusFactory = mock(); const configuration = instance(mockedConfiguration); beforeEach(() => { - when(mockedChildContainer.resolve(EventBus)).thenReturn( - resolvableInstance(mockedEventBus) - ); - when( - mockedContainer.resolve(RepeatersManager) - ).thenReturn(instance(mockedRepeaterManager)); - when(mockedConfiguration.container).thenReturn(instance(mockedContainer)); + when(mockedConfiguration.loadCredentials()).thenResolve(); + when(mockedContainer.createChildContainer()).thenReturn( instance(mockedChildContainer) ); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - when(mockedEventBus.init!()).thenResolve(); + when( + mockedContainer.resolve(RepeatersManager) + ).thenReturn(instance(mockedRepeaterManager)); when(mockedRepeaterManager.createRepeater(anything())).thenResolve({ repeaterId }); + + when( + mockedChildContainer.resolve(RepeaterBusFactory) + ).thenReturn(instance(mockedRepeaterBusFactory)); + + when(mockedRepeaterBusFactory.create(repeaterId)).thenReturn( + instance(mockedRepeaterBus) + ); }); afterEach(() => { - reset( + reset< + | DependencyContainer + | Configuration + | RepeaterBus + | RepeatersManager + | RepeaterBusFactory + >( mockedContainer, mockedChildContainer, mockedConfiguration, - mockedEventBus, + mockedRepeaterBus, + mockedRepeaterBusFactory, mockedRepeaterManager ); }); describe('createRepeater', () => { it('should create repeater', async () => { + // arrange const factory = new RepeaterFactory(configuration); + // act const res = await factory.createRepeater(); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - verify(mockedEventBus.init!()).once(); + // assert expect(res).toBeInstanceOf(Repeater); expect(res).toMatchObject({ repeaterId @@ -115,8 +114,10 @@ describe('RepeaterFactory', () => { }); it('should create repeater with given name prefix and description', async () => { + // arrange const factory = new RepeaterFactory(configuration); + // act const res = await factory.createRepeater({ namePrefix: 'foo', description: 'description' @@ -132,19 +133,23 @@ describe('RepeaterFactory', () => { description?: string; }>(mockedRepeaterManager.createRepeater).first(); + // assert expect(arg?.name).toMatch(/^foo/); expect(arg?.description).toBe('description'); expect(res).toBeInstanceOf(Repeater); }); it('should create repeater with given name without the random postfix', async () => { + // arrange const factory = new RepeaterFactory(configuration); + // act const res = await factory.createRepeater({ namePrefix: 'foo', disableRandomNameGeneration: true }); + // assert verify( mockedRepeaterManager.createRepeater(objectContaining({ name: 'foo' })) ); @@ -152,6 +157,7 @@ describe('RepeaterFactory', () => { }); it('should create repeater with given project', async () => { + // arrange const factory = new RepeaterFactory(configuration); const projectId = '321'; const res = await factory.createRepeater({ @@ -165,6 +171,7 @@ describe('RepeaterFactory', () => { }); it('should register custom request runner options', async () => { + // arrange const factory = new RepeaterFactory(configuration); when( mockedChildContainer.register(RequestRunnerOptions, anything()) @@ -176,12 +183,14 @@ describe('RepeaterFactory', () => { allowedMimes: ['text/html'] }; + // act await factory.createRepeater({ namePrefix: 'foo', description: 'description', requestRunnerOptions }); + // assert verify( mockedChildContainer.register( RequestRunnerOptions, @@ -193,13 +202,16 @@ describe('RepeaterFactory', () => { }); it('should register request runner options', async () => { + // arrange const factory = new RepeaterFactory(configuration); when( mockedChildContainer.register(RequestRunnerOptions, anything()) ).thenReturn(); + // act await factory.createRepeater({ requestRunnerOptions: defaultOptions }); + // assert verify( mockedChildContainer.register( RequestRunnerOptions, @@ -211,12 +223,15 @@ describe('RepeaterFactory', () => { }); it('should register request runners', async () => { + // arrange const factory = new RepeaterFactory(configuration); + // act await factory.createRepeater({ requestRunners: [HttpRequestRunner] }); + // assert verify( mockedChildContainer.register( RequestRunner, @@ -231,108 +246,34 @@ describe('RepeaterFactory', () => { }); it('should throw an error if name prefix is too long', async () => { + // arrange const factory = new RepeaterFactory(configuration); + // act const res = factory.createRepeater({ namePrefix: 'foo'.repeat(50) }); + // assert await expect(res).rejects.toThrow( 'Name prefix must be less than or equal to 43 characters.' ); }); it('should throw an error when name prefix is too long and random postfix is disabled', async () => { + // arrange const factory = new RepeaterFactory(configuration); + // act const res = factory.createRepeater({ namePrefix: 'foo'.repeat(80), disableRandomNameGeneration: true }); + // assert await expect(res).rejects.toThrow( 'Name prefix must be less than or equal to 80 characters.' ); }); }); - - describe('createRepeaterFromExisting', () => { - it('should create repeater from existing repeater ID', async () => { - const factory = new RepeaterFactory(configuration); - const existingRepeaterId = '123'; - - const res = await factory.createRepeaterFromExisting(existingRepeaterId); - - expect(res).toBeInstanceOf(Repeater); - expect(res).toMatchObject({ - repeaterId: existingRepeaterId - }); - }); - - it('should register custom request runner options', async () => { - const factory = new RepeaterFactory(configuration); - const existingRepeaterId = '123'; - when( - mockedChildContainer.register(RequestRunnerOptions, anything()) - ).thenReturn(); - - const requestRunnerOptions = { - timeout: 10000, - maxContentLength: 200, - allowedMimes: ['text/html'] - }; - - await factory.createRepeaterFromExisting(existingRepeaterId, { - requestRunnerOptions - }); - - verify( - mockedChildContainer.register( - RequestRunnerOptions, - objectContaining({ - useValue: requestRunnerOptions - }) - ) - ).once(); - }); - - it('should register request runner options', async () => { - const factory = new RepeaterFactory(configuration); - const existingRepeaterId = '123'; - - await factory.createRepeaterFromExisting(existingRepeaterId, { - requestRunnerOptions: defaultOptions - }); - - verify( - mockedChildContainer.register( - RequestRunnerOptions, - deepEqual({ - useValue: defaultOptions - }) - ) - ).once(); - }); - - it('should register request runners', async () => { - const factory = new RepeaterFactory(configuration); - const existingRepeaterId = '123'; - - await factory.createRepeaterFromExisting(existingRepeaterId, { - requestRunners: [HttpRequestRunner] - }); - - verify( - mockedChildContainer.register( - RequestRunner, - deepEqual({ - useClass: HttpRequestRunner - }), - deepEqual({ - lifecycle: Lifecycle.ContainerScoped - }) - ) - ).once(); - }); - }); }); diff --git a/packages/repeater/src/lib/RepeaterFactory.ts b/packages/repeater/src/lib/RepeaterFactory.ts index 9168c48a..26abde14 100644 --- a/packages/repeater/src/lib/RepeaterFactory.ts +++ b/packages/repeater/src/lib/RepeaterFactory.ts @@ -2,8 +2,10 @@ import { Repeater, RepeaterId } from './Repeater'; import { RequestRunner, RequestRunnerOptions } from '../request-runner'; import { RepeaterOptions } from './RepeaterOptions'; import { RepeatersManager } from '../api'; +import { RepeaterBusFactory } from '../bus/RepeaterBusFactory'; +import { DefaultRepeaterServerOptions } from '../bus/DefaultRepeaterServer'; import { RepeaterRequestRunnerOptions } from './RepeaterRequestRunnerOptions'; -import { Configuration, EventBus } from '@sectester/core'; +import { Configuration } from '@sectester/core'; import { v4 as uuidv4 } from 'uuid'; import { DependencyContainer, injectable, Lifecycle } from 'tsyringe'; @@ -41,43 +43,35 @@ export class RepeaterFactory { return this.createRepeaterInstance(repeaterId, requestRunnerOptions); } - public async createRepeaterFromExisting( - repeaterId: string, - options?: RepeaterRequestRunnerOptions - ): Promise { - await this.repeatersManager.getRepeater(repeaterId); - - return this.createRepeaterInstance(repeaterId, options); - } - private async createRepeaterInstance( repeaterId: string, { requestRunnerOptions, requestRunners = [] }: RepeaterRequestRunnerOptions = {} - ) { + ): Promise { const container = this.configuration.container.createChildContainer(); container.register(RepeaterId, { useValue: repeaterId }); + await this.registerRepeaterServerOptions(container); this.registerRequestRunnerOptions(container, requestRunnerOptions); this.registerRequestRunners(container, requestRunners); - const bus = await this.createEventBus(container); + const busFactory = + container.resolve(RepeaterBusFactory); return new Repeater({ - bus, repeaterId, - configuration: this.configuration + bus: busFactory.create(repeaterId) }); } - private async createEventBus( + private async registerRepeaterServerOptions( container: DependencyContainer - ): Promise { + ): Promise { await this.configuration.loadCredentials(); if (!this.configuration.credentials) { @@ -86,11 +80,16 @@ export class RepeaterFactory { ); } - const bus = container.resolve(EventBus); - - await bus.init?.(); - - return bus; + container.register( + DefaultRepeaterServerOptions, + { + useValue: { + uri: `${this.configuration.api}/workstations`, + token: this.configuration.credentials?.token as string, + connectTimeout: 10000 + } + } + ); } private generateName( diff --git a/packages/repeater/src/register.ts b/packages/repeater/src/register.ts index a1d8f55b..bb0ec1b6 100644 --- a/packages/repeater/src/register.ts +++ b/packages/repeater/src/register.ts @@ -1,5 +1,13 @@ import { RepeaterFactory, RepeaterId } from './lib'; import { DefaultRepeatersManager, RepeatersManager } from './api'; +import { + DefaultRepeaterBusFactory, + DefaultRepeaterCommandHub, + DefaultRepeaterServer, + RepeaterBusFactory, + RepeaterCommandHub, + RepeaterServer +} from './bus'; import { HttpRequestRunner, RequestRunner, @@ -86,3 +94,6 @@ container.register(EventBus, { }); container.register(RepeatersManager, { useClass: DefaultRepeatersManager }); +container.register(RepeaterServer, { useClass: DefaultRepeaterServer }); +container.register(RepeaterCommandHub, { useClass: DefaultRepeaterCommandHub }); +container.register(RepeaterBusFactory, { useClass: DefaultRepeaterBusFactory }); diff --git a/packages/repeater/src/request-runner/Request.ts b/packages/repeater/src/request-runner/Request.ts index ad57de5b..4ab420c9 100644 --- a/packages/repeater/src/request-runner/Request.ts +++ b/packages/repeater/src/request-runner/Request.ts @@ -4,9 +4,14 @@ import { URL } from 'url'; export interface RequestOptions { protocol: Protocol; url: string; - method?: string; headers?: Record; + method?: string; body?: string; + correlationIdRegex?: string | RegExp; + encoding?: 'base64'; + maxContentSize?: number; + timeout?: number; + decompress?: boolean; } export class Request { @@ -26,34 +31,80 @@ export class Request { 'referer', 'user-agent' ]); + public readonly protocol: Protocol; public readonly url: string; public readonly body?: string; + public readonly correlationIdRegex?: RegExp; + public readonly encoding?: 'base64'; + public readonly maxContentSize?: number; + public readonly decompress?: boolean; + public readonly timeout?: number; - private readonly _method?: string; + private _method: string; - get method(): string | undefined { + get method(): string { return this._method; } - private _headers?: Record; + private _headers: Record = {}; - get headers(): Readonly> | undefined { + get headers(): Readonly> { return this._headers; } + private _ca?: Buffer; + + get ca() { + return this._ca; + } + + private _pfx?: Buffer; + + get pfx() { + return this._pfx; + } + + private _passphrase?: string; + + get passphrase() { + return this._passphrase; + } + get secureEndpoint(): boolean { return this.url.startsWith('https'); } - constructor({ protocol, method, url, body, headers = {} }: RequestOptions) { + constructor({ + protocol, + method, + url, + body, + timeout, + correlationIdRegex, + maxContentSize, + encoding, + decompress = true, + headers = {} + }: RequestOptions) { this.protocol = protocol; this._method = method?.toUpperCase() ?? 'GET'; + this.validateUrl(url); - this.url = url; - this.setHeaders(headers); + this.url = url.trim(); + this.precheckBody(body); this.body = body; + + this.correlationIdRegex = + this.normalizeCorrelationIdRegex(correlationIdRegex); + + this.setHeaders(headers); + + this.encoding = encoding; + this.timeout = timeout; + this.maxContentSize = maxContentSize; + this.decompress = !!decompress; } public setHeaders(headers: Record): void { @@ -62,16 +113,17 @@ export class Request { ...headers }; - this._headers = Object.fromEntries( - Object.entries(mergedHeaders).map( - ([field, value]: [string, string | string[]]) => [ - field, + this._headers = Object.entries(mergedHeaders).reduce( + (result, [field, value]: [string, string | string[]]) => { + result[field] = Array.isArray(value) && Request.SINGLE_VALUE_HEADERS.has(field.toLowerCase()) ? value.join(', ') - : value - ] - ) + : value; + + return result; + }, + {} ); } @@ -88,4 +140,16 @@ export class Request { throw new Error('Body must be string.'); } } + + private normalizeCorrelationIdRegex( + correlationIdRegex: RegExp | string | undefined + ): RegExp | undefined { + if (correlationIdRegex) { + try { + return new RegExp(correlationIdRegex, 'i'); + } catch { + throw new Error('Correlation id must be regular expression.'); + } + } + } } diff --git a/packages/repeater/src/request-runner/Response.ts b/packages/repeater/src/request-runner/Response.ts index b522aa2d..f05b010c 100644 --- a/packages/repeater/src/request-runner/Response.ts +++ b/packages/repeater/src/request-runner/Response.ts @@ -3,8 +3,9 @@ import { Protocol } from '../models'; export class Response { public readonly protocol: Protocol; public readonly statusCode?: number; - public readonly headers?: Record; + public readonly headers?: Record; public readonly body?: string; + public readonly encoding?: 'base64'; public readonly message?: string; public readonly errorCode?: string; @@ -14,14 +15,16 @@ export class Response { headers, body, message, - errorCode + errorCode, + encoding }: { protocol: Protocol; statusCode?: number; message?: string; errorCode?: string; - headers?: Record; + headers?: Record; body?: string; + encoding?: 'base64'; }) { this.protocol = protocol; this.statusCode = statusCode; @@ -29,5 +32,6 @@ export class Response { this.body = body; this.errorCode = errorCode; this.message = message; + this.encoding = encoding; } } diff --git a/packages/repeater/src/request-runner/protocols/HttpRequestRunner.ts b/packages/repeater/src/request-runner/protocols/HttpRequestRunner.ts index 00762e75..623b332d 100644 --- a/packages/repeater/src/request-runner/protocols/HttpRequestRunner.ts +++ b/packages/repeater/src/request-runner/protocols/HttpRequestRunner.ts @@ -81,7 +81,10 @@ export class HttpRequestRunner implements RequestRunner { return new Response({ protocol: this.protocol, statusCode: response.statusCode, - headers: response.headers, + headers: (response.headers ?? {}) as unknown as Record< + string, + string | string[] + >, body: response.body }); } catch (err) { From 7caf3ee7d5f90859821d9d6e2d56adaef4ac8e30 Mon Sep 17 00:00:00 2001 From: Dmitry Ostrikov Date: Tue, 21 May 2024 19:35:50 +0400 Subject: [PATCH 03/17] feat(repeater): address code coverage issues closes #196 --- package-lock.json | 343 +++++++++++++----- package.json | 4 +- .../src/bus/DefaultRepeaterBus.spec.ts | 229 ++++++++++++ .../repeater/src/bus/DefaultRepeaterBus.ts | 17 +- .../src/bus/DefaultRepeaterBusFactory.spec.ts | 47 +++ .../src/bus/DefaultRepeaterCommandHub.spec.ts | 62 ++++ .../src/bus/DefaultRepeaterCommandHub.ts | 2 +- .../src/bus/DefaultRepeaterServer.spec.ts | 221 +++++++++++ .../repeater/src/bus/DefaultRepeaterServer.ts | 132 +++---- .../src/bus/RepeaterApplicationEvents.spec.ts | 96 +++++ .../src/bus/RepeaterApplicationEvents.ts | 87 +++++ packages/repeater/src/bus/index.ts | 1 + 12 files changed, 1062 insertions(+), 179 deletions(-) create mode 100644 packages/repeater/src/bus/DefaultRepeaterBus.spec.ts create mode 100644 packages/repeater/src/bus/DefaultRepeaterBusFactory.spec.ts create mode 100644 packages/repeater/src/bus/DefaultRepeaterCommandHub.spec.ts create mode 100644 packages/repeater/src/bus/DefaultRepeaterServer.spec.ts create mode 100644 packages/repeater/src/bus/RepeaterApplicationEvents.spec.ts create mode 100644 packages/repeater/src/bus/RepeaterApplicationEvents.ts diff --git a/package-lock.json b/package-lock.json index 469dc98f..f28ff55c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,10 +21,7 @@ "chalk": "^4.1.2", "ci-info": "^3.3.0", "content-type": "^1.0.4", - "find-up": "^5.0.0", "form-data": "^4.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.4", "reflect-metadata": "^0.1.13", "semver": "^7.5.2", "socket.io-client": "^4.7.5", @@ -72,6 +69,7 @@ "nx": "14.5.6", "prettier": "2.7.1", "semantic-release": "~19.0.3", + "socket.io": "^4.7.5", "ts-jest": "27.1.4", "ts-mockito": "^2.6.1", "typescript": "4.7.4" @@ -2481,6 +2479,21 @@ "integrity": "sha512-dgMN+syt1xb7Hk8LU6AODOfPlvz5z1CbXpPuJE5ZrX9STfBOIXF09pEB8N7a97WT9dbngt3ksDCm6GW6yMrxfQ==", "dev": true }, + "node_modules/@types/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==", + "dev": true + }, + "node_modules/@types/cors": { + "version": "2.8.17", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", + "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/eslint": { "version": "8.4.5", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.5.tgz", @@ -3030,6 +3043,19 @@ "integrity": "sha512-9IK9EadsbHo6jLWIpxpR6pL0sazTXV6+SQv25ZB+F7Bj9mJNaOc4nCRabwd5M/JwmUa8idz6Eci6eKfJryPs6Q==", "dev": true }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.8.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz", @@ -3527,6 +3553,15 @@ } ] }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "dev": true, + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/before-after-hook": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.2.tgz", @@ -4233,6 +4268,15 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true }, + "node_modules/cookie": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", + "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/copy-webpack-plugin": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-10.2.4.tgz", @@ -4318,6 +4362,19 @@ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dev": true, + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/cosmiconfig": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz", @@ -4805,6 +4862,27 @@ "once": "^1.4.0" } }, + "node_modules/engine.io": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.4.tgz", + "integrity": "sha512-KdVSDKhVKyOi+r5uEabrDLZw2qXStVvCsEB/LN3mw4WFi6Gx50jTyuxYVCwAAC0U46FdnzP/ScKRBTXb/NiEOg==", + "dev": true, + "dependencies": { + "@types/cookie": "^0.4.1", + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.4.1", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.11.0" + }, + "engines": { + "node": ">=10.2.0" + } + }, "node_modules/engine.io-client": { "version": "6.5.3", "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.3.tgz", @@ -5776,6 +5854,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -6429,52 +6508,6 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/http-proxy-agent/node_modules/agent-base": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", - "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", - "dependencies": { - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", - "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", - "dependencies": { - "agent-base": "^7.0.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent/node_modules/agent-base": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", - "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", - "dependencies": { - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -8420,6 +8453,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, "dependencies": { "p-locate": "^5.0.0" }, @@ -8890,6 +8924,15 @@ "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "dev": true }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/neo-async": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", @@ -11672,6 +11715,15 @@ "node": ">=12" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", @@ -11831,6 +11883,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, "dependencies": { "yocto-queue": "^0.1.0" }, @@ -11845,6 +11898,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, "dependencies": { "p-limit": "^3.0.2" }, @@ -13338,6 +13392,34 @@ "node": ">=6" } }, + "node_modules/socket.io": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.7.5.tgz", + "integrity": "sha512-DmeAkF6cwM9jSfmp6Dr/5/mfMwb5Z5qRrSXLpo3Fq5SqyU8CMF15jIN4ZhfSwu35ksM1qmHZDQ/DK5XTccSTvA==", + "dev": true, + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.3.2", + "engine.io": "~6.5.2", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.4.tgz", + "integrity": "sha512-wDNHGXGewWAjQPt3pyeYBtpWSq9cLE5UW1ZUPL/2eGK9jtse/FpXib7epSTsz0Q0m+6sg6Y4KtcFTlah1bdOVg==", + "dev": true, + "dependencies": { + "debug": "~4.3.4", + "ws": "~8.11.0" + } + }, "node_modules/socket.io-client": { "version": "4.7.5", "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.5.tgz", @@ -14605,6 +14687,15 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", @@ -15050,6 +15141,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, "engines": { "node": ">=10" }, @@ -17059,6 +17151,21 @@ "integrity": "sha512-dgMN+syt1xb7Hk8LU6AODOfPlvz5z1CbXpPuJE5ZrX9STfBOIXF09pEB8N7a97WT9dbngt3ksDCm6GW6yMrxfQ==", "dev": true }, + "@types/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==", + "dev": true + }, + "@types/cors": { + "version": "2.8.17", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", + "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/eslint": { "version": "8.4.5", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.5.tgz", @@ -17518,6 +17625,16 @@ "integrity": "sha512-9IK9EadsbHo6jLWIpxpR6pL0sazTXV6+SQv25ZB+F7Bj9mJNaOc4nCRabwd5M/JwmUa8idz6Eci6eKfJryPs6Q==", "dev": true }, + "accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "requires": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + } + }, "acorn": { "version": "8.8.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz", @@ -17873,6 +17990,12 @@ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "dev": true }, + "base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "dev": true + }, "before-after-hook": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.2.tgz", @@ -18405,6 +18528,12 @@ } } }, + "cookie": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", + "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", + "dev": true + }, "copy-webpack-plugin": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-10.2.4.tgz", @@ -18461,6 +18590,16 @@ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" }, + "cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dev": true, + "requires": { + "object-assign": "^4", + "vary": "^1" + } + }, "cosmiconfig": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz", @@ -18846,6 +18985,24 @@ "once": "^1.4.0" } }, + "engine.io": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.4.tgz", + "integrity": "sha512-KdVSDKhVKyOi+r5uEabrDLZw2qXStVvCsEB/LN3mw4WFi6Gx50jTyuxYVCwAAC0U46FdnzP/ScKRBTXb/NiEOg==", + "dev": true, + "requires": { + "@types/cookie": "^0.4.1", + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.4.1", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.11.0" + } + }, "engine.io-client": { "version": "6.5.3", "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.3.tgz", @@ -19586,6 +19743,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, "requires": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -20070,44 +20228,6 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, - "http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "requires": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "dependencies": { - "agent-base": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", - "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", - "requires": { - "debug": "^4.3.4" - } - } - } - }, - "https-proxy-agent": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", - "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", - "requires": { - "agent-base": "^7.0.2", - "debug": "4" - }, - "dependencies": { - "agent-base": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", - "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", - "requires": { - "debug": "^4.3.4" - } - } - } - }, "human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -21516,6 +21636,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, "requires": { "p-locate": "^5.0.0" } @@ -21865,6 +21986,12 @@ "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "dev": true }, + "negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true + }, "neo-async": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", @@ -23837,6 +23964,12 @@ } } }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true + }, "object-inspect": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", @@ -23950,6 +24083,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, "requires": { "yocto-queue": "^0.1.0" } @@ -23958,6 +24092,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, "requires": { "p-limit": "^3.0.2" } @@ -25061,6 +25196,31 @@ } } }, + "socket.io": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.7.5.tgz", + "integrity": "sha512-DmeAkF6cwM9jSfmp6Dr/5/mfMwb5Z5qRrSXLpo3Fq5SqyU8CMF15jIN4ZhfSwu35ksM1qmHZDQ/DK5XTccSTvA==", + "dev": true, + "requires": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.3.2", + "engine.io": "~6.5.2", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + } + }, + "socket.io-adapter": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.4.tgz", + "integrity": "sha512-wDNHGXGewWAjQPt3pyeYBtpWSq9cLE5UW1ZUPL/2eGK9jtse/FpXib7epSTsz0Q0m+6sg6Y4KtcFTlah1bdOVg==", + "dev": true, + "requires": { + "debug": "~4.3.4", + "ws": "~8.11.0" + } + }, "socket.io-client": { "version": "4.7.5", "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.5.tgz", @@ -26045,6 +26205,12 @@ "spdx-expression-parse": "^3.0.0" } }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true + }, "w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", @@ -26384,7 +26550,8 @@ "yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true } } } diff --git a/package.json b/package.json index 4f16f37e..63e5ca5d 100644 --- a/package.json +++ b/package.json @@ -84,10 +84,7 @@ "chalk": "^4.1.2", "ci-info": "^3.3.0", "content-type": "^1.0.4", - "find-up": "^5.0.0", "form-data": "^4.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.4", "reflect-metadata": "^0.1.13", "semver": "^7.5.2", "socket.io-client": "^4.7.5", @@ -135,6 +132,7 @@ "nx": "14.5.6", "prettier": "2.7.1", "semantic-release": "~19.0.3", + "socket.io": "^4.7.5", "ts-jest": "27.1.4", "ts-mockito": "^2.6.1", "typescript": "4.7.4" diff --git a/packages/repeater/src/bus/DefaultRepeaterBus.spec.ts b/packages/repeater/src/bus/DefaultRepeaterBus.spec.ts new file mode 100644 index 00000000..203dc2f4 --- /dev/null +++ b/packages/repeater/src/bus/DefaultRepeaterBus.spec.ts @@ -0,0 +1,229 @@ +import { DefaultRepeaterBus } from './DefaultRepeaterBus'; +import { Protocol } from '../models/Protocol'; +import { Request, Response } from '../request-runner'; +import { RepeaterApplicationEvents } from './RepeaterApplicationEvents'; +import { + RepeaterErrorCodes, + RepeaterServer, + RepeaterServerEvents, + RepeaterServerRequestEvent +} from './RepeaterServer'; +import { RepeaterCommandHub } from './RepeaterCommandHub'; +import { delay, Logger } from '@sectester/core'; +import { + anything, + instance, + mock, + objectContaining, + reset, + verify, + when +} from 'ts-mockito'; + +describe('DefaultRepeaterBus', () => { + const RepeaterId = 'fooId'; + + let events!: RepeaterApplicationEvents; + let sut!: DefaultRepeaterBus; + + const mockedRepeaterServer = mock(); + const mockedRepeaterCommandHub = mock(); + const mockedLogger = mock(); + + beforeEach(() => { + events = new RepeaterApplicationEvents(); + + when(mockedRepeaterServer.on(anything(), anything())).thenCall( + (event, handler) => events.on(event, handler) + ); + when(mockedRepeaterServer.off(anything(), anything())).thenCall( + (event, handler) => events.off(event, handler) + ); + + sut = new DefaultRepeaterBus( + RepeaterId, + instance(mockedLogger), + instance(mockedRepeaterServer), + instance(mockedRepeaterCommandHub) + ); + }); + + afterEach(() => + reset( + mockedRepeaterServer, + mockedRepeaterCommandHub, + mockedLogger + ) + ); + + describe('connect', () => { + it('should connect', async () => { + // act + await sut.connect(); + + // assert + verify(mockedRepeaterServer.connect(RepeaterId)).once(); + verify( + mockedRepeaterServer.deploy( + objectContaining({ repeaterId: RepeaterId }) + ) + ).once(); + }); + + it('should throw when underlying connect throws', async () => { + // arrange + when(mockedRepeaterServer.connect(RepeaterId)).thenReject( + new Error('foo') + ); + + // act + const act = () => sut.connect(); + + // assert + await expect(act).rejects.toThrowError('foo'); + }); + + it('should throw when underlying deploy throws', async () => { + // arrange + when(mockedRepeaterServer.deploy(anything())).thenReject( + new Error('foo') + ); + + // act + const act = () => sut.connect(); + + // assert + await expect(act).rejects.toThrowError('foo'); + }); + }); + + describe('close', () => { + it('should close', async () => { + // act + await sut.close(); + + // assert + verify(mockedRepeaterServer.disconnect()).once(); + }); + }); + + describe('events', () => { + it(`should subscribe to ${RepeaterServerEvents.UPDATE_AVAILABLE}`, async () => { + // arrange + await sut.connect(); + + // act + events.emit(RepeaterServerEvents.UPDATE_AVAILABLE, { version: '1.0.0' }); + + // assert + verify( + mockedLogger.warn( + '%s: A new Repeater version (%s) is available, for update instruction visit https://docs.brightsec.com/docs/installation-options', + anything(), + '1.0.0' + ) + ).once(); + }); + + it(`should subscribe to ${RepeaterServerEvents.REQUEST}`, async () => { + // arrange + const requestEvent: RepeaterServerRequestEvent = { + protocol: Protocol.HTTP, + url: 'http://foo.bar', + method: 'GET' + }; + + const request = new Request(requestEvent); + + when(mockedRepeaterCommandHub.sendRequest(anything())).thenResolve( + new Response({ + protocol: Protocol.HTTP, + statusCode: 200 + }) + ); + + await sut.connect(); + + // act + events.emit(RepeaterServerEvents.REQUEST, requestEvent); + + // assert + await delay(200); + verify( + mockedRepeaterCommandHub.sendRequest(objectContaining(request)) + ).once(); + }); + + it(`should subscribe to ${RepeaterServerEvents.RECONNECT_ATTEMPT}`, async () => { + // arrange + await sut.connect(); + + // act + events.emit(RepeaterServerEvents.RECONNECT_ATTEMPT, { + attempt: 1, + maxAttempts: 3 + }); + + // assert + verify( + mockedLogger.warn( + 'Failed to connect to Bright cloud (attempt %d/%d)', + anything(), + anything() + ) + ).once(); + }); + + it(`should subscribe to ${RepeaterServerEvents.ERROR} and proceed on error`, async () => { + // arrange + await sut.connect(); + + // act + events.emit(RepeaterServerEvents.ERROR, { + code: RepeaterErrorCodes.UNKNOWN_ERROR, + message: 'error' + }); + + // assert + verify(mockedLogger.error('error')).once(); + }); + + it(`should subscribe to ${RepeaterServerEvents.ERROR} and proceed on critical error`, async () => { + // arrange + await sut.connect(); + + // act + events.emit(RepeaterServerEvents.ERROR, { + code: RepeaterErrorCodes.UNEXPECTED_ERROR, + message: 'unexpected error', + remediation: 'remediation' + }); + + // assert + verify( + mockedLogger.error( + '%s: %s. %s', + anything(), + 'unexpected error', + 'remediation' + ) + ).once(); + verify(mockedRepeaterServer.disconnect()).once(); + }); + + it(`should subscribe to ${RepeaterServerEvents.RECONNECTION_FAILED}`, async () => { + // arrange + const error = new Error('test error'); + await sut.connect(); + + // act + events.emit(RepeaterServerEvents.RECONNECTION_FAILED, { + error + }); + + // assert + verify(mockedLogger.error(error)).once(); + verify(mockedRepeaterServer.disconnect()).once(); + }); + }); +}); diff --git a/packages/repeater/src/bus/DefaultRepeaterBus.ts b/packages/repeater/src/bus/DefaultRepeaterBus.ts index f17b1209..a3bf6318 100644 --- a/packages/repeater/src/bus/DefaultRepeaterBus.ts +++ b/packages/repeater/src/bus/DefaultRepeaterBus.ts @@ -45,13 +45,22 @@ export class DefaultRepeaterBus implements RepeaterBus { this.logger.log('Deploying the Repeater (%s)...', this.repeaterId); - await this.repeaterServer.deploy({ - repeaterId: this.repeaterId - }); + await this.deploy(); this.logger.log('The Repeater (%s) started', this.repeaterId); } + private async deploy() { + await this.deployRepeater(); + this.repeaterServer.on(RepeaterServerEvents.CONNECTED, this.deployRepeater); + } + + private deployRepeater = async () => { + await this.repeaterServer.deploy({ + repeaterId: this.repeaterId + }); + }; + private subscribeToEvents() { this.repeaterServer.on(RepeaterServerEvents.ERROR, this.handleError); this.repeaterServer.on( @@ -118,7 +127,6 @@ export class DefaultRepeaterBus implements RepeaterBus { remediation ); this.close().catch(this.logger.error); - process.exitCode = 1; } private reconnectionFailed = ({ @@ -126,7 +134,6 @@ export class DefaultRepeaterBus implements RepeaterBus { }: RepeaterServerReconnectionFailedEvent) => { this.logger.error(error); this.close().catch(this.logger.error); - process.exitCode = 1; }; private requestReceived = async (event: RepeaterServerRequestEvent) => { diff --git a/packages/repeater/src/bus/DefaultRepeaterBusFactory.spec.ts b/packages/repeater/src/bus/DefaultRepeaterBusFactory.spec.ts new file mode 100644 index 00000000..b7bce6aa --- /dev/null +++ b/packages/repeater/src/bus/DefaultRepeaterBusFactory.spec.ts @@ -0,0 +1,47 @@ +import { DefaultRepeaterBusFactory } from './DefaultRepeaterBusFactory'; +import { RepeaterServer } from './RepeaterServer'; +import { RepeaterCommandHub } from './RepeaterCommandHub'; +import { DefaultRepeaterBus } from './DefaultRepeaterBus'; +import { Configuration, Logger } from '@sectester/core'; +import { instance, mock, reset } from 'ts-mockito'; + +describe('DefaultRepeaterBusFactory', () => { + const repeaterId = 'fooId'; + + const mockedLogger = mock(); + const mockedConfiguration = mock(); + const mockedRepeaterServer = mock(); + const mockedRepeaterCommandHub = mock(); + + const configuration = instance(mockedConfiguration); + + let sut!: DefaultRepeaterBusFactory; + + beforeEach(() => { + sut = new DefaultRepeaterBusFactory( + instance(mockedLogger), + configuration, + instance(mockedRepeaterServer), + instance(mockedRepeaterCommandHub) + ); + }); + + afterEach(() => { + reset( + mockedLogger, + mockedConfiguration, + mockedRepeaterServer, + mockedRepeaterCommandHub + ); + }); + + describe('create', () => { + it('should create', () => { + // act + const res = sut.create(repeaterId); + + // assert + expect(res).toBeInstanceOf(DefaultRepeaterBus); + }); + }); +}); diff --git a/packages/repeater/src/bus/DefaultRepeaterCommandHub.spec.ts b/packages/repeater/src/bus/DefaultRepeaterCommandHub.spec.ts new file mode 100644 index 00000000..9ddfda88 --- /dev/null +++ b/packages/repeater/src/bus/DefaultRepeaterCommandHub.spec.ts @@ -0,0 +1,62 @@ +import { DefaultRepeaterCommandHub } from './DefaultRepeaterCommandHub'; +import { Protocol } from '../models/Protocol'; +import { RequestRunner, Request, Response } from '../request-runner'; +import { instance, mock, reset, when } from 'ts-mockito'; + +describe('DefaultRepeaterCommandHub', () => { + let sut!: DefaultRepeaterCommandHub; + + const mockedRequestRunner = mock(); + + beforeEach(() => { + sut = new DefaultRepeaterCommandHub([instance(mockedRequestRunner)]); + }); + + afterEach(() => reset(mockedRequestRunner)); + + describe('sendRequest', () => { + it('should send', async () => { + // arrange + const request = new Request({ + protocol: Protocol.HTTP, + url: 'http://foo.bar', + method: 'GET' + }); + + const response = new Response({ + protocol: Protocol.HTTP, + statusCode: 200 + }); + + when(mockedRequestRunner.protocol).thenReturn(Protocol.HTTP); + when(mockedRequestRunner.run(request)).thenResolve(response); + + // act + const result = await sut.sendRequest(request); + + // assert + expect(result).toEqual(response); + }); + + it('should throw when there are no suitable protocol handler', async () => { + // arrange + const request = new Request({ + protocol: Protocol.HTTP, + url: 'http://foo.bar', + method: 'GET' + }); + + when(mockedRequestRunner.protocol).thenReturn( + 'someOtherProtocol' as Protocol + ); + + // act + const act = () => sut.sendRequest(request); + + // assert + await expect(act).rejects.toThrow( + `Unsupported protocol "${Protocol.HTTP}"` + ); + }); + }); +}); diff --git a/packages/repeater/src/bus/DefaultRepeaterCommandHub.ts b/packages/repeater/src/bus/DefaultRepeaterCommandHub.ts index 5f147207..46c7e8a4 100644 --- a/packages/repeater/src/bus/DefaultRepeaterCommandHub.ts +++ b/packages/repeater/src/bus/DefaultRepeaterCommandHub.ts @@ -9,7 +9,7 @@ export class DefaultRepeaterCommandHub implements RepeaterCommandHub { private readonly requestRunners: RequestRunner[] ) {} - public sendRequest(request: Request): Promise { + public async sendRequest(request: Request): Promise { const { protocol } = request; const requestRunner = this.requestRunners.find( diff --git a/packages/repeater/src/bus/DefaultRepeaterServer.spec.ts b/packages/repeater/src/bus/DefaultRepeaterServer.spec.ts new file mode 100644 index 00000000..6825c777 --- /dev/null +++ b/packages/repeater/src/bus/DefaultRepeaterServer.spec.ts @@ -0,0 +1,221 @@ +import { + DefaultRepeaterServer, + DefaultRepeaterServerOptions, + SocketEvents +} from './DefaultRepeaterServer'; +import { + RepeaterErrorCodes, + RepeaterServerEventHandler, + RepeaterServerEvents +} from './RepeaterServer'; +import { Protocol } from '../models/Protocol'; +import { RepeaterApplicationEvents } from './RepeaterApplicationEvents'; +import { RepeaterCommandHub } from './RepeaterCommandHub'; +import { delay, Logger } from '@sectester/core'; +import { anything, instance, mock, reset, verify, when } from 'ts-mockito'; +import { Server } from 'socket.io'; +import msgpack from 'socket.io-msgpack-parser'; +import { createServer, Server as HttpServer } from 'http'; + +class MockSocketServer { + private readonly httpServer: HttpServer; + private readonly io: Server; + + get address() { + const address = this.httpServer.address(); + if (typeof address === 'string') { + return address; + } + + return `http://localhost:${address?.port}`; + } + + constructor() { + this.httpServer = createServer(); + + this.httpServer.listen(0); + + this.io = new Server(this.httpServer, { + path: '/api/ws/v1', + parser: msgpack + }); + } + + public onConnection(callback: (socket: any) => void) { + this.io.on('connection', callback); + } + + public close() { + this.io.close(); + } + + public emit(event: string, data: any) { + this.io.sockets.emit(event, data); + } +} + +describe('DefaultRepeaterServer', () => { + const RepeaterId = 'fooId'; + + let events!: RepeaterApplicationEvents; + let sut!: DefaultRepeaterServer; + let mockServer!: MockSocketServer; + + const mockedLogger = mock(); + const mockedDefaultRepeaterServerOptions = + mock(); + + beforeEach(() => { + mockServer = new MockSocketServer(); + + events = new RepeaterApplicationEvents(); + + sut = new DefaultRepeaterServer( + instance(mockedLogger), + events, + instance(mockedDefaultRepeaterServerOptions) + ); + + const address = mockServer.address; + + when(mockedDefaultRepeaterServerOptions.uri).thenReturn(address); + when(mockedDefaultRepeaterServerOptions.token).thenReturn('token'); + when(mockedDefaultRepeaterServerOptions.connectTimeout).thenReturn(10_00); + }); + + afterEach(() => { + sut.disconnect(); + + mockServer.close(); + + reset( + mockedLogger, + mockedDefaultRepeaterServerOptions + ); + }); + + describe('connect', () => { + it('should connect', async () => { + // act + await sut.connect(RepeaterId); + + // assert + verify(mockedLogger.debug('Repeater connected to %s', anything())).once(); + }); + }); + + describe('deploy', () => { + it('should deploy', async () => { + // arrange + const event = { repeaterId: RepeaterId }; + + mockServer.onConnection(socket => { + socket.on('deploy', () => { + socket.emit('deployed', event); + }); + }); + + const handler: RepeaterServerEventHandler = jest.fn(); + + sut.on(RepeaterServerEvents.DEPLOY, handler); + + await sut.connect(RepeaterId); + + // act + await sut.deploy({ repeaterId: RepeaterId }); + + // assert + expect(handler).toHaveBeenCalledWith(event); + }); + }); + + describe('disconnect', () => { + it('should disconnect', async () => { + // arrange + const handler: RepeaterServerEventHandler = jest.fn(); + + sut.on(RepeaterServerEvents.DEPLOY, handler); + + await sut.connect(RepeaterId); + + // act + const act = () => sut.disconnect(); + + // assert + expect(act).not.toThrow(); + }); + }); + + describe('on', () => { + it.each([ + { + input: { + event: SocketEvents.UPDATE_AVAILABLE, + data: { version: '1.0.0' } + }, + expected: { + event: RepeaterServerEvents.UPDATE_AVAILABLE, + data: [{ version: '1.0.0' }] + } + }, + { + input: { + event: SocketEvents.ERROR, + data: { code: RepeaterErrorCodes.UNKNOWN_ERROR, message: 'msg' } + }, + expected: { + event: RepeaterServerEvents.ERROR, + data: [{ code: RepeaterErrorCodes.UNKNOWN_ERROR, message: 'msg' }] + } + }, + { + input: { + event: SocketEvents.REQUEST, + data: { protocol: Protocol.HTTP, url: 'https://foo.com' } + }, + expected: { + event: RepeaterServerEvents.REQUEST, + data: [{ protocol: Protocol.HTTP, url: 'https://foo.com' }, undefined] + } + } + ])( + 'should propagate $input.event data to $expected.event', + async ({ input, expected }) => { + // arrange + const handler: RepeaterServerEventHandler = jest.fn(); + + sut.on(expected.event, handler); + + await sut.connect(RepeaterId); + + // act + mockServer.emit(input.event, input.data); + + // assert + await delay(200); + expect(handler).toHaveBeenCalledWith(...expected.data); + } + ); + }); + + describe('off', () => { + it('should not invoke handler when it switched off', async () => { + // arrange + const event = { code: RepeaterErrorCodes.UNKNOWN_ERROR, message: 'msg' }; + + const handler: RepeaterServerEventHandler = jest.fn(); + + sut.on(RepeaterServerEvents.ERROR, handler); + + await sut.connect(RepeaterId); + + sut.off(RepeaterServerEvents.ERROR, handler); + + // act + mockServer.emit(SocketEvents.ERROR, event); + + // assert + expect(handler).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/repeater/src/bus/DefaultRepeaterServer.ts b/packages/repeater/src/bus/DefaultRepeaterServer.ts index a62accb6..2748312d 100644 --- a/packages/repeater/src/bus/DefaultRepeaterServer.ts +++ b/packages/repeater/src/bus/DefaultRepeaterServer.ts @@ -1,3 +1,4 @@ +// eslint-disable-next-line max-classes-per-file import { DeployCommandOptions, DeploymentRuntime, @@ -14,12 +15,16 @@ import { RepeaterServerRequestResponse, RepeaterUpgradeAvailableEvent } from './RepeaterServer'; +import { + CallbackFunction, + RepeaterApplicationEvents +} from './RepeaterApplicationEvents'; import { Logger } from '@sectester/core'; import { inject, injectable } from 'tsyringe'; import io, { Socket } from 'socket.io-client'; import parser from 'socket.io-msgpack-parser'; import { ErrorEvent } from 'ws'; -import { EventEmitter, once } from 'events'; +import { once } from 'events'; import Timer = NodeJS.Timer; export interface DefaultRepeaterServerOptions { @@ -34,10 +39,7 @@ export const DefaultRepeaterServerOptions: unique symbol = Symbol( 'DefaultRepeaterServerOptions' ); -type CallbackFunction = (arg: T) => unknown; -type HandlerFunction = (args: unknown[]) => unknown; - -const enum SocketEvents { +export const enum SocketEvents { DEPLOYED = 'deployed', DEPLOY = 'deploy', UNDEPLOY = 'undeploy', @@ -77,11 +79,7 @@ export class DefaultRepeaterServer implements RepeaterServer { private readonly MAX_RECONNECTION_ATTEMPTS = 20; private readonly MIN_RECONNECTION_DELAY = 1000; private readonly MAX_RECONNECTION_DELAY = 86_400_000; - private readonly events = new EventEmitter(); - private readonly handlerMap = new WeakMap< - RepeaterServerEventHandler, - HandlerFunction - >(); + private latestReconnectionError?: Error; private pingTimer?: Timer; private connectionTimer?: Timer; @@ -100,12 +98,29 @@ export class DefaultRepeaterServer implements RepeaterServer { constructor( private readonly logger: Logger, + private readonly applicationEvents: RepeaterApplicationEvents, @inject(DefaultRepeaterServerOptions) private readonly options: DefaultRepeaterServerOptions - ) {} + ) { + this.applicationEvents.onError = this.handleEventError; + } + + public on( + event: K, + handler: RepeaterServerEventHandler + ): void { + this.applicationEvents.on(event, handler); + } + + public off( + event: K, + handler: RepeaterServerEventHandler + ): void { + this.applicationEvents.off(event, handler); + } public disconnect() { - this.events.removeAllListeners(); + this.applicationEvents.removeAllListeners(); this.clearPingTimer(); this.clearConnectionTimer(); @@ -135,7 +150,7 @@ export class DefaultRepeaterServer implements RepeaterServer { return result; } - public async connect(hostname: string) { + public async connect(repeaterId: string) { this._socket = io(this.options.uri, { parser, path: '/api/ws/v1', @@ -147,7 +162,7 @@ export class DefaultRepeaterServer implements RepeaterServer { reconnectionAttempts: this.MAX_RECONNECTION_ATTEMPTS, auth: { token: this.options.token, - domain: hostname + domain: repeaterId } }); @@ -159,39 +174,18 @@ export class DefaultRepeaterServer implements RepeaterServer { this.logger.debug('Repeater connected to %s', this.options.uri); } - public off( - event: K, - handler: RepeaterServerEventHandler - ): void { - const wrappedHandler = this.handlerMap.get(handler); - if (wrappedHandler) { - this.events.off(event, wrappedHandler); - this.handlerMap.delete(handler); - } - } - - public on( - event: K, - handler: RepeaterServerEventHandler - ): void { - const wrappedHandler = (...args: unknown[]) => - this.wrapEventListener(event, handler, ...args); - this.handlerMap.set(handler, wrappedHandler); - this.events.on(event, wrappedHandler); - } - private listenToApplicationEvents() { this.socket.on(SocketEvents.DEPLOYED, event => - this.events.emit(RepeaterServerEvents.DEPLOY, event) + this.applicationEvents.emit(RepeaterServerEvents.DEPLOY, event) ); this.socket.on(SocketEvents.REQUEST, (event, callback) => - this.events.emit(RepeaterServerEvents.REQUEST, event, callback) + this.applicationEvents.emit(RepeaterServerEvents.REQUEST, event, callback) ); this.socket.on(SocketEvents.ERROR, event => { - this.events.emit(RepeaterServerEvents.ERROR, event); + this.applicationEvents.emit(RepeaterServerEvents.ERROR, event); }); this.socket.on(SocketEvents.UPDATE_AVAILABLE, event => - this.events.emit(RepeaterServerEvents.UPDATE_AVAILABLE, event) + this.applicationEvents.emit(RepeaterServerEvents.UPDATE_AVAILABLE, event) ); } @@ -207,18 +201,18 @@ export class DefaultRepeaterServer implements RepeaterServer { error => (this.latestReconnectionError = error) ); this.socket.io.on('reconnect_failed', () => - this.events.emit(RepeaterServerEvents.RECONNECTION_FAILED, { + this.applicationEvents.emit(RepeaterServerEvents.RECONNECTION_FAILED, { error: this.latestReconnectionError } as RepeaterServerReconnectionFailedEvent) ); this.socket.io.on('reconnect_attempt', attempt => - this.events.emit(RepeaterServerEvents.RECONNECT_ATTEMPT, { + this.applicationEvents.emit(RepeaterServerEvents.RECONNECT_ATTEMPT, { attempt, maxAttempts: this.MAX_RECONNECTION_ATTEMPTS } as RepeaterServerReconnectionAttemptedEvent) ); this.socket.io.on('reconnect', () => - this.events.emit(RepeaterServerEvents.RECONNECTION_SUCCEEDED) + this.applicationEvents.emit(RepeaterServerEvents.RECONNECTION_SUCCEEDED) ); } @@ -235,7 +229,7 @@ export class DefaultRepeaterServer implements RepeaterServer { } if (this.suppressConnectionError(data)) { - this.events.emit(RepeaterServerEvents.ERROR, { + this.applicationEvents.emit(RepeaterServerEvents.ERROR, { ...data, message: err.message }); @@ -244,7 +238,7 @@ export class DefaultRepeaterServer implements RepeaterServer { } if (this.connectionAttempts >= this.MAX_RECONNECTION_ATTEMPTS) { - this.events.emit(RepeaterServerEvents.RECONNECTION_FAILED, { + this.applicationEvents.emit(RepeaterServerEvents.RECONNECTION_FAILED, { error: err } as RepeaterServerReconnectionFailedEvent); @@ -274,7 +268,7 @@ export class DefaultRepeaterServer implements RepeaterServer { this.connectionAttempts++; - this.events.emit(RepeaterServerEvents.RECONNECT_ATTEMPT, { + this.applicationEvents.emit(RepeaterServerEvents.RECONNECT_ATTEMPT, { attempt: this.connectionAttempts, maxAttempts: this.MAX_RECONNECTION_ATTEMPTS } as RepeaterServerReconnectionAttemptedEvent); @@ -298,35 +292,6 @@ export class DefaultRepeaterServer implements RepeaterServer { } } - private async wrapEventListener( - event: string, - handler: (...payload: TArgs) => unknown, - ...args: unknown[] - ) { - try { - const callback = this.extractLastArgument(args); - - // eslint-disable-next-line @typescript-eslint/return-await - const response = await handler(...(args as TArgs)); - - callback?.(response); - } catch (err) { - this.handleEventError(err, event, args); - } - } - - private extractLastArgument(args: unknown[]): CallbackFunction | undefined { - const lastArg = args.pop(); - if (typeof lastArg === 'function') { - return lastArg as CallbackFunction; - } else { - // If the last argument is not a function, add it back to the args array - args.push(lastArg); - - return undefined; - } - } - private clearConnectionTimer() { if (this.connectionTimer) { clearTimeout(this.connectionTimer); @@ -336,14 +301,14 @@ export class DefaultRepeaterServer implements RepeaterServer { private handleConnect = () => { this.connectionAttempts = 0; this.clearConnectionTimer(); - this.events.emit(RepeaterServerEvents.CONNECTED); + this.applicationEvents.emit(RepeaterServerEvents.CONNECTED); }; private handleDisconnect = (reason: string): void => { this.clearPingTimer(); if (reason !== 'io client disconnect') { - this.events.emit(RepeaterServerEvents.DISCONNECTED); + this.applicationEvents.emit(RepeaterServerEvents.DISCONNECTED); } // the disconnection was initiated by the server, you need to reconnect manually @@ -352,22 +317,25 @@ export class DefaultRepeaterServer implements RepeaterServer { } }; - private handleEventError(error: Error, event: string, args: unknown[]): void { + private handleEventError = ( + error: Error, + event: string, + args: unknown[] + ): void => { this.logger.debug( 'An error occurred while processing the %s event with the following payload: %j', event, args ); this.logger.error(error); - } + }; private createPingTimer() { this.clearPingTimer(); - this.pingTimer = setInterval( - () => this.socket.volatile.emit(SocketEvents.PING), - this.MAX_PING_INTERVAL - ).unref(); + this.pingTimer = setInterval(() => { + this.socket.volatile.emit(SocketEvents.PING); + }, this.MAX_PING_INTERVAL).unref(); } private clearPingTimer() { diff --git a/packages/repeater/src/bus/RepeaterApplicationEvents.spec.ts b/packages/repeater/src/bus/RepeaterApplicationEvents.spec.ts new file mode 100644 index 00000000..c7c98362 --- /dev/null +++ b/packages/repeater/src/bus/RepeaterApplicationEvents.spec.ts @@ -0,0 +1,96 @@ +import { + ErrorHandlerFunction, + RepeaterApplicationEvents +} from './RepeaterApplicationEvents'; +import { + RepeaterServerEventHandler, + RepeaterServerEvents, + RepeaterServerEventsMap +} from './RepeaterServer'; +import { delay } from '@sectester/core'; + +describe('RepeaterApplicationEvents', () => { + let sut: RepeaterApplicationEvents; + + beforeEach(() => { + sut = new RepeaterApplicationEvents(); + }); + + describe('emit', () => { + it('should invoke handler after subscription', () => { + // arrange + const handler: RepeaterServerEventHandler = jest.fn(); + const event = 'testEvent'; + + sut.on(event as keyof RepeaterServerEventsMap, handler); + + // act + sut.emit(event as RepeaterServerEvents, 'arg1', 'arg2'); + + // assert + expect(handler).toHaveBeenCalledWith('arg1', 'arg2'); + }); + + it('should not invoke handler after removal', () => { + // arrange + const handler: RepeaterServerEventHandler = jest.fn(); + const event = 'testEvent'; + + sut.on(event as keyof RepeaterServerEventsMap, handler); + sut.off(event as keyof RepeaterServerEventsMap, handler); + + // act + sut.emit(event as RepeaterServerEvents, 'arg1', 'arg2'); + + // Capture the arguments passed to the handler + + expect(handler).not.toHaveBeenCalled(); + }); + + it('should call the callback with the handler result', async () => { + // arrange + const handler: RepeaterServerEventHandler = jest + .fn() + .mockResolvedValue('result'); + const callback = jest.fn(); + + const event = 'testEvent'; + + sut.on(event as keyof RepeaterServerEventsMap, handler); + + // act + sut.emit(event as RepeaterServerEvents, 'arg1', 'arg2', callback); + + // assert + await delay(200); + expect(callback).toHaveBeenCalledWith('result'); + }); + + it('should handle errors in event handlers', async () => { + // arrange + const error = new Error('test error'); + + const errorHandlerMock: ErrorHandlerFunction = jest.fn(); + sut.onError = errorHandlerMock; + + const handler: RepeaterServerEventHandler = jest + .fn() + .mockRejectedValue(error); + const event = 'testEvent'; + + sut.on(event as keyof RepeaterServerEventsMap, handler); + + // act + sut.emit(event as RepeaterServerEvents, 'arg1', 'arg2'); + + // assert + await delay(200); + + expect(errorHandlerMock).toHaveBeenCalledWith( + new Error('test error'), + event, + ['arg1', 'arg2'] + ); + }); + }); +}); diff --git a/packages/repeater/src/bus/RepeaterApplicationEvents.ts b/packages/repeater/src/bus/RepeaterApplicationEvents.ts new file mode 100644 index 00000000..7674f2cd --- /dev/null +++ b/packages/repeater/src/bus/RepeaterApplicationEvents.ts @@ -0,0 +1,87 @@ +import 'reflect-metadata'; +import { + RepeaterServerEventHandler, + RepeaterServerEvents, + RepeaterServerEventsMap +} from './RepeaterServer'; +import { injectable, Lifecycle, scoped } from 'tsyringe'; +import { EventEmitter } from 'events'; + +export type CallbackFunction = (arg: T) => unknown; +export type HandlerFunction = (args: unknown[]) => unknown; +export type ErrorHandlerFunction = ( + error: Error, + event: string, + args: unknown[] +) => unknown; + +@scoped(Lifecycle.ContainerScoped) +@injectable() +export class RepeaterApplicationEvents { + public onError: ErrorHandlerFunction | undefined; + + protected readonly events = new EventEmitter(); + + private readonly handlerMap = new WeakMap< + RepeaterServerEventHandler, + HandlerFunction + >(); + + public emit(event: RepeaterServerEvents, ...rest: unknown[]) { + this.events.emit(event, ...rest); + } + + public removeAllListeners() { + this.events.removeAllListeners(); + } + + public off( + event: K, + handler: RepeaterServerEventHandler + ): void { + const wrappedHandler = this.handlerMap.get(handler); + if (wrappedHandler) { + this.events.off(event, wrappedHandler); + this.handlerMap.delete(handler); + } + } + + public on( + event: K, + handler: RepeaterServerEventHandler + ): void { + const wrappedHandler = (...args: unknown[]) => + this.wrapEventListener(event, handler, ...args); + this.handlerMap.set(handler, wrappedHandler); + this.events.on(event, wrappedHandler); + } + + private async wrapEventListener( + event: string, + handler: (...payload: TArgs) => unknown, + ...args: unknown[] + ) { + try { + const callback = this.extractLastArgument(args); + + // eslint-disable-next-line @typescript-eslint/return-await + const response = await handler(...(args as TArgs)); + + callback?.(response); + } catch (err) { + this.onError?.(err, event, args); + } + } + + private extractLastArgument(args: unknown[]): CallbackFunction | undefined { + const lastArg = args.pop(); + if (typeof lastArg === 'function') { + return lastArg as CallbackFunction; + } else { + // If the last argument is not a function, add it back to the args array + args.push(lastArg); + + return undefined; + } + } +} diff --git a/packages/repeater/src/bus/index.ts b/packages/repeater/src/bus/index.ts index 7ba307a6..0547d204 100644 --- a/packages/repeater/src/bus/index.ts +++ b/packages/repeater/src/bus/index.ts @@ -5,3 +5,4 @@ export * from './RepeaterBus'; export * from './RepeaterBusFactory'; export * from './RepeaterCommandHub'; export * from './RepeaterServer'; +export * from './RepeaterApplicationEvents'; From 7aec236acbea91e2b4380293abe35e2b8420c000 Mon Sep 17 00:00:00 2001 From: Dmitry Ostrikov Date: Wed, 22 May 2024 10:02:22 +0400 Subject: [PATCH 04/17] feat(repeater): remove accidentally committed test closes #196 --- package-lock.json | 25 ---- package.json | 1 - .../src/bus/DefaultRepeaterBus.spec.ts | 73 ++++++++-- .../repeater/src/bus/DefaultRepeaterBus.ts | 56 +++++--- ...pec.ts => DefaultRepeaterEventHub.spec.ts} | 40 ++++-- ...onEvents.ts => DefaultRepeaterEventHub.ts} | 17 +-- .../src/bus/DefaultRepeaterServer.spec.ts | 18 +-- .../repeater/src/bus/DefaultRepeaterServer.ts | 83 ++++------- packages/repeater/src/bus/RepeaterEventHub.ts | 129 ++++++++++++++++++ packages/repeater/src/bus/RepeaterServer.ts | 129 +----------------- packages/repeater/src/bus/index.ts | 3 +- .../repeater/src/lib/RepeaterFactory.spec.ts | 15 ++ packages/repeater/src/register.ts | 3 + 13 files changed, 329 insertions(+), 263 deletions(-) rename packages/repeater/src/bus/{RepeaterApplicationEvents.spec.ts => DefaultRepeaterEventHub.spec.ts} (72%) rename packages/repeater/src/bus/{RepeaterApplicationEvents.ts => DefaultRepeaterEventHub.ts} (86%) create mode 100644 packages/repeater/src/bus/RepeaterEventHub.ts diff --git a/package-lock.json b/package-lock.json index f28ff55c..b72a25bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,6 @@ "@har-sdk/core": "^1.4.3", "amqp-connection-manager": "^4.1.13", "amqplib": "^0.10.3", - "arch": "^3.0.0", "axios": "^0.26.1", "axios-rate-limit": "^1.3.0", "chalk": "^4.1.2", @@ -3299,25 +3298,6 @@ "node": ">= 8" } }, - "node_modules/arch": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/arch/-/arch-3.0.0.tgz", - "integrity": "sha512-AmIAC+Wtm2AU8lGfTtHsw0Y9Qtftx2YXEEtiBP10xFUtMOA+sHHx6OAddyL52mUKh1vsXQ6/w1mVDptZCyUt4Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -17808,11 +17788,6 @@ "picomatch": "^2.0.4" } }, - "arch": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/arch/-/arch-3.0.0.tgz", - "integrity": "sha512-AmIAC+Wtm2AU8lGfTtHsw0Y9Qtftx2YXEEtiBP10xFUtMOA+sHHx6OAddyL52mUKh1vsXQ6/w1mVDptZCyUt4Q==" - }, "arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", diff --git a/package.json b/package.json index 63e5ca5d..418acc6a 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,6 @@ "@har-sdk/core": "^1.4.3", "amqp-connection-manager": "^4.1.13", "amqplib": "^0.10.3", - "arch": "^3.0.0", "axios": "^0.26.1", "axios-rate-limit": "^1.3.0", "chalk": "^4.1.2", diff --git a/packages/repeater/src/bus/DefaultRepeaterBus.spec.ts b/packages/repeater/src/bus/DefaultRepeaterBus.spec.ts index 203dc2f4..67643592 100644 --- a/packages/repeater/src/bus/DefaultRepeaterBus.spec.ts +++ b/packages/repeater/src/bus/DefaultRepeaterBus.spec.ts @@ -1,17 +1,21 @@ import { DefaultRepeaterBus } from './DefaultRepeaterBus'; import { Protocol } from '../models/Protocol'; import { Request, Response } from '../request-runner'; -import { RepeaterApplicationEvents } from './RepeaterApplicationEvents'; +import { RepeaterServer } from './RepeaterServer'; +import { DefaultRepeaterEventHub } from './DefaultRepeaterEventHub'; import { RepeaterErrorCodes, - RepeaterServer, + RepeaterEventHub, + RepeaterServerEventHandler, RepeaterServerEvents, + RepeaterServerEventsMap, RepeaterServerRequestEvent -} from './RepeaterServer'; +} from './RepeaterEventHub'; import { RepeaterCommandHub } from './RepeaterCommandHub'; import { delay, Logger } from '@sectester/core'; import { anything, + deepEqual, instance, mock, objectContaining, @@ -23,7 +27,7 @@ import { describe('DefaultRepeaterBus', () => { const RepeaterId = 'fooId'; - let events!: RepeaterApplicationEvents; + let events!: RepeaterEventHub; let sut!: DefaultRepeaterBus; const mockedRepeaterServer = mock(); @@ -31,14 +35,9 @@ describe('DefaultRepeaterBus', () => { const mockedLogger = mock(); beforeEach(() => { - events = new RepeaterApplicationEvents(); + events = new DefaultRepeaterEventHub(); - when(mockedRepeaterServer.on(anything(), anything())).thenCall( - (event, handler) => events.on(event, handler) - ); - when(mockedRepeaterServer.off(anything(), anything())).thenCall( - (event, handler) => events.off(event, handler) - ); + when(mockedRepeaterServer.events).thenReturn(events); sut = new DefaultRepeaterBus( RepeaterId, @@ -55,6 +54,34 @@ describe('DefaultRepeaterBus', () => { mockedLogger ) ); + describe('constructor', () => { + it('should provide error handler', async () => { + // arrange + const error = new Error('test error'); + const args = ['arg1', 'arg2']; + + const handler: RepeaterServerEventHandler = jest + .fn() + .mockRejectedValue(error); + const event = 'testEvent'; + + events.on(event as keyof RepeaterServerEventsMap, handler); + + // act + events.emit(event as RepeaterServerEvents, ...args); + + // assert + await delay(200); + verify( + mockedLogger.debug( + 'An error occurred while processing the %s event with the following payload: %j', + event, + deepEqual(['arg1', 'arg2']) + ) + ).once(); + verify(mockedLogger.error(error)).once(); + }); + }); describe('connect', () => { it('should connect', async () => { @@ -70,6 +97,17 @@ describe('DefaultRepeaterBus', () => { ).once(); }); + it('should allow connect more than once', async () => { + // arrange + await sut.connect(); + + // act + const act = sut.connect(); + + // assert + await expect(act).resolves.not.toThrow(); + }); + it('should throw when underlying connect throws', async () => { // arrange when(mockedRepeaterServer.connect(RepeaterId)).thenReject( @@ -225,5 +263,18 @@ describe('DefaultRepeaterBus', () => { verify(mockedLogger.error(error)).once(); verify(mockedRepeaterServer.disconnect()).once(); }); + + it(`should subscribe to ${RepeaterServerEvents.RECONNECTION_SUCCEEDED}`, async () => { + // arrange + await sut.connect(); + + // act + events.emit(RepeaterServerEvents.RECONNECTION_SUCCEEDED); + + // assert + verify( + mockedLogger.log('The Repeater (%s) connected', RepeaterId) + ).once(); + }); }); }); diff --git a/packages/repeater/src/bus/DefaultRepeaterBus.ts b/packages/repeater/src/bus/DefaultRepeaterBus.ts index a3bf6318..ff8f0054 100644 --- a/packages/repeater/src/bus/DefaultRepeaterBus.ts +++ b/packages/repeater/src/bus/DefaultRepeaterBus.ts @@ -1,13 +1,13 @@ import { RepeaterBus } from './RepeaterBus'; +import { RepeaterServer } from './RepeaterServer'; import { RepeaterCommandHub } from './RepeaterCommandHub'; import { RepeaterErrorCodes, - RepeaterServer, RepeaterServerErrorEvent, RepeaterServerEvents, RepeaterServerReconnectionFailedEvent, RepeaterServerRequestEvent -} from './RepeaterServer'; +} from './RepeaterEventHub'; import { Request } from '../request-runner/Request'; import { Logger } from '@sectester/core'; import chalk from 'chalk'; @@ -20,7 +20,9 @@ export class DefaultRepeaterBus implements RepeaterBus { private readonly logger: Logger, private readonly repeaterServer: RepeaterServer, private readonly commandHub: RepeaterCommandHub - ) {} + ) { + this.repeaterServer.events.errorHandler = this.handleEventError; + } public close() { this.repeaterRunning = false; @@ -52,7 +54,10 @@ export class DefaultRepeaterBus implements RepeaterBus { private async deploy() { await this.deployRepeater(); - this.repeaterServer.on(RepeaterServerEvents.CONNECTED, this.deployRepeater); + this.repeaterServer.events.on( + RepeaterServerEvents.CONNECTED, + this.deployRepeater + ); } private deployRepeater = async () => { @@ -62,20 +67,25 @@ export class DefaultRepeaterBus implements RepeaterBus { }; private subscribeToEvents() { - this.repeaterServer.on(RepeaterServerEvents.ERROR, this.handleError); - this.repeaterServer.on( + this.repeaterServer.events.on(RepeaterServerEvents.ERROR, this.handleError); + this.repeaterServer.events.on( RepeaterServerEvents.RECONNECTION_FAILED, this.reconnectionFailed ); - this.repeaterServer.on(RepeaterServerEvents.REQUEST, this.requestReceived); - this.repeaterServer.on(RepeaterServerEvents.UPDATE_AVAILABLE, payload => - this.logger.warn( - '%s: A new Repeater version (%s) is available, for update instruction visit https://docs.brightsec.com/docs/installation-options', - chalk.yellow('(!) IMPORTANT'), - payload.version - ) + this.repeaterServer.events.on( + RepeaterServerEvents.REQUEST, + this.requestReceived + ); + this.repeaterServer.events.on( + RepeaterServerEvents.UPDATE_AVAILABLE, + payload => + this.logger.warn( + '%s: A new Repeater version (%s) is available, for update instruction visit https://docs.brightsec.com/docs/installation-options', + chalk.yellow('(!) IMPORTANT'), + payload.version + ) ); - this.repeaterServer.on( + this.repeaterServer.events.on( RepeaterServerEvents.RECONNECT_ATTEMPT, ({ attempt, maxAttempts }) => this.logger.warn( @@ -84,8 +94,9 @@ export class DefaultRepeaterBus implements RepeaterBus { maxAttempts ) ); - this.repeaterServer.on(RepeaterServerEvents.RECONNECTION_SUCCEEDED, () => - this.logger.log('The Repeater (%s) connected', this.repeaterId) + this.repeaterServer.events.on( + RepeaterServerEvents.RECONNECTION_SUCCEEDED, + () => this.logger.log('The Repeater (%s) connected', this.repeaterId) ); } @@ -136,6 +147,19 @@ export class DefaultRepeaterBus implements RepeaterBus { this.close().catch(this.logger.error); }; + private handleEventError = ( + error: Error, + event: string, + args: unknown[] + ): void => { + this.logger.debug( + 'An error occurred while processing the %s event with the following payload: %j', + event, + args + ); + this.logger.error(error); + }; + private requestReceived = async (event: RepeaterServerRequestEvent) => { const response = await this.commandHub.sendRequest( new Request({ ...event }) diff --git a/packages/repeater/src/bus/RepeaterApplicationEvents.spec.ts b/packages/repeater/src/bus/DefaultRepeaterEventHub.spec.ts similarity index 72% rename from packages/repeater/src/bus/RepeaterApplicationEvents.spec.ts rename to packages/repeater/src/bus/DefaultRepeaterEventHub.spec.ts index c7c98362..90667c2e 100644 --- a/packages/repeater/src/bus/RepeaterApplicationEvents.spec.ts +++ b/packages/repeater/src/bus/DefaultRepeaterEventHub.spec.ts @@ -1,22 +1,20 @@ +import { DefaultRepeaterEventHub } from './DefaultRepeaterEventHub'; import { ErrorHandlerFunction, - RepeaterApplicationEvents -} from './RepeaterApplicationEvents'; -import { RepeaterServerEventHandler, RepeaterServerEvents, RepeaterServerEventsMap -} from './RepeaterServer'; +} from './RepeaterEventHub'; import { delay } from '@sectester/core'; -describe('RepeaterApplicationEvents', () => { - let sut: RepeaterApplicationEvents; +describe('DefaultRepeaterEventHub', () => { + let sut: DefaultRepeaterEventHub; beforeEach(() => { - sut = new RepeaterApplicationEvents(); + sut = new DefaultRepeaterEventHub(); }); - describe('emit', () => { + describe('on', () => { it('should invoke handler after subscription', () => { // arrange const handler: RepeaterServerEventHandler = jest.fn(); @@ -30,7 +28,9 @@ describe('RepeaterApplicationEvents', () => { // assert expect(handler).toHaveBeenCalledWith('arg1', 'arg2'); }); + }); + describe('off', () => { it('should not invoke handler after removal', () => { // arrange const handler: RepeaterServerEventHandler = jest.fn(); @@ -46,7 +46,27 @@ describe('RepeaterApplicationEvents', () => { expect(handler).not.toHaveBeenCalled(); }); + }); + describe('removeAllListeners', () => { + it('should not invoke handler after removing all listeners', () => { + // arrange + const handler: RepeaterServerEventHandler = jest.fn(); + const event = 'testEvent'; + + sut.on(event as keyof RepeaterServerEventsMap, handler); + sut.removeAllListeners(); + + // act + sut.emit(event as RepeaterServerEvents, 'arg1', 'arg2'); + + // Capture the arguments passed to the handler + + expect(handler).not.toHaveBeenCalled(); + }); + }); + + describe('emit', () => { it('should call the callback with the handler result', async () => { // arrange const handler: RepeaterServerEventHandler = jest @@ -65,13 +85,15 @@ describe('RepeaterApplicationEvents', () => { await delay(200); expect(callback).toHaveBeenCalledWith('result'); }); + }); + describe('errorHandler', () => { it('should handle errors in event handlers', async () => { // arrange const error = new Error('test error'); const errorHandlerMock: ErrorHandlerFunction = jest.fn(); - sut.onError = errorHandlerMock; + sut.errorHandler = errorHandlerMock; const handler: RepeaterServerEventHandler = jest .fn() diff --git a/packages/repeater/src/bus/RepeaterApplicationEvents.ts b/packages/repeater/src/bus/DefaultRepeaterEventHub.ts similarity index 86% rename from packages/repeater/src/bus/RepeaterApplicationEvents.ts rename to packages/repeater/src/bus/DefaultRepeaterEventHub.ts index 7674f2cd..a2d305a9 100644 --- a/packages/repeater/src/bus/RepeaterApplicationEvents.ts +++ b/packages/repeater/src/bus/DefaultRepeaterEventHub.ts @@ -1,24 +1,21 @@ import 'reflect-metadata'; import { + CallbackFunction, + ErrorHandlerFunction, + RepeaterEventHub, RepeaterServerEventHandler, RepeaterServerEvents, RepeaterServerEventsMap -} from './RepeaterServer'; +} from './RepeaterEventHub'; import { injectable, Lifecycle, scoped } from 'tsyringe'; import { EventEmitter } from 'events'; -export type CallbackFunction = (arg: T) => unknown; export type HandlerFunction = (args: unknown[]) => unknown; -export type ErrorHandlerFunction = ( - error: Error, - event: string, - args: unknown[] -) => unknown; @scoped(Lifecycle.ContainerScoped) @injectable() -export class RepeaterApplicationEvents { - public onError: ErrorHandlerFunction | undefined; +export class DefaultRepeaterEventHub implements RepeaterEventHub { + public errorHandler?: ErrorHandlerFunction; protected readonly events = new EventEmitter(); @@ -69,7 +66,7 @@ export class RepeaterApplicationEvents { callback?.(response); } catch (err) { - this.onError?.(err, event, args); + this.errorHandler?.(err, event, args); } } diff --git a/packages/repeater/src/bus/DefaultRepeaterServer.spec.ts b/packages/repeater/src/bus/DefaultRepeaterServer.spec.ts index 6825c777..69aaa178 100644 --- a/packages/repeater/src/bus/DefaultRepeaterServer.spec.ts +++ b/packages/repeater/src/bus/DefaultRepeaterServer.spec.ts @@ -7,9 +7,9 @@ import { RepeaterErrorCodes, RepeaterServerEventHandler, RepeaterServerEvents -} from './RepeaterServer'; +} from './RepeaterEventHub'; import { Protocol } from '../models/Protocol'; -import { RepeaterApplicationEvents } from './RepeaterApplicationEvents'; +import { DefaultRepeaterEventHub } from './DefaultRepeaterEventHub'; import { RepeaterCommandHub } from './RepeaterCommandHub'; import { delay, Logger } from '@sectester/core'; import { anything, instance, mock, reset, verify, when } from 'ts-mockito'; @@ -57,7 +57,7 @@ class MockSocketServer { describe('DefaultRepeaterServer', () => { const RepeaterId = 'fooId'; - let events!: RepeaterApplicationEvents; + let events!: DefaultRepeaterEventHub; let sut!: DefaultRepeaterServer; let mockServer!: MockSocketServer; @@ -68,7 +68,7 @@ describe('DefaultRepeaterServer', () => { beforeEach(() => { mockServer = new MockSocketServer(); - events = new RepeaterApplicationEvents(); + events = new DefaultRepeaterEventHub(); sut = new DefaultRepeaterServer( instance(mockedLogger), @@ -117,7 +117,7 @@ describe('DefaultRepeaterServer', () => { const handler: RepeaterServerEventHandler = jest.fn(); - sut.on(RepeaterServerEvents.DEPLOY, handler); + sut.events.on(RepeaterServerEvents.DEPLOY, handler); await sut.connect(RepeaterId); @@ -134,7 +134,7 @@ describe('DefaultRepeaterServer', () => { // arrange const handler: RepeaterServerEventHandler = jest.fn(); - sut.on(RepeaterServerEvents.DEPLOY, handler); + sut.events.on(RepeaterServerEvents.DEPLOY, handler); await sut.connect(RepeaterId); @@ -184,7 +184,7 @@ describe('DefaultRepeaterServer', () => { // arrange const handler: RepeaterServerEventHandler = jest.fn(); - sut.on(expected.event, handler); + sut.events.on(expected.event, handler); await sut.connect(RepeaterId); @@ -205,11 +205,11 @@ describe('DefaultRepeaterServer', () => { const handler: RepeaterServerEventHandler = jest.fn(); - sut.on(RepeaterServerEvents.ERROR, handler); + sut.events.on(RepeaterServerEvents.ERROR, handler); await sut.connect(RepeaterId); - sut.off(RepeaterServerEvents.ERROR, handler); + sut.events.off(RepeaterServerEvents.ERROR, handler); // act mockServer.emit(SocketEvents.ERROR, event); diff --git a/packages/repeater/src/bus/DefaultRepeaterServer.ts b/packages/repeater/src/bus/DefaultRepeaterServer.ts index 2748312d..9a736f26 100644 --- a/packages/repeater/src/bus/DefaultRepeaterServer.ts +++ b/packages/repeater/src/bus/DefaultRepeaterServer.ts @@ -1,26 +1,21 @@ -// eslint-disable-next-line max-classes-per-file +import 'reflect-metadata'; +import { RepeaterServer } from './RepeaterServer'; import { + CallbackFunction, DeployCommandOptions, - DeploymentRuntime, RepeaterErrorCodes, - RepeaterServer, + RepeaterEventHub, RepeaterServerDeployedEvent, RepeaterServerErrorEvent, - RepeaterServerEventHandler, RepeaterServerEvents, - RepeaterServerEventsMap, RepeaterServerReconnectionAttemptedEvent, RepeaterServerReconnectionFailedEvent, RepeaterServerRequestEvent, RepeaterServerRequestResponse, RepeaterUpgradeAvailableEvent -} from './RepeaterServer'; -import { - CallbackFunction, - RepeaterApplicationEvents -} from './RepeaterApplicationEvents'; +} from './RepeaterEventHub'; import { Logger } from '@sectester/core'; -import { inject, injectable } from 'tsyringe'; +import { inject, injectable, Lifecycle, scoped } from 'tsyringe'; import io, { Socket } from 'socket.io-client'; import parser from 'socket.io-msgpack-parser'; import { ErrorEvent } from 'ws'; @@ -64,14 +59,12 @@ interface SocketListeningEventMap { } interface SocketEmitEventMap { - [SocketEvents.DEPLOY]: ( - options: DeployCommandOptions, - runtime?: DeploymentRuntime - ) => void; + [SocketEvents.DEPLOY]: (options: DeployCommandOptions) => void; [SocketEvents.UNDEPLOY]: () => void; [SocketEvents.PING]: () => void; } +@scoped(Lifecycle.ContainerScoped) @injectable() export class DefaultRepeaterServer implements RepeaterServer { private readonly MAX_DEPLOYMENT_TIMEOUT = 60_000; @@ -98,29 +91,14 @@ export class DefaultRepeaterServer implements RepeaterServer { constructor( private readonly logger: Logger, - private readonly applicationEvents: RepeaterApplicationEvents, + @inject(RepeaterEventHub) + public readonly events: RepeaterEventHub, @inject(DefaultRepeaterServerOptions) private readonly options: DefaultRepeaterServerOptions - ) { - this.applicationEvents.onError = this.handleEventError; - } - - public on( - event: K, - handler: RepeaterServerEventHandler - ): void { - this.applicationEvents.on(event, handler); - } - - public off( - event: K, - handler: RepeaterServerEventHandler - ): void { - this.applicationEvents.off(event, handler); - } + ) {} public disconnect() { - this.applicationEvents.removeAllListeners(); + this.events.removeAllListeners(); this.clearPingTimer(); this.clearConnectionTimer(); @@ -176,16 +154,16 @@ export class DefaultRepeaterServer implements RepeaterServer { private listenToApplicationEvents() { this.socket.on(SocketEvents.DEPLOYED, event => - this.applicationEvents.emit(RepeaterServerEvents.DEPLOY, event) + this.events.emit(RepeaterServerEvents.DEPLOY, event) ); this.socket.on(SocketEvents.REQUEST, (event, callback) => - this.applicationEvents.emit(RepeaterServerEvents.REQUEST, event, callback) + this.events.emit(RepeaterServerEvents.REQUEST, event, callback) ); this.socket.on(SocketEvents.ERROR, event => { - this.applicationEvents.emit(RepeaterServerEvents.ERROR, event); + this.events.emit(RepeaterServerEvents.ERROR, event); }); this.socket.on(SocketEvents.UPDATE_AVAILABLE, event => - this.applicationEvents.emit(RepeaterServerEvents.UPDATE_AVAILABLE, event) + this.events.emit(RepeaterServerEvents.UPDATE_AVAILABLE, event) ); } @@ -201,18 +179,18 @@ export class DefaultRepeaterServer implements RepeaterServer { error => (this.latestReconnectionError = error) ); this.socket.io.on('reconnect_failed', () => - this.applicationEvents.emit(RepeaterServerEvents.RECONNECTION_FAILED, { + this.events.emit(RepeaterServerEvents.RECONNECTION_FAILED, { error: this.latestReconnectionError } as RepeaterServerReconnectionFailedEvent) ); this.socket.io.on('reconnect_attempt', attempt => - this.applicationEvents.emit(RepeaterServerEvents.RECONNECT_ATTEMPT, { + this.events.emit(RepeaterServerEvents.RECONNECT_ATTEMPT, { attempt, maxAttempts: this.MAX_RECONNECTION_ATTEMPTS } as RepeaterServerReconnectionAttemptedEvent) ); this.socket.io.on('reconnect', () => - this.applicationEvents.emit(RepeaterServerEvents.RECONNECTION_SUCCEEDED) + this.events.emit(RepeaterServerEvents.RECONNECTION_SUCCEEDED) ); } @@ -229,7 +207,7 @@ export class DefaultRepeaterServer implements RepeaterServer { } if (this.suppressConnectionError(data)) { - this.applicationEvents.emit(RepeaterServerEvents.ERROR, { + this.events.emit(RepeaterServerEvents.ERROR, { ...data, message: err.message }); @@ -238,7 +216,7 @@ export class DefaultRepeaterServer implements RepeaterServer { } if (this.connectionAttempts >= this.MAX_RECONNECTION_ATTEMPTS) { - this.applicationEvents.emit(RepeaterServerEvents.RECONNECTION_FAILED, { + this.events.emit(RepeaterServerEvents.RECONNECTION_FAILED, { error: err } as RepeaterServerReconnectionFailedEvent); @@ -268,7 +246,7 @@ export class DefaultRepeaterServer implements RepeaterServer { this.connectionAttempts++; - this.applicationEvents.emit(RepeaterServerEvents.RECONNECT_ATTEMPT, { + this.events.emit(RepeaterServerEvents.RECONNECT_ATTEMPT, { attempt: this.connectionAttempts, maxAttempts: this.MAX_RECONNECTION_ATTEMPTS } as RepeaterServerReconnectionAttemptedEvent); @@ -301,14 +279,14 @@ export class DefaultRepeaterServer implements RepeaterServer { private handleConnect = () => { this.connectionAttempts = 0; this.clearConnectionTimer(); - this.applicationEvents.emit(RepeaterServerEvents.CONNECTED); + this.events.emit(RepeaterServerEvents.CONNECTED); }; private handleDisconnect = (reason: string): void => { this.clearPingTimer(); if (reason !== 'io client disconnect') { - this.applicationEvents.emit(RepeaterServerEvents.DISCONNECTED); + this.events.emit(RepeaterServerEvents.DISCONNECTED); } // the disconnection was initiated by the server, you need to reconnect manually @@ -317,19 +295,6 @@ export class DefaultRepeaterServer implements RepeaterServer { } }; - private handleEventError = ( - error: Error, - event: string, - args: unknown[] - ): void => { - this.logger.debug( - 'An error occurred while processing the %s event with the following payload: %j', - event, - args - ); - this.logger.error(error); - }; - private createPingTimer() { this.clearPingTimer(); diff --git a/packages/repeater/src/bus/RepeaterEventHub.ts b/packages/repeater/src/bus/RepeaterEventHub.ts new file mode 100644 index 00000000..fb7553c6 --- /dev/null +++ b/packages/repeater/src/bus/RepeaterEventHub.ts @@ -0,0 +1,129 @@ +import { Protocol } from '../models/Protocol'; + +export interface RepeaterServerDeployedEvent { + repeaterId: string; +} + +export interface RepeaterServerRequestEvent { + protocol: Protocol; + url: string; + method?: string; + headers?: Record; + correlationIdRegex?: string; + body?: string; + encoding?: 'base64'; + maxContentSize?: number; + timeout?: number; +} + +export type RepeaterServerRequestResponse = + | { + protocol: Protocol; + statusCode?: number; + message?: string; + errorCode?: string; + headers?: Record; + body?: string; + } + | { + protocol: Protocol; + message?: string; + errorCode?: string; + }; + +export interface RepeaterServerReconnectionFailedEvent { + error: Error; +} + +export interface RepeaterServerReconnectionAttemptedEvent { + attempt: number; + maxAttempts: number; +} + +export enum RepeaterErrorCodes { + REPEATER_NOT_PERMITTED = 'repeater_not_permitted', + REPEATER_ALREADY_STARTED = 'repeater_already_started', + REPEATER_DEACTIVATED = 'repeater_deactivated', + REPEATER_UNAUTHORIZED = 'repeater_unauthorized', + REPEATER_NO_LONGER_SUPPORTED = 'repeater_no_longer_supported', + UNKNOWN_ERROR = 'unknown_error', + UNEXPECTED_ERROR = 'unexpected_error' +} + +export interface RepeaterServerErrorEvent { + message: string; + code: RepeaterErrorCodes; + transaction?: string; + remediation?: string; +} + +export interface RepeaterUpgradeAvailableEvent { + version: string; +} + +export interface DeployCommandOptions { + repeaterId?: string; +} + +export const enum RepeaterServerEvents { + DEPLOYED = 'deployed', + DEPLOY = 'deploy', + CONNECTED = 'connected', + DISCONNECTED = 'disconnected', + REQUEST = 'request', + UPDATE_AVAILABLE = 'update_available', + RECONNECTION_FAILED = 'reconnection_failed', + RECONNECT_ATTEMPT = 'reconnect_attempt', + RECONNECTION_SUCCEEDED = 'reconnection_succeeded', + ERROR = 'error', + PING = 'ping' +} + +export interface RepeaterServerEventsMap { + [RepeaterServerEvents.DEPLOY]: [DeployCommandOptions]; + [RepeaterServerEvents.DEPLOYED]: RepeaterServerDeployedEvent; + [RepeaterServerEvents.CONNECTED]: void; + [RepeaterServerEvents.DISCONNECTED]: void; + [RepeaterServerEvents.REQUEST]: RepeaterServerRequestEvent; + [RepeaterServerEvents.UPDATE_AVAILABLE]: RepeaterUpgradeAvailableEvent; + [RepeaterServerEvents.RECONNECTION_FAILED]: RepeaterServerReconnectionFailedEvent; + [RepeaterServerEvents.RECONNECT_ATTEMPT]: RepeaterServerReconnectionAttemptedEvent; + [RepeaterServerEvents.RECONNECTION_SUCCEEDED]: void; + [RepeaterServerEvents.ERROR]: RepeaterServerErrorEvent; + [RepeaterServerEvents.PING]: void; +} + +export type RepeaterServerEventHandler< + K extends keyof RepeaterServerEventsMap +> = ( + ...args: RepeaterServerEventsMap[K] extends (infer U)[] + ? U[] + : [RepeaterServerEventsMap[K]] +) => unknown; + +export type CallbackFunction = (arg: T) => unknown; +export type ErrorHandlerFunction = ( + error: Error, + event: string, + args: unknown[] +) => unknown; + +export interface RepeaterEventHub { + errorHandler?: ErrorHandlerFunction; + + on( + event: K, + handler: RepeaterServerEventHandler + ): void; + + off( + event: K, + handler?: RepeaterServerEventHandler + ): void; + + removeAllListeners(): void; + + emit(event: RepeaterServerEvents, ...rest: unknown[]): void; +} + +export const RepeaterEventHub: unique symbol = Symbol('RepeaterEventHub'); diff --git a/packages/repeater/src/bus/RepeaterServer.ts b/packages/repeater/src/bus/RepeaterServer.ts index 20e1553c..9036438a 100644 --- a/packages/repeater/src/bus/RepeaterServer.ts +++ b/packages/repeater/src/bus/RepeaterServer.ts @@ -1,132 +1,17 @@ -import { Protocol } from '../models/Protocol'; - -export interface RepeaterServerDeployedEvent { - repeaterId: string; -} - -export interface RepeaterServerRequestEvent { - protocol: Protocol; - url: string; - method?: string; - headers?: Record; - correlationIdRegex?: string; - body?: string; - encoding?: 'base64'; - maxContentSize?: number; - timeout?: number; -} - -export type RepeaterServerRequestResponse = - | { - protocol: Protocol; - statusCode?: number; - message?: string; - errorCode?: string; - headers?: Record; - body?: string; - } - | { - protocol: Protocol; - message?: string; - errorCode?: string; - }; - -export interface RepeaterServerReconnectionFailedEvent { - error: Error; -} - -export interface RepeaterServerReconnectionAttemptedEvent { - attempt: number; - maxAttempts: number; -} - -export enum RepeaterErrorCodes { - REPEATER_NOT_PERMITTED = 'repeater_not_permitted', - REPEATER_ALREADY_STARTED = 'repeater_already_started', - REPEATER_DEACTIVATED = 'repeater_deactivated', - REPEATER_UNAUTHORIZED = 'repeater_unauthorized', - REPEATER_NO_LONGER_SUPPORTED = 'repeater_no_longer_supported', - UNKNOWN_ERROR = 'unknown_error', - UNEXPECTED_ERROR = 'unexpected_error' -} - -export interface RepeaterServerErrorEvent { - message: string; - code: RepeaterErrorCodes; - transaction?: string; - remediation?: string; -} - -export interface RepeaterUpgradeAvailableEvent { - version: string; -} - -export interface DeployCommandOptions { - repeaterId?: string; -} - -export interface DeploymentRuntime { - version: string; - ci?: string; - os?: string; - arch?: string; - docker?: boolean; - distribution?: string; - nodeVersion?: string; -} - -export const enum RepeaterServerEvents { - DEPLOYED = 'deployed', - DEPLOY = 'deploy', - CONNECTED = 'connected', - DISCONNECTED = 'disconnected', - REQUEST = 'request', - UPDATE_AVAILABLE = 'update_available', - RECONNECTION_FAILED = 'reconnection_failed', - RECONNECT_ATTEMPT = 'reconnect_attempt', - RECONNECTION_SUCCEEDED = 'reconnection_succeeded', - ERROR = 'error', - PING = 'ping' -} - -export interface RepeaterServerEventsMap { - [RepeaterServerEvents.DEPLOY]: [DeployCommandOptions, DeploymentRuntime?]; - [RepeaterServerEvents.DEPLOYED]: RepeaterServerDeployedEvent; - [RepeaterServerEvents.CONNECTED]: void; - [RepeaterServerEvents.DISCONNECTED]: void; - [RepeaterServerEvents.REQUEST]: RepeaterServerRequestEvent; - [RepeaterServerEvents.UPDATE_AVAILABLE]: RepeaterUpgradeAvailableEvent; - [RepeaterServerEvents.RECONNECTION_FAILED]: RepeaterServerReconnectionFailedEvent; - [RepeaterServerEvents.RECONNECT_ATTEMPT]: RepeaterServerReconnectionAttemptedEvent; - [RepeaterServerEvents.RECONNECTION_SUCCEEDED]: void; - [RepeaterServerEvents.ERROR]: RepeaterServerErrorEvent; - [RepeaterServerEvents.PING]: void; -} - -export type RepeaterServerEventHandler< - K extends keyof RepeaterServerEventsMap -> = ( - ...args: RepeaterServerEventsMap[K] extends (infer U)[] - ? U[] - : [RepeaterServerEventsMap[K]] -) => unknown; +import { + DeployCommandOptions, + RepeaterEventHub, + RepeaterServerDeployedEvent +} from './RepeaterEventHub'; export interface RepeaterServer { + events: RepeaterEventHub; + disconnect(): void; connect(hostname: string): Promise; deploy(options: DeployCommandOptions): Promise; - - on( - event: K, - handler: RepeaterServerEventHandler - ): void; - - off( - event: K, - handler?: RepeaterServerEventHandler - ): void; } export const RepeaterServer: unique symbol = Symbol('RepeaterServer'); diff --git a/packages/repeater/src/bus/index.ts b/packages/repeater/src/bus/index.ts index 0547d204..c146f1a2 100644 --- a/packages/repeater/src/bus/index.ts +++ b/packages/repeater/src/bus/index.ts @@ -1,8 +1,9 @@ export * from './DefaultRepeaterBusFactory'; export * from './DefaultRepeaterCommandHub'; +export * from './DefaultRepeaterEventHub'; export * from './DefaultRepeaterServer'; export * from './RepeaterBus'; export * from './RepeaterBusFactory'; export * from './RepeaterCommandHub'; +export * from './RepeaterEventHub'; export * from './RepeaterServer'; -export * from './RepeaterApplicationEvents'; diff --git a/packages/repeater/src/lib/RepeaterFactory.spec.ts b/packages/repeater/src/lib/RepeaterFactory.spec.ts index 99c1403e..306b8bb6 100644 --- a/packages/repeater/src/lib/RepeaterFactory.spec.ts +++ b/packages/repeater/src/lib/RepeaterFactory.spec.ts @@ -275,5 +275,20 @@ describe('RepeaterFactory', () => { 'Name prefix must be less than or equal to 80 characters.' ); }); + + it('should throw an error when credentials was not provided', async () => { + // arrange + const factory = new RepeaterFactory(configuration); + + when(mockedConfiguration.credentials).thenReturn(undefined); + + // act + const res = factory.createRepeater(); + + // assert + await expect(res).rejects.toThrow( + 'Please provide credentials to establish a connection with the bus.' + ); + }); }); }); diff --git a/packages/repeater/src/register.ts b/packages/repeater/src/register.ts index bb0ec1b6..d6fe37e4 100644 --- a/packages/repeater/src/register.ts +++ b/packages/repeater/src/register.ts @@ -3,9 +3,11 @@ import { DefaultRepeatersManager, RepeatersManager } from './api'; import { DefaultRepeaterBusFactory, DefaultRepeaterCommandHub, + DefaultRepeaterEventHub, DefaultRepeaterServer, RepeaterBusFactory, RepeaterCommandHub, + RepeaterEventHub, RepeaterServer } from './bus'; import { @@ -95,5 +97,6 @@ container.register(EventBus, { container.register(RepeatersManager, { useClass: DefaultRepeatersManager }); container.register(RepeaterServer, { useClass: DefaultRepeaterServer }); +container.register(RepeaterEventHub, { useClass: DefaultRepeaterEventHub }); container.register(RepeaterCommandHub, { useClass: DefaultRepeaterCommandHub }); container.register(RepeaterBusFactory, { useClass: DefaultRepeaterBusFactory }); From 52afe0c226f10d1e8d2991a3f1a56328ea8ea30d Mon Sep 17 00:00:00 2001 From: Dmitry Ostrikov Date: Wed, 22 May 2024 10:57:02 +0400 Subject: [PATCH 05/17] feat(repeater): address code complexity issues closes #196 --- .../repeater/src/bus/DefaultRepeaterBus.ts | 37 ++++++++++++------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/packages/repeater/src/bus/DefaultRepeaterBus.ts b/packages/repeater/src/bus/DefaultRepeaterBus.ts index ff8f0054..8ffe744b 100644 --- a/packages/repeater/src/bus/DefaultRepeaterBus.ts +++ b/packages/repeater/src/bus/DefaultRepeaterBus.ts @@ -5,8 +5,10 @@ import { RepeaterErrorCodes, RepeaterServerErrorEvent, RepeaterServerEvents, + RepeaterServerReconnectionAttemptedEvent, RepeaterServerReconnectionFailedEvent, - RepeaterServerRequestEvent + RepeaterServerRequestEvent, + RepeaterUpgradeAvailableEvent } from './RepeaterEventHub'; import { Request } from '../request-runner/Request'; import { Logger } from '@sectester/core'; @@ -78,21 +80,11 @@ export class DefaultRepeaterBus implements RepeaterBus { ); this.repeaterServer.events.on( RepeaterServerEvents.UPDATE_AVAILABLE, - payload => - this.logger.warn( - '%s: A new Repeater version (%s) is available, for update instruction visit https://docs.brightsec.com/docs/installation-options', - chalk.yellow('(!) IMPORTANT'), - payload.version - ) + this.upgradeAvailable ); this.repeaterServer.events.on( RepeaterServerEvents.RECONNECT_ATTEMPT, - ({ attempt, maxAttempts }) => - this.logger.warn( - 'Failed to connect to Bright cloud (attempt %d/%d)', - attempt, - maxAttempts - ) + this.reconnectAttempt ); this.repeaterServer.events.on( RepeaterServerEvents.RECONNECTION_SUCCEEDED, @@ -140,6 +132,25 @@ export class DefaultRepeaterBus implements RepeaterBus { this.close().catch(this.logger.error); } + private upgradeAvailable = (event: RepeaterUpgradeAvailableEvent) => { + this.logger.warn( + '%s: A new Repeater version (%s) is available, for update instruction visit https://docs.brightsec.com/docs/installation-options', + chalk.yellow('(!) IMPORTANT'), + event.version + ); + }; + + private reconnectAttempt = ({ + attempt, + maxAttempts + }: RepeaterServerReconnectionAttemptedEvent) => { + this.logger.warn( + 'Failed to connect to Bright cloud (attempt %d/%d)', + attempt, + maxAttempts + ); + }; + private reconnectionFailed = ({ error }: RepeaterServerReconnectionFailedEvent) => { From 878e8ce0e4eef70f9cbf23cee1ff7573ded3d415 Mon Sep 17 00:00:00 2001 From: Dmitry Ostrikov Date: Mon, 10 Jun 2024 12:17:20 +0700 Subject: [PATCH 06/17] feat(repeater): address pr comments closes #196 --- packages/core/src/logger/Logger.ts | 8 +- .../src/api/DefaultRepeatersManager.spec.ts | 79 ----------- .../src/api/DefaultRepeatersManager.ts | 40 ------ packages/repeater/src/api/RepeatersManager.ts | 11 -- packages/repeater/src/api/index.ts | 2 - .../src/bus/DefaultRepeaterBus.spec.ts | 90 ++++-------- .../repeater/src/bus/DefaultRepeaterBus.ts | 72 ++++------ .../src/bus/DefaultRepeaterBusFactory.spec.ts | 10 +- .../src/bus/DefaultRepeaterBusFactory.ts | 14 +- ...pec.ts => DefaultRepeaterCommands.spec.ts} | 8 +- ...mmandHub.ts => DefaultRepeaterCommands.ts} | 4 +- .../src/bus/DefaultRepeaterEventHub.spec.ts | 118 ---------------- .../src/bus/DefaultRepeaterEventHub.ts | 84 ------------ .../src/bus/DefaultRepeaterServer.spec.ts | 35 ++--- .../repeater/src/bus/DefaultRepeaterServer.ts | 106 +++++++++++--- packages/repeater/src/bus/RepeaterBus.ts | 1 + .../repeater/src/bus/RepeaterBusFactory.ts | 2 +- .../repeater/src/bus/RepeaterCommandHub.ts | 7 - packages/repeater/src/bus/RepeaterCommands.ts | 7 + packages/repeater/src/bus/RepeaterEventHub.ts | 129 ------------------ packages/repeater/src/bus/RepeaterServer.ts | 124 +++++++++++++++-- packages/repeater/src/bus/index.ts | 6 +- packages/repeater/src/index.ts | 1 - packages/repeater/src/lib/Repeater.spec.ts | 7 +- packages/repeater/src/lib/Repeater.ts | 22 +-- .../repeater/src/lib/RepeaterFactory.spec.ts | 114 +--------------- packages/repeater/src/lib/RepeaterFactory.ts | 62 +-------- packages/repeater/src/lib/index.ts | 2 +- packages/repeater/src/register.ts | 18 ++- packages/runner/src/lib/SecRunner.spec.ts | 14 +- packages/runner/src/lib/SecRunner.ts | 15 +- 31 files changed, 325 insertions(+), 887 deletions(-) delete mode 100644 packages/repeater/src/api/DefaultRepeatersManager.spec.ts delete mode 100644 packages/repeater/src/api/DefaultRepeatersManager.ts delete mode 100644 packages/repeater/src/api/RepeatersManager.ts rename packages/repeater/src/bus/{DefaultRepeaterCommandHub.spec.ts => DefaultRepeaterCommands.spec.ts} (86%) rename packages/repeater/src/bus/{DefaultRepeaterCommandHub.ts => DefaultRepeaterCommands.ts} (81%) delete mode 100644 packages/repeater/src/bus/DefaultRepeaterEventHub.spec.ts delete mode 100644 packages/repeater/src/bus/DefaultRepeaterEventHub.ts delete mode 100644 packages/repeater/src/bus/RepeaterCommandHub.ts create mode 100644 packages/repeater/src/bus/RepeaterCommands.ts delete mode 100644 packages/repeater/src/bus/RepeaterEventHub.ts diff --git a/packages/core/src/logger/Logger.ts b/packages/core/src/logger/Logger.ts index af9349eb..55e9bb47 100644 --- a/packages/core/src/logger/Logger.ts +++ b/packages/core/src/logger/Logger.ts @@ -28,12 +28,8 @@ export class Logger { this._logLevel = logLevel; } - public error(errorOrMessage: string | Error, ...args: any[]): void { - if (typeof errorOrMessage === 'string') { - this.write(errorOrMessage, LogLevel.ERROR, ...args); - } else { - this.write(errorOrMessage.message, LogLevel.ERROR, ...args); - } + public error(errorOrMessage: string, ...args: any[]): void { + this.write(errorOrMessage, LogLevel.ERROR, ...args); } public warn(message: string, ...args: any[]): void { diff --git a/packages/repeater/src/api/DefaultRepeatersManager.spec.ts b/packages/repeater/src/api/DefaultRepeatersManager.spec.ts deleted file mode 100644 index 6b2dcd0e..00000000 --- a/packages/repeater/src/api/DefaultRepeatersManager.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -import 'reflect-metadata'; -import { CreateRepeaterRequest, DeleteRepeaterRequest } from './commands'; -import { DefaultRepeatersManager } from './DefaultRepeatersManager'; -import { RepeatersManager } from './RepeatersManager'; -import { CommandDispatcher } from '@sectester/core'; -import { - anyOfClass, - instance, - mock, - objectContaining, - reset, - verify, - when -} from 'ts-mockito'; - -describe('DefaultRepeatersManager', () => { - const mockedCommandDispatcher = mock(); - let manager!: RepeatersManager; - - beforeEach(() => { - manager = new DefaultRepeatersManager(instance(mockedCommandDispatcher)); - }); - - afterEach(() => reset(mockedCommandDispatcher)); - - describe('createRepeater', () => { - it('should create repeater', async () => { - when( - mockedCommandDispatcher.execute(anyOfClass(CreateRepeaterRequest)) - ).thenResolve({ id: '142' }); - - const result = await manager.createRepeater({ name: 'foo' }); - - verify( - mockedCommandDispatcher.execute(anyOfClass(CreateRepeaterRequest)) - ).once(); - expect(result).toMatchObject({ repeaterId: '142' }); - }); - - it('should create repeater under a specific project', async () => { - when( - mockedCommandDispatcher.execute( - objectContaining({ payload: { name: 'foo', projectIds: ['321'] } }) - ) - ).thenResolve({ id: '142' }); - - const result = await manager.createRepeater({ - name: 'foo', - projectId: '321' - }); - - expect(result).toMatchObject({ repeaterId: '142' }); - }); - - it('should throw an error if cannot find created repeater', async () => { - when( - mockedCommandDispatcher.execute(anyOfClass(CreateRepeaterRequest)) - ).thenResolve(); - - const res = manager.createRepeater({ name: 'foo' }); - - await expect(res).rejects.toThrow('Cannot create a new repeater'); - }); - }); - - describe('deleteRepeater', () => { - it('should remove repeater', async () => { - when( - mockedCommandDispatcher.execute(anyOfClass(DeleteRepeaterRequest)) - ).thenResolve(); - - await manager.deleteRepeater('fooId'); - - verify( - mockedCommandDispatcher.execute(anyOfClass(DeleteRepeaterRequest)) - ).once(); - }); - }); -}); diff --git a/packages/repeater/src/api/DefaultRepeatersManager.ts b/packages/repeater/src/api/DefaultRepeatersManager.ts deleted file mode 100644 index 630ec035..00000000 --- a/packages/repeater/src/api/DefaultRepeatersManager.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { RepeatersManager } from './RepeatersManager'; -import { CreateRepeaterRequest, DeleteRepeaterRequest } from './commands'; -import { inject, injectable } from 'tsyringe'; -import { CommandDispatcher } from '@sectester/core'; - -@injectable() -export class DefaultRepeatersManager implements RepeatersManager { - constructor( - @inject(CommandDispatcher) - private readonly commandDispatcher: CommandDispatcher - ) {} - - public async createRepeater({ - projectId, - ...options - }: { - name: string; - description?: string; - projectId?: string; - }): Promise<{ repeaterId: string }> { - const repeater = await this.commandDispatcher.execute( - new CreateRepeaterRequest({ - ...options, - ...(projectId ? { projectIds: [projectId] } : {}) - }) - ); - - if (!repeater?.id) { - throw new Error('Cannot create a new repeater.'); - } - - return { repeaterId: repeater.id }; - } - - public async deleteRepeater(repeaterId: string): Promise { - return this.commandDispatcher.execute( - new DeleteRepeaterRequest({ repeaterId }) - ); - } -} diff --git a/packages/repeater/src/api/RepeatersManager.ts b/packages/repeater/src/api/RepeatersManager.ts deleted file mode 100644 index c53bdd3e..00000000 --- a/packages/repeater/src/api/RepeatersManager.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface RepeatersManager { - createRepeater(options: { - name: string; - projectId?: string; - description?: string; - }): Promise<{ repeaterId: string }>; - - deleteRepeater(repeaterId: string): Promise; -} - -export const RepeatersManager: unique symbol = Symbol('RepeatersManager'); diff --git a/packages/repeater/src/api/index.ts b/packages/repeater/src/api/index.ts index fdd8cb76..218dacc3 100644 --- a/packages/repeater/src/api/index.ts +++ b/packages/repeater/src/api/index.ts @@ -5,6 +5,4 @@ export { RegisterRepeaterCommand } from './commands'; export { RepeaterStatusEvent } from './events'; -export * from './RepeatersManager'; -export * from './DefaultRepeatersManager'; export * from './ExecuteRequestEventHandler'; diff --git a/packages/repeater/src/bus/DefaultRepeaterBus.spec.ts b/packages/repeater/src/bus/DefaultRepeaterBus.spec.ts index 67643592..fb050744 100644 --- a/packages/repeater/src/bus/DefaultRepeaterBus.spec.ts +++ b/packages/repeater/src/bus/DefaultRepeaterBus.spec.ts @@ -1,21 +1,16 @@ import { DefaultRepeaterBus } from './DefaultRepeaterBus'; import { Protocol } from '../models/Protocol'; import { Request, Response } from '../request-runner'; -import { RepeaterServer } from './RepeaterServer'; -import { DefaultRepeaterEventHub } from './DefaultRepeaterEventHub'; import { RepeaterErrorCodes, - RepeaterEventHub, - RepeaterServerEventHandler, + RepeaterServer, RepeaterServerEvents, - RepeaterServerEventsMap, RepeaterServerRequestEvent -} from './RepeaterEventHub'; -import { RepeaterCommandHub } from './RepeaterCommandHub'; +} from './RepeaterServer'; +import { RepeaterCommands } from './RepeaterCommands'; import { delay, Logger } from '@sectester/core'; import { anything, - deepEqual, instance, mock, objectContaining, @@ -23,65 +18,48 @@ import { verify, when } from 'ts-mockito'; +import { EventEmitter } from 'events'; describe('DefaultRepeaterBus', () => { const RepeaterId = 'fooId'; - let events!: RepeaterEventHub; + let events!: EventEmitter; let sut!: DefaultRepeaterBus; const mockedRepeaterServer = mock(); - const mockedRepeaterCommandHub = mock(); + const repeaterCommands = mock(); const mockedLogger = mock(); beforeEach(() => { - events = new DefaultRepeaterEventHub(); + events = new EventEmitter(); + when(mockedRepeaterServer.on(anything(), anything())).thenCall( + (event, handler) => { + events.on(event, handler); + } + ); + + when(mockedRepeaterServer.off(anything(), anything())).thenCall( + (event, listener) => { + events.off(event, listener); + } + ); - when(mockedRepeaterServer.events).thenReturn(events); + when(mockedRepeaterServer.deploy()).thenResolve({ repeaterId: RepeaterId }); sut = new DefaultRepeaterBus( - RepeaterId, instance(mockedLogger), instance(mockedRepeaterServer), - instance(mockedRepeaterCommandHub) + instance(repeaterCommands) ); }); afterEach(() => - reset( + reset( mockedRepeaterServer, - mockedRepeaterCommandHub, + repeaterCommands, mockedLogger ) ); - describe('constructor', () => { - it('should provide error handler', async () => { - // arrange - const error = new Error('test error'); - const args = ['arg1', 'arg2']; - - const handler: RepeaterServerEventHandler = jest - .fn() - .mockRejectedValue(error); - const event = 'testEvent'; - - events.on(event as keyof RepeaterServerEventsMap, handler); - - // act - events.emit(event as RepeaterServerEvents, ...args); - - // assert - await delay(200); - verify( - mockedLogger.debug( - 'An error occurred while processing the %s event with the following payload: %j', - event, - deepEqual(['arg1', 'arg2']) - ) - ).once(); - verify(mockedLogger.error(error)).once(); - }); - }); describe('connect', () => { it('should connect', async () => { @@ -89,12 +67,8 @@ describe('DefaultRepeaterBus', () => { await sut.connect(); // assert - verify(mockedRepeaterServer.connect(RepeaterId)).once(); - verify( - mockedRepeaterServer.deploy( - objectContaining({ repeaterId: RepeaterId }) - ) - ).once(); + verify(mockedRepeaterServer.connect()).once(); + verify(mockedRepeaterServer.deploy()).once(); }); it('should allow connect more than once', async () => { @@ -110,9 +84,7 @@ describe('DefaultRepeaterBus', () => { it('should throw when underlying connect throws', async () => { // arrange - when(mockedRepeaterServer.connect(RepeaterId)).thenReject( - new Error('foo') - ); + when(mockedRepeaterServer.connect()).thenReject(new Error('foo')); // act const act = () => sut.connect(); @@ -123,9 +95,7 @@ describe('DefaultRepeaterBus', () => { it('should throw when underlying deploy throws', async () => { // arrange - when(mockedRepeaterServer.deploy(anything())).thenReject( - new Error('foo') - ); + when(mockedRepeaterServer.deploy()).thenReject(new Error('foo')); // act const act = () => sut.connect(); @@ -173,7 +143,7 @@ describe('DefaultRepeaterBus', () => { const request = new Request(requestEvent); - when(mockedRepeaterCommandHub.sendRequest(anything())).thenResolve( + when(repeaterCommands.sendRequest(anything())).thenResolve( new Response({ protocol: Protocol.HTTP, statusCode: 200 @@ -187,9 +157,7 @@ describe('DefaultRepeaterBus', () => { // assert await delay(200); - verify( - mockedRepeaterCommandHub.sendRequest(objectContaining(request)) - ).once(); + verify(repeaterCommands.sendRequest(objectContaining(request))).once(); }); it(`should subscribe to ${RepeaterServerEvents.RECONNECT_ATTEMPT}`, async () => { @@ -260,7 +228,7 @@ describe('DefaultRepeaterBus', () => { }); // assert - verify(mockedLogger.error(error)).once(); + verify(mockedLogger.error(error.message)).once(); verify(mockedRepeaterServer.disconnect()).once(); }); diff --git a/packages/repeater/src/bus/DefaultRepeaterBus.ts b/packages/repeater/src/bus/DefaultRepeaterBus.ts index 8ffe744b..9de54e90 100644 --- a/packages/repeater/src/bus/DefaultRepeaterBus.ts +++ b/packages/repeater/src/bus/DefaultRepeaterBus.ts @@ -1,7 +1,6 @@ import { RepeaterBus } from './RepeaterBus'; -import { RepeaterServer } from './RepeaterServer'; -import { RepeaterCommandHub } from './RepeaterCommandHub'; import { + RepeaterServer, RepeaterErrorCodes, RepeaterServerErrorEvent, RepeaterServerEvents, @@ -9,22 +8,25 @@ import { RepeaterServerReconnectionFailedEvent, RepeaterServerRequestEvent, RepeaterUpgradeAvailableEvent -} from './RepeaterEventHub'; +} from './RepeaterServer'; +import { RepeaterCommands } from './RepeaterCommands'; import { Request } from '../request-runner/Request'; import { Logger } from '@sectester/core'; import chalk from 'chalk'; export class DefaultRepeaterBus implements RepeaterBus { private repeaterRunning: boolean = false; + private _repeaterId?: string; + + get repeaterId(): string | undefined { + return this._repeaterId; + } constructor( - private readonly repeaterId: string, private readonly logger: Logger, private readonly repeaterServer: RepeaterServer, - private readonly commandHub: RepeaterCommandHub - ) { - this.repeaterServer.events.errorHandler = this.handleEventError; - } + private readonly commandHub: RepeaterCommands + ) {} public close() { this.repeaterRunning = false; @@ -41,13 +43,13 @@ export class DefaultRepeaterBus implements RepeaterBus { this.repeaterRunning = true; - this.logger.log('Connecting the Repeater (%s)...', this.repeaterId); + this.logger.log('Connecting the Bridges'); this.subscribeToEvents(); - await this.repeaterServer.connect(this.repeaterId); + await this.repeaterServer.connect(); - this.logger.log('Deploying the Repeater (%s)...', this.repeaterId); + this.logger.log('Deploying the repeater'); await this.deploy(); @@ -55,40 +57,29 @@ export class DefaultRepeaterBus implements RepeaterBus { } private async deploy() { - await this.deployRepeater(); - this.repeaterServer.events.on( - RepeaterServerEvents.CONNECTED, - this.deployRepeater - ); - } + const response = await this.repeaterServer.deploy(); - private deployRepeater = async () => { - await this.repeaterServer.deploy({ - repeaterId: this.repeaterId - }); - }; + this._repeaterId = response.repeaterId; + } private subscribeToEvents() { - this.repeaterServer.events.on(RepeaterServerEvents.ERROR, this.handleError); - this.repeaterServer.events.on( + this.repeaterServer.on(RepeaterServerEvents.ERROR, this.handleError); + + this.repeaterServer.on( RepeaterServerEvents.RECONNECTION_FAILED, this.reconnectionFailed ); - this.repeaterServer.events.on( - RepeaterServerEvents.REQUEST, - this.requestReceived - ); - this.repeaterServer.events.on( + this.repeaterServer.on(RepeaterServerEvents.REQUEST, this.requestReceived); + this.repeaterServer.on( RepeaterServerEvents.UPDATE_AVAILABLE, this.upgradeAvailable ); - this.repeaterServer.events.on( + this.repeaterServer.on( RepeaterServerEvents.RECONNECT_ATTEMPT, this.reconnectAttempt ); - this.repeaterServer.events.on( - RepeaterServerEvents.RECONNECTION_SUCCEEDED, - () => this.logger.log('The Repeater (%s) connected', this.repeaterId) + this.repeaterServer.on(RepeaterServerEvents.RECONNECTION_SUCCEEDED, () => + this.logger.log('The Repeater (%s) connected', this.repeaterId) ); } @@ -154,23 +145,10 @@ export class DefaultRepeaterBus implements RepeaterBus { private reconnectionFailed = ({ error }: RepeaterServerReconnectionFailedEvent) => { - this.logger.error(error); + this.logger.error(error.message); this.close().catch(this.logger.error); }; - private handleEventError = ( - error: Error, - event: string, - args: unknown[] - ): void => { - this.logger.debug( - 'An error occurred while processing the %s event with the following payload: %j', - event, - args - ); - this.logger.error(error); - }; - private requestReceived = async (event: RepeaterServerRequestEvent) => { const response = await this.commandHub.sendRequest( new Request({ ...event }) diff --git a/packages/repeater/src/bus/DefaultRepeaterBusFactory.spec.ts b/packages/repeater/src/bus/DefaultRepeaterBusFactory.spec.ts index b7bce6aa..321af4df 100644 --- a/packages/repeater/src/bus/DefaultRepeaterBusFactory.spec.ts +++ b/packages/repeater/src/bus/DefaultRepeaterBusFactory.spec.ts @@ -1,17 +1,15 @@ import { DefaultRepeaterBusFactory } from './DefaultRepeaterBusFactory'; import { RepeaterServer } from './RepeaterServer'; -import { RepeaterCommandHub } from './RepeaterCommandHub'; +import { RepeaterCommands } from './RepeaterCommands'; import { DefaultRepeaterBus } from './DefaultRepeaterBus'; import { Configuration, Logger } from '@sectester/core'; import { instance, mock, reset } from 'ts-mockito'; describe('DefaultRepeaterBusFactory', () => { - const repeaterId = 'fooId'; - const mockedLogger = mock(); const mockedConfiguration = mock(); const mockedRepeaterServer = mock(); - const mockedRepeaterCommandHub = mock(); + const mockedRepeaterCommandHub = mock(); const configuration = instance(mockedConfiguration); @@ -27,7 +25,7 @@ describe('DefaultRepeaterBusFactory', () => { }); afterEach(() => { - reset( + reset( mockedLogger, mockedConfiguration, mockedRepeaterServer, @@ -38,7 +36,7 @@ describe('DefaultRepeaterBusFactory', () => { describe('create', () => { it('should create', () => { // act - const res = sut.create(repeaterId); + const res = sut.create(); // assert expect(res).toBeInstanceOf(DefaultRepeaterBus); diff --git a/packages/repeater/src/bus/DefaultRepeaterBusFactory.ts b/packages/repeater/src/bus/DefaultRepeaterBusFactory.ts index 7c9f3537..a467fec8 100644 --- a/packages/repeater/src/bus/DefaultRepeaterBusFactory.ts +++ b/packages/repeater/src/bus/DefaultRepeaterBusFactory.ts @@ -1,7 +1,7 @@ import { RepeaterBus } from './RepeaterBus'; import { DefaultRepeaterBus } from './DefaultRepeaterBus'; import { RepeaterBusFactory } from './RepeaterBusFactory'; -import { RepeaterCommandHub } from './RepeaterCommandHub'; +import { RepeaterCommands } from './RepeaterCommands'; import { RepeaterServer } from './RepeaterServer'; import { Configuration, Logger } from '@sectester/core'; import { inject, injectable } from 'tsyringe'; @@ -12,22 +12,20 @@ export class DefaultRepeaterBusFactory implements RepeaterBusFactory { private readonly logger: Logger, private readonly configuration: Configuration, @inject(RepeaterServer) private readonly repeaterServer: RepeaterServer, - @inject(RepeaterCommandHub) - private readonly commandHub: RepeaterCommandHub + @inject(RepeaterCommands) + private readonly repeaterCommands: RepeaterCommands ) {} - public create(repeaterId: string): RepeaterBus { + public create(): RepeaterBus { this.logger.log( - 'Creating the repeater (%s, %s)...', - repeaterId, + 'Creating the repeater (%s)...', this.configuration.version ); return new DefaultRepeaterBus( - repeaterId, this.logger, this.repeaterServer, - this.commandHub + this.repeaterCommands ); } } diff --git a/packages/repeater/src/bus/DefaultRepeaterCommandHub.spec.ts b/packages/repeater/src/bus/DefaultRepeaterCommands.spec.ts similarity index 86% rename from packages/repeater/src/bus/DefaultRepeaterCommandHub.spec.ts rename to packages/repeater/src/bus/DefaultRepeaterCommands.spec.ts index 9ddfda88..e575cc4b 100644 --- a/packages/repeater/src/bus/DefaultRepeaterCommandHub.spec.ts +++ b/packages/repeater/src/bus/DefaultRepeaterCommands.spec.ts @@ -1,15 +1,15 @@ -import { DefaultRepeaterCommandHub } from './DefaultRepeaterCommandHub'; +import { DefaultRepeaterCommands } from './DefaultRepeaterCommands'; import { Protocol } from '../models/Protocol'; import { RequestRunner, Request, Response } from '../request-runner'; import { instance, mock, reset, when } from 'ts-mockito'; -describe('DefaultRepeaterCommandHub', () => { - let sut!: DefaultRepeaterCommandHub; +describe('DefaultRepeaterCommands', () => { + let sut!: DefaultRepeaterCommands; const mockedRequestRunner = mock(); beforeEach(() => { - sut = new DefaultRepeaterCommandHub([instance(mockedRequestRunner)]); + sut = new DefaultRepeaterCommands([instance(mockedRequestRunner)]); }); afterEach(() => reset(mockedRequestRunner)); diff --git a/packages/repeater/src/bus/DefaultRepeaterCommandHub.ts b/packages/repeater/src/bus/DefaultRepeaterCommands.ts similarity index 81% rename from packages/repeater/src/bus/DefaultRepeaterCommandHub.ts rename to packages/repeater/src/bus/DefaultRepeaterCommands.ts index 46c7e8a4..d899058d 100644 --- a/packages/repeater/src/bus/DefaultRepeaterCommandHub.ts +++ b/packages/repeater/src/bus/DefaultRepeaterCommands.ts @@ -1,9 +1,9 @@ -import { RepeaterCommandHub } from './RepeaterCommandHub'; +import { RepeaterCommands } from './RepeaterCommands'; import { Request, Response, RequestRunner } from '../request-runner'; import { injectable, injectAll } from 'tsyringe'; @injectable() -export class DefaultRepeaterCommandHub implements RepeaterCommandHub { +export class DefaultRepeaterCommands implements RepeaterCommands { constructor( @injectAll(RequestRunner) private readonly requestRunners: RequestRunner[] diff --git a/packages/repeater/src/bus/DefaultRepeaterEventHub.spec.ts b/packages/repeater/src/bus/DefaultRepeaterEventHub.spec.ts deleted file mode 100644 index 90667c2e..00000000 --- a/packages/repeater/src/bus/DefaultRepeaterEventHub.spec.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { DefaultRepeaterEventHub } from './DefaultRepeaterEventHub'; -import { - ErrorHandlerFunction, - RepeaterServerEventHandler, - RepeaterServerEvents, - RepeaterServerEventsMap -} from './RepeaterEventHub'; -import { delay } from '@sectester/core'; - -describe('DefaultRepeaterEventHub', () => { - let sut: DefaultRepeaterEventHub; - - beforeEach(() => { - sut = new DefaultRepeaterEventHub(); - }); - - describe('on', () => { - it('should invoke handler after subscription', () => { - // arrange - const handler: RepeaterServerEventHandler = jest.fn(); - const event = 'testEvent'; - - sut.on(event as keyof RepeaterServerEventsMap, handler); - - // act - sut.emit(event as RepeaterServerEvents, 'arg1', 'arg2'); - - // assert - expect(handler).toHaveBeenCalledWith('arg1', 'arg2'); - }); - }); - - describe('off', () => { - it('should not invoke handler after removal', () => { - // arrange - const handler: RepeaterServerEventHandler = jest.fn(); - const event = 'testEvent'; - - sut.on(event as keyof RepeaterServerEventsMap, handler); - sut.off(event as keyof RepeaterServerEventsMap, handler); - - // act - sut.emit(event as RepeaterServerEvents, 'arg1', 'arg2'); - - // Capture the arguments passed to the handler - - expect(handler).not.toHaveBeenCalled(); - }); - }); - - describe('removeAllListeners', () => { - it('should not invoke handler after removing all listeners', () => { - // arrange - const handler: RepeaterServerEventHandler = jest.fn(); - const event = 'testEvent'; - - sut.on(event as keyof RepeaterServerEventsMap, handler); - sut.removeAllListeners(); - - // act - sut.emit(event as RepeaterServerEvents, 'arg1', 'arg2'); - - // Capture the arguments passed to the handler - - expect(handler).not.toHaveBeenCalled(); - }); - }); - - describe('emit', () => { - it('should call the callback with the handler result', async () => { - // arrange - const handler: RepeaterServerEventHandler = jest - .fn() - .mockResolvedValue('result'); - const callback = jest.fn(); - - const event = 'testEvent'; - - sut.on(event as keyof RepeaterServerEventsMap, handler); - - // act - sut.emit(event as RepeaterServerEvents, 'arg1', 'arg2', callback); - - // assert - await delay(200); - expect(callback).toHaveBeenCalledWith('result'); - }); - }); - - describe('errorHandler', () => { - it('should handle errors in event handlers', async () => { - // arrange - const error = new Error('test error'); - - const errorHandlerMock: ErrorHandlerFunction = jest.fn(); - sut.errorHandler = errorHandlerMock; - - const handler: RepeaterServerEventHandler = jest - .fn() - .mockRejectedValue(error); - const event = 'testEvent'; - - sut.on(event as keyof RepeaterServerEventsMap, handler); - - // act - sut.emit(event as RepeaterServerEvents, 'arg1', 'arg2'); - - // assert - await delay(200); - - expect(errorHandlerMock).toHaveBeenCalledWith( - new Error('test error'), - event, - ['arg1', 'arg2'] - ); - }); - }); -}); diff --git a/packages/repeater/src/bus/DefaultRepeaterEventHub.ts b/packages/repeater/src/bus/DefaultRepeaterEventHub.ts deleted file mode 100644 index a2d305a9..00000000 --- a/packages/repeater/src/bus/DefaultRepeaterEventHub.ts +++ /dev/null @@ -1,84 +0,0 @@ -import 'reflect-metadata'; -import { - CallbackFunction, - ErrorHandlerFunction, - RepeaterEventHub, - RepeaterServerEventHandler, - RepeaterServerEvents, - RepeaterServerEventsMap -} from './RepeaterEventHub'; -import { injectable, Lifecycle, scoped } from 'tsyringe'; -import { EventEmitter } from 'events'; - -export type HandlerFunction = (args: unknown[]) => unknown; - -@scoped(Lifecycle.ContainerScoped) -@injectable() -export class DefaultRepeaterEventHub implements RepeaterEventHub { - public errorHandler?: ErrorHandlerFunction; - - protected readonly events = new EventEmitter(); - - private readonly handlerMap = new WeakMap< - RepeaterServerEventHandler, - HandlerFunction - >(); - - public emit(event: RepeaterServerEvents, ...rest: unknown[]) { - this.events.emit(event, ...rest); - } - - public removeAllListeners() { - this.events.removeAllListeners(); - } - - public off( - event: K, - handler: RepeaterServerEventHandler - ): void { - const wrappedHandler = this.handlerMap.get(handler); - if (wrappedHandler) { - this.events.off(event, wrappedHandler); - this.handlerMap.delete(handler); - } - } - - public on( - event: K, - handler: RepeaterServerEventHandler - ): void { - const wrappedHandler = (...args: unknown[]) => - this.wrapEventListener(event, handler, ...args); - this.handlerMap.set(handler, wrappedHandler); - this.events.on(event, wrappedHandler); - } - - private async wrapEventListener( - event: string, - handler: (...payload: TArgs) => unknown, - ...args: unknown[] - ) { - try { - const callback = this.extractLastArgument(args); - - // eslint-disable-next-line @typescript-eslint/return-await - const response = await handler(...(args as TArgs)); - - callback?.(response); - } catch (err) { - this.errorHandler?.(err, event, args); - } - } - - private extractLastArgument(args: unknown[]): CallbackFunction | undefined { - const lastArg = args.pop(); - if (typeof lastArg === 'function') { - return lastArg as CallbackFunction; - } else { - // If the last argument is not a function, add it back to the args array - args.push(lastArg); - - return undefined; - } - } -} diff --git a/packages/repeater/src/bus/DefaultRepeaterServer.spec.ts b/packages/repeater/src/bus/DefaultRepeaterServer.spec.ts index 69aaa178..069dafb6 100644 --- a/packages/repeater/src/bus/DefaultRepeaterServer.spec.ts +++ b/packages/repeater/src/bus/DefaultRepeaterServer.spec.ts @@ -7,10 +7,9 @@ import { RepeaterErrorCodes, RepeaterServerEventHandler, RepeaterServerEvents -} from './RepeaterEventHub'; +} from './RepeaterServer'; import { Protocol } from '../models/Protocol'; -import { DefaultRepeaterEventHub } from './DefaultRepeaterEventHub'; -import { RepeaterCommandHub } from './RepeaterCommandHub'; +import { RepeaterCommands } from './RepeaterCommands'; import { delay, Logger } from '@sectester/core'; import { anything, instance, mock, reset, verify, when } from 'ts-mockito'; import { Server } from 'socket.io'; @@ -57,26 +56,22 @@ class MockSocketServer { describe('DefaultRepeaterServer', () => { const RepeaterId = 'fooId'; - let events!: DefaultRepeaterEventHub; let sut!: DefaultRepeaterServer; - let mockServer!: MockSocketServer; + let mockSocketServer!: MockSocketServer; const mockedLogger = mock(); const mockedDefaultRepeaterServerOptions = mock(); beforeEach(() => { - mockServer = new MockSocketServer(); - - events = new DefaultRepeaterEventHub(); + mockSocketServer = new MockSocketServer(); sut = new DefaultRepeaterServer( instance(mockedLogger), - events, instance(mockedDefaultRepeaterServerOptions) ); - const address = mockServer.address; + const address = mockSocketServer.address; when(mockedDefaultRepeaterServerOptions.uri).thenReturn(address); when(mockedDefaultRepeaterServerOptions.token).thenReturn('token'); @@ -86,9 +81,9 @@ describe('DefaultRepeaterServer', () => { afterEach(() => { sut.disconnect(); - mockServer.close(); + mockSocketServer.close(); - reset( + reset( mockedLogger, mockedDefaultRepeaterServerOptions ); @@ -109,7 +104,7 @@ describe('DefaultRepeaterServer', () => { // arrange const event = { repeaterId: RepeaterId }; - mockServer.onConnection(socket => { + mockSocketServer.onConnection(socket => { socket.on('deploy', () => { socket.emit('deployed', event); }); @@ -117,7 +112,7 @@ describe('DefaultRepeaterServer', () => { const handler: RepeaterServerEventHandler = jest.fn(); - sut.events.on(RepeaterServerEvents.DEPLOY, handler); + sut.on(RepeaterServerEvents.DEPLOY, handler); await sut.connect(RepeaterId); @@ -134,7 +129,7 @@ describe('DefaultRepeaterServer', () => { // arrange const handler: RepeaterServerEventHandler = jest.fn(); - sut.events.on(RepeaterServerEvents.DEPLOY, handler); + sut.on(RepeaterServerEvents.DEPLOY, handler); await sut.connect(RepeaterId); @@ -184,12 +179,12 @@ describe('DefaultRepeaterServer', () => { // arrange const handler: RepeaterServerEventHandler = jest.fn(); - sut.events.on(expected.event, handler); + sut.on(expected.event, handler); await sut.connect(RepeaterId); // act - mockServer.emit(input.event, input.data); + mockSocketServer.emit(input.event, input.data); // assert await delay(200); @@ -205,14 +200,14 @@ describe('DefaultRepeaterServer', () => { const handler: RepeaterServerEventHandler = jest.fn(); - sut.events.on(RepeaterServerEvents.ERROR, handler); + sut.on(RepeaterServerEvents.ERROR, handler); await sut.connect(RepeaterId); - sut.events.off(RepeaterServerEvents.ERROR, handler); + sut.off(RepeaterServerEvents.ERROR, handler); // act - mockServer.emit(SocketEvents.ERROR, event); + mockSocketServer.emit(SocketEvents.ERROR, event); // assert expect(handler).not.toHaveBeenCalled(); diff --git a/packages/repeater/src/bus/DefaultRepeaterServer.ts b/packages/repeater/src/bus/DefaultRepeaterServer.ts index 9a736f26..ab514a47 100644 --- a/packages/repeater/src/bus/DefaultRepeaterServer.ts +++ b/packages/repeater/src/bus/DefaultRepeaterServer.ts @@ -1,10 +1,11 @@ import 'reflect-metadata'; -import { RepeaterServer } from './RepeaterServer'; import { + RepeaterServer, + RepeaterServerEventHandler, + RepeaterServerEventsMap, CallbackFunction, DeployCommandOptions, RepeaterErrorCodes, - RepeaterEventHub, RepeaterServerDeployedEvent, RepeaterServerErrorEvent, RepeaterServerEvents, @@ -12,14 +13,16 @@ import { RepeaterServerReconnectionFailedEvent, RepeaterServerRequestEvent, RepeaterServerRequestResponse, - RepeaterUpgradeAvailableEvent -} from './RepeaterEventHub'; + RepeaterUpgradeAvailableEvent, + HandlerFunction +} from './RepeaterServer'; import { Logger } from '@sectester/core'; import { inject, injectable, Lifecycle, scoped } from 'tsyringe'; import io, { Socket } from 'socket.io-client'; import parser from 'socket.io-msgpack-parser'; import { ErrorEvent } from 'ws'; -import { once } from 'events'; +import { EventEmitter, once } from 'events'; +import { hostname } from 'os'; import Timer = NodeJS.Timer; export interface DefaultRepeaterServerOptions { @@ -27,7 +30,6 @@ export interface DefaultRepeaterServerOptions { readonly token: string; readonly connectTimeout?: number; readonly proxyUrl?: string; - readonly insecure?: boolean; } export const DefaultRepeaterServerOptions: unique symbol = Symbol( @@ -78,6 +80,12 @@ export class DefaultRepeaterServer implements RepeaterServer { private connectionTimer?: Timer; private _socket?: Socket; private connectionAttempts = 0; + private events = new EventEmitter(); + + private readonly handlerMap = new WeakMap< + RepeaterServerEventHandler, + HandlerFunction + >(); private get socket() { if (!this._socket) { @@ -91,8 +99,6 @@ export class DefaultRepeaterServer implements RepeaterServer { constructor( private readonly logger: Logger, - @inject(RepeaterEventHub) - public readonly events: RepeaterEventHub, @inject(DefaultRepeaterServerOptions) private readonly options: DefaultRepeaterServerOptions ) {} @@ -108,7 +114,7 @@ export class DefaultRepeaterServer implements RepeaterServer { } public async deploy( - options: DeployCommandOptions + options: DeployCommandOptions = {} ): Promise { process.nextTick(() => this.socket.emit(SocketEvents.DEPLOY, options)); @@ -128,19 +134,15 @@ export class DefaultRepeaterServer implements RepeaterServer { return result; } - public async connect(repeaterId: string) { + public async connect(domain: string = hostname()) { this._socket = io(this.options.uri, { parser, path: '/api/ws/v1', transports: ['websocket'], - reconnectionDelayMax: this.MAX_RECONNECTION_DELAY, - reconnectionDelay: this.MIN_RECONNECTION_DELAY, - timeout: this.options?.connectTimeout, - rejectUnauthorized: !this.options.insecure, reconnectionAttempts: this.MAX_RECONNECTION_ATTEMPTS, auth: { - token: this.options.token, - domain: repeaterId + domain, + token: this.options.token } }); @@ -152,10 +154,60 @@ export class DefaultRepeaterServer implements RepeaterServer { this.logger.debug('Repeater connected to %s', this.options.uri); } + public off( + event: K, + handler: RepeaterServerEventHandler + ): void { + const wrappedHandler = this.handlerMap.get(handler); + if (wrappedHandler) { + this.events.off(event, wrappedHandler); + this.handlerMap.delete(handler); + } + } + + public on( + event: K, + handler: RepeaterServerEventHandler + ): void { + const wrappedHandler = (...args: unknown[]) => + this.wrapEventListener(event, handler, ...args); + this.handlerMap.set(handler, wrappedHandler); + this.events.on(event, wrappedHandler); + } + + private async wrapEventListener( + event: string, + handler: (...payload: TArgs) => unknown, + ...args: unknown[] + ) { + try { + const callback = this.extractLastArgument(args); + + // eslint-disable-next-line @typescript-eslint/return-await + const response = await handler(...(args as TArgs)); + + callback?.(response); + } catch (err) { + this.handleEventError(err, event, args); + } + } + + private extractLastArgument(args: unknown[]): CallbackFunction | undefined { + const lastArg = args.pop(); + if (typeof lastArg === 'function') { + return lastArg as CallbackFunction; + } else { + // If the last argument is not a function, add it back to the args array + args.push(lastArg); + + return undefined; + } + } + private listenToApplicationEvents() { - this.socket.on(SocketEvents.DEPLOYED, event => - this.events.emit(RepeaterServerEvents.DEPLOY, event) - ); + this.socket.on(SocketEvents.DEPLOYED, event => { + this.events.emit(RepeaterServerEvents.DEPLOY, event); + }); this.socket.on(SocketEvents.REQUEST, (event, callback) => this.events.emit(RepeaterServerEvents.REQUEST, event, callback) ); @@ -295,12 +347,22 @@ export class DefaultRepeaterServer implements RepeaterServer { } }; + private handleEventError(error: Error, event: string, args: unknown[]): void { + this.logger.debug( + 'An error occurred while processing the %s event with the following payload: %j', + event, + args + ); + this.logger.error('An error occurred', error); + } + private createPingTimer() { this.clearPingTimer(); - this.pingTimer = setInterval(() => { - this.socket.volatile.emit(SocketEvents.PING); - }, this.MAX_PING_INTERVAL).unref(); + this.pingTimer = setInterval( + () => this.socket.volatile.emit(SocketEvents.PING), + this.MAX_PING_INTERVAL + ).unref(); } private clearPingTimer() { diff --git a/packages/repeater/src/bus/RepeaterBus.ts b/packages/repeater/src/bus/RepeaterBus.ts index a3fd758f..9deda133 100644 --- a/packages/repeater/src/bus/RepeaterBus.ts +++ b/packages/repeater/src/bus/RepeaterBus.ts @@ -1,4 +1,5 @@ export interface RepeaterBus { + readonly repeaterId?: string; connect(): Promise; close(): Promise; } diff --git a/packages/repeater/src/bus/RepeaterBusFactory.ts b/packages/repeater/src/bus/RepeaterBusFactory.ts index cc65e800..a470caa6 100644 --- a/packages/repeater/src/bus/RepeaterBusFactory.ts +++ b/packages/repeater/src/bus/RepeaterBusFactory.ts @@ -1,7 +1,7 @@ import { RepeaterBus } from './RepeaterBus'; export interface RepeaterBusFactory { - create(repeaterId: string): RepeaterBus; + create(): RepeaterBus; } export const RepeaterBusFactory: unique symbol = Symbol('RepeaterBusFactory'); diff --git a/packages/repeater/src/bus/RepeaterCommandHub.ts b/packages/repeater/src/bus/RepeaterCommandHub.ts deleted file mode 100644 index 7391bcc6..00000000 --- a/packages/repeater/src/bus/RepeaterCommandHub.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Request, Response } from '../request-runner'; - -export interface RepeaterCommandHub { - sendRequest(request: Request): Promise; -} - -export const RepeaterCommandHub: unique symbol = Symbol('RepeaterCommandHub'); diff --git a/packages/repeater/src/bus/RepeaterCommands.ts b/packages/repeater/src/bus/RepeaterCommands.ts new file mode 100644 index 00000000..adf1e7ac --- /dev/null +++ b/packages/repeater/src/bus/RepeaterCommands.ts @@ -0,0 +1,7 @@ +import { Request, Response } from '../request-runner'; + +export interface RepeaterCommands { + sendRequest(request: Request): Promise; +} + +export const RepeaterCommands: unique symbol = Symbol('RepeaterCommands'); diff --git a/packages/repeater/src/bus/RepeaterEventHub.ts b/packages/repeater/src/bus/RepeaterEventHub.ts deleted file mode 100644 index fb7553c6..00000000 --- a/packages/repeater/src/bus/RepeaterEventHub.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { Protocol } from '../models/Protocol'; - -export interface RepeaterServerDeployedEvent { - repeaterId: string; -} - -export interface RepeaterServerRequestEvent { - protocol: Protocol; - url: string; - method?: string; - headers?: Record; - correlationIdRegex?: string; - body?: string; - encoding?: 'base64'; - maxContentSize?: number; - timeout?: number; -} - -export type RepeaterServerRequestResponse = - | { - protocol: Protocol; - statusCode?: number; - message?: string; - errorCode?: string; - headers?: Record; - body?: string; - } - | { - protocol: Protocol; - message?: string; - errorCode?: string; - }; - -export interface RepeaterServerReconnectionFailedEvent { - error: Error; -} - -export interface RepeaterServerReconnectionAttemptedEvent { - attempt: number; - maxAttempts: number; -} - -export enum RepeaterErrorCodes { - REPEATER_NOT_PERMITTED = 'repeater_not_permitted', - REPEATER_ALREADY_STARTED = 'repeater_already_started', - REPEATER_DEACTIVATED = 'repeater_deactivated', - REPEATER_UNAUTHORIZED = 'repeater_unauthorized', - REPEATER_NO_LONGER_SUPPORTED = 'repeater_no_longer_supported', - UNKNOWN_ERROR = 'unknown_error', - UNEXPECTED_ERROR = 'unexpected_error' -} - -export interface RepeaterServerErrorEvent { - message: string; - code: RepeaterErrorCodes; - transaction?: string; - remediation?: string; -} - -export interface RepeaterUpgradeAvailableEvent { - version: string; -} - -export interface DeployCommandOptions { - repeaterId?: string; -} - -export const enum RepeaterServerEvents { - DEPLOYED = 'deployed', - DEPLOY = 'deploy', - CONNECTED = 'connected', - DISCONNECTED = 'disconnected', - REQUEST = 'request', - UPDATE_AVAILABLE = 'update_available', - RECONNECTION_FAILED = 'reconnection_failed', - RECONNECT_ATTEMPT = 'reconnect_attempt', - RECONNECTION_SUCCEEDED = 'reconnection_succeeded', - ERROR = 'error', - PING = 'ping' -} - -export interface RepeaterServerEventsMap { - [RepeaterServerEvents.DEPLOY]: [DeployCommandOptions]; - [RepeaterServerEvents.DEPLOYED]: RepeaterServerDeployedEvent; - [RepeaterServerEvents.CONNECTED]: void; - [RepeaterServerEvents.DISCONNECTED]: void; - [RepeaterServerEvents.REQUEST]: RepeaterServerRequestEvent; - [RepeaterServerEvents.UPDATE_AVAILABLE]: RepeaterUpgradeAvailableEvent; - [RepeaterServerEvents.RECONNECTION_FAILED]: RepeaterServerReconnectionFailedEvent; - [RepeaterServerEvents.RECONNECT_ATTEMPT]: RepeaterServerReconnectionAttemptedEvent; - [RepeaterServerEvents.RECONNECTION_SUCCEEDED]: void; - [RepeaterServerEvents.ERROR]: RepeaterServerErrorEvent; - [RepeaterServerEvents.PING]: void; -} - -export type RepeaterServerEventHandler< - K extends keyof RepeaterServerEventsMap -> = ( - ...args: RepeaterServerEventsMap[K] extends (infer U)[] - ? U[] - : [RepeaterServerEventsMap[K]] -) => unknown; - -export type CallbackFunction = (arg: T) => unknown; -export type ErrorHandlerFunction = ( - error: Error, - event: string, - args: unknown[] -) => unknown; - -export interface RepeaterEventHub { - errorHandler?: ErrorHandlerFunction; - - on( - event: K, - handler: RepeaterServerEventHandler - ): void; - - off( - event: K, - handler?: RepeaterServerEventHandler - ): void; - - removeAllListeners(): void; - - emit(event: RepeaterServerEvents, ...rest: unknown[]): void; -} - -export const RepeaterEventHub: unique symbol = Symbol('RepeaterEventHub'); diff --git a/packages/repeater/src/bus/RepeaterServer.ts b/packages/repeater/src/bus/RepeaterServer.ts index 9036438a..947f4113 100644 --- a/packages/repeater/src/bus/RepeaterServer.ts +++ b/packages/repeater/src/bus/RepeaterServer.ts @@ -1,17 +1,125 @@ -import { - DeployCommandOptions, - RepeaterEventHub, - RepeaterServerDeployedEvent -} from './RepeaterEventHub'; +import { Protocol } from '../models/Protocol'; + +export interface RepeaterServerDeployedEvent { + repeaterId: string; +} + +export interface RepeaterServerRequestEvent { + protocol: Protocol; + url: string; + method?: string; + headers?: Record; + correlationIdRegex?: string; + body?: string; + encoding?: 'base64'; + maxContentSize?: number; + timeout?: number; +} + +export type RepeaterServerRequestResponse = + | { + protocol: Protocol; + statusCode?: number; + message?: string; + errorCode?: string; + headers?: Record; + body?: string; + } + | { + protocol: Protocol; + message?: string; + errorCode?: string; + }; + +export interface RepeaterServerReconnectionFailedEvent { + error: Error; +} + +export interface RepeaterServerReconnectionAttemptedEvent { + attempt: number; + maxAttempts: number; +} + +export enum RepeaterErrorCodes { + REPEATER_NOT_PERMITTED = 'repeater_not_permitted', + REPEATER_ALREADY_STARTED = 'repeater_already_started', + REPEATER_DEACTIVATED = 'repeater_deactivated', + REPEATER_UNAUTHORIZED = 'repeater_unauthorized', + REPEATER_NO_LONGER_SUPPORTED = 'repeater_no_longer_supported', + UNKNOWN_ERROR = 'unknown_error', + UNEXPECTED_ERROR = 'unexpected_error' +} + +export interface RepeaterServerErrorEvent { + message: string; + code: RepeaterErrorCodes; + transaction?: string; + remediation?: string; +} + +export interface RepeaterUpgradeAvailableEvent { + version: string; +} + +export interface DeployCommandOptions { + repeaterId?: string; +} + +export const enum RepeaterServerEvents { + DEPLOYED = 'deployed', + DEPLOY = 'deploy', + CONNECTED = 'connected', + DISCONNECTED = 'disconnected', + REQUEST = 'request', + UPDATE_AVAILABLE = 'update_available', + RECONNECTION_FAILED = 'reconnection_failed', + RECONNECT_ATTEMPT = 'reconnect_attempt', + RECONNECTION_SUCCEEDED = 'reconnection_succeeded', + ERROR = 'error', + PING = 'ping' +} + +export interface RepeaterServerEventsMap { + [RepeaterServerEvents.DEPLOY]: [DeployCommandOptions]; + [RepeaterServerEvents.DEPLOYED]: RepeaterServerDeployedEvent; + [RepeaterServerEvents.CONNECTED]: void; + [RepeaterServerEvents.DISCONNECTED]: void; + [RepeaterServerEvents.REQUEST]: RepeaterServerRequestEvent; + [RepeaterServerEvents.UPDATE_AVAILABLE]: RepeaterUpgradeAvailableEvent; + [RepeaterServerEvents.RECONNECTION_FAILED]: RepeaterServerReconnectionFailedEvent; + [RepeaterServerEvents.RECONNECT_ATTEMPT]: RepeaterServerReconnectionAttemptedEvent; + [RepeaterServerEvents.RECONNECTION_SUCCEEDED]: void; + [RepeaterServerEvents.ERROR]: RepeaterServerErrorEvent; + [RepeaterServerEvents.PING]: void; +} + +export type RepeaterServerEventHandler< + K extends keyof RepeaterServerEventsMap +> = ( + ...args: RepeaterServerEventsMap[K] extends (infer U)[] + ? U[] + : [RepeaterServerEventsMap[K]] +) => unknown; + +export type CallbackFunction = (arg: T) => unknown; +export type HandlerFunction = (args: unknown[]) => unknown; export interface RepeaterServer { - events: RepeaterEventHub; + connect(hostname?: string): Promise; disconnect(): void; - connect(hostname: string): Promise; + deploy(options?: DeployCommandOptions): Promise; + + on( + event: K, + handler: RepeaterServerEventHandler + ): void; - deploy(options: DeployCommandOptions): Promise; + off( + event: K, + handler?: RepeaterServerEventHandler + ): void; } export const RepeaterServer: unique symbol = Symbol('RepeaterServer'); diff --git a/packages/repeater/src/bus/index.ts b/packages/repeater/src/bus/index.ts index c146f1a2..79611d29 100644 --- a/packages/repeater/src/bus/index.ts +++ b/packages/repeater/src/bus/index.ts @@ -1,9 +1,7 @@ export * from './DefaultRepeaterBusFactory'; -export * from './DefaultRepeaterCommandHub'; -export * from './DefaultRepeaterEventHub'; +export * from './DefaultRepeaterCommands'; export * from './DefaultRepeaterServer'; export * from './RepeaterBus'; export * from './RepeaterBusFactory'; -export * from './RepeaterCommandHub'; -export * from './RepeaterEventHub'; +export * from './RepeaterCommands'; export * from './RepeaterServer'; diff --git a/packages/repeater/src/index.ts b/packages/repeater/src/index.ts index fbce4bbd..235af287 100644 --- a/packages/repeater/src/index.ts +++ b/packages/repeater/src/index.ts @@ -1,7 +1,6 @@ import './register'; export { - RepeatersManager, ExecuteRequestEventHandler, ExecuteRequestPayload, ExecuteRequestResult diff --git a/packages/repeater/src/lib/Repeater.spec.ts b/packages/repeater/src/lib/Repeater.spec.ts index 7a9d14ce..d6007cd0 100644 --- a/packages/repeater/src/lib/Repeater.spec.ts +++ b/packages/repeater/src/lib/Repeater.spec.ts @@ -8,13 +8,10 @@ describe('Repeater', () => { let repeater!: Repeater; const mockedRepeaterBus = mock(); - const createRepeater = () => - new Repeater({ - repeaterId, - bus: instance(mockedRepeaterBus) - }); + const createRepeater = () => new Repeater(instance(mockedRepeaterBus)); beforeEach(() => { + when(mockedRepeaterBus.repeaterId).thenReturn(repeaterId); repeater = createRepeater(); }); diff --git a/packages/repeater/src/lib/Repeater.ts b/packages/repeater/src/lib/Repeater.ts index cb9ce5ae..d7c42ca1 100644 --- a/packages/repeater/src/lib/Repeater.ts +++ b/packages/repeater/src/lib/Repeater.ts @@ -6,30 +6,18 @@ export enum RunningStatus { RUNNING } -export type RepeaterId = string; -export const RepeaterId = Symbol('RepeaterId'); - export class Repeater { - public readonly repeaterId: RepeaterId; - - private readonly bus: RepeaterBus; - private _runningStatus = RunningStatus.OFF; + get repeaterId(): string | undefined { + return this.bus.repeaterId; + } + get runningStatus(): RunningStatus { return this._runningStatus; } - constructor({ - repeaterId, - bus - }: { - repeaterId: RepeaterId; - bus: RepeaterBus; - }) { - this.repeaterId = repeaterId; - this.bus = bus; - } + constructor(private readonly bus: RepeaterBus) {} public async start(): Promise { if (this.runningStatus !== RunningStatus.OFF) { diff --git a/packages/repeater/src/lib/RepeaterFactory.spec.ts b/packages/repeater/src/lib/RepeaterFactory.spec.ts index 306b8bb6..9fee127e 100644 --- a/packages/repeater/src/lib/RepeaterFactory.spec.ts +++ b/packages/repeater/src/lib/RepeaterFactory.spec.ts @@ -7,11 +7,9 @@ import { RequestRunnerOptions } from '../request-runner'; import { Repeater } from './Repeater'; -import { RepeatersManager } from '../api'; import { Configuration } from '@sectester/core'; import { anything, - capture, deepEqual, instance, mock, @@ -23,7 +21,6 @@ import { import { DependencyContainer, Lifecycle } from 'tsyringe'; describe('RepeaterFactory', () => { - const repeaterId = 'fooId'; const defaultOptions = { timeout: 30000, maxContentLength: 100, @@ -50,7 +47,6 @@ describe('RepeaterFactory', () => { const mockedChildContainer = mock(); const mockedConfiguration = mock(); const mockedRepeaterBus = mock(); - const mockedRepeaterManager = mock(); const mockedRepeaterBusFactory = mock(); const configuration = instance(mockedConfiguration); @@ -64,37 +60,24 @@ describe('RepeaterFactory', () => { instance(mockedChildContainer) ); - when( - mockedContainer.resolve(RepeatersManager) - ).thenReturn(instance(mockedRepeaterManager)); - - when(mockedRepeaterManager.createRepeater(anything())).thenResolve({ - repeaterId - }); - when( mockedChildContainer.resolve(RepeaterBusFactory) ).thenReturn(instance(mockedRepeaterBusFactory)); - when(mockedRepeaterBusFactory.create(repeaterId)).thenReturn( + when(mockedRepeaterBusFactory.create()).thenReturn( instance(mockedRepeaterBus) ); }); afterEach(() => { reset< - | DependencyContainer - | Configuration - | RepeaterBus - | RepeatersManager - | RepeaterBusFactory + DependencyContainer | Configuration | RepeaterBus | RepeaterBusFactory >( mockedContainer, mockedChildContainer, mockedConfiguration, mockedRepeaterBus, - mockedRepeaterBusFactory, - mockedRepeaterManager + mockedRepeaterBusFactory ); }); @@ -108,66 +91,6 @@ describe('RepeaterFactory', () => { // assert expect(res).toBeInstanceOf(Repeater); - expect(res).toMatchObject({ - repeaterId - }); - }); - - it('should create repeater with given name prefix and description', async () => { - // arrange - const factory = new RepeaterFactory(configuration); - - // act - const res = await factory.createRepeater({ - namePrefix: 'foo', - description: 'description' - }); - - const [arg]: [ - { - name: string; - description?: string; - } - ] = capture<{ - name: string; - description?: string; - }>(mockedRepeaterManager.createRepeater).first(); - - // assert - expect(arg?.name).toMatch(/^foo/); - expect(arg?.description).toBe('description'); - expect(res).toBeInstanceOf(Repeater); - }); - - it('should create repeater with given name without the random postfix', async () => { - // arrange - const factory = new RepeaterFactory(configuration); - - // act - const res = await factory.createRepeater({ - namePrefix: 'foo', - disableRandomNameGeneration: true - }); - - // assert - verify( - mockedRepeaterManager.createRepeater(objectContaining({ name: 'foo' })) - ); - expect(res).toBeInstanceOf(Repeater); - }); - - it('should create repeater with given project', async () => { - // arrange - const factory = new RepeaterFactory(configuration); - const projectId = '321'; - const res = await factory.createRepeater({ - projectId - }); - - verify( - mockedRepeaterManager.createRepeater(objectContaining({ projectId })) - ); - expect(res).toBeInstanceOf(Repeater); }); it('should register custom request runner options', async () => { @@ -245,37 +168,6 @@ describe('RepeaterFactory', () => { ).once(); }); - it('should throw an error if name prefix is too long', async () => { - // arrange - const factory = new RepeaterFactory(configuration); - - // act - const res = factory.createRepeater({ - namePrefix: 'foo'.repeat(50) - }); - - // assert - await expect(res).rejects.toThrow( - 'Name prefix must be less than or equal to 43 characters.' - ); - }); - - it('should throw an error when name prefix is too long and random postfix is disabled', async () => { - // arrange - const factory = new RepeaterFactory(configuration); - - // act - const res = factory.createRepeater({ - namePrefix: 'foo'.repeat(80), - disableRandomNameGeneration: true - }); - - // assert - await expect(res).rejects.toThrow( - 'Name prefix must be less than or equal to 80 characters.' - ); - }); - it('should throw an error when credentials was not provided', async () => { // arrange const factory = new RepeaterFactory(configuration); diff --git a/packages/repeater/src/lib/RepeaterFactory.ts b/packages/repeater/src/lib/RepeaterFactory.ts index 26abde14..c5d175f5 100644 --- a/packages/repeater/src/lib/RepeaterFactory.ts +++ b/packages/repeater/src/lib/RepeaterFactory.ts @@ -1,12 +1,9 @@ -import { Repeater, RepeaterId } from './Repeater'; +import { Repeater } from './Repeater'; import { RequestRunner, RequestRunnerOptions } from '../request-runner'; import { RepeaterOptions } from './RepeaterOptions'; -import { RepeatersManager } from '../api'; import { RepeaterBusFactory } from '../bus/RepeaterBusFactory'; import { DefaultRepeaterServerOptions } from '../bus/DefaultRepeaterServer'; -import { RepeaterRequestRunnerOptions } from './RepeaterRequestRunnerOptions'; import { Configuration } from '@sectester/core'; -import { v4 as uuidv4 } from 'uuid'; import { DependencyContainer, injectable, Lifecycle } from 'tsyringe'; /** @@ -14,48 +11,19 @@ import { DependencyContainer, injectable, Lifecycle } from 'tsyringe'; */ @injectable() export class RepeaterFactory { - private readonly MAX_NAME_LENGTH = 80; - private readonly repeatersManager: RepeatersManager; private readonly runnerOptions: Readonly; constructor(private readonly configuration: Configuration) { - this.repeatersManager = - this.configuration.container.resolve(RepeatersManager); this.runnerOptions = this.configuration.container.resolve(RequestRunnerOptions); } public async createRepeater({ - projectId, - description, - disableRandomNameGeneration, - namePrefix = 'sectester', - ...requestRunnerOptions + requestRunnerOptions, + requestRunners = [] }: RepeaterOptions = {}): Promise { - const name = this.generateName(namePrefix, disableRandomNameGeneration); - - const { repeaterId } = await this.repeatersManager.createRepeater({ - description, - projectId, - name - }); - - return this.createRepeaterInstance(repeaterId, requestRunnerOptions); - } - - private async createRepeaterInstance( - repeaterId: string, - { - requestRunnerOptions, - requestRunners = [] - }: RepeaterRequestRunnerOptions = {} - ): Promise { const container = this.configuration.container.createChildContainer(); - container.register(RepeaterId, { - useValue: repeaterId - }); - await this.registerRepeaterServerOptions(container); this.registerRequestRunnerOptions(container, requestRunnerOptions); this.registerRequestRunners(container, requestRunners); @@ -63,10 +31,7 @@ export class RepeaterFactory { const busFactory = container.resolve(RepeaterBusFactory); - return new Repeater({ - repeaterId, - bus: busFactory.create(repeaterId) - }); + return new Repeater(busFactory.create()); } private async registerRepeaterServerOptions( @@ -92,25 +57,6 @@ export class RepeaterFactory { ); } - private generateName( - namePrefix: string, - disableRandomNameGeneration: boolean = false - ) { - const normalizedPrefix = namePrefix?.trim(); - const randomPostfix = disableRandomNameGeneration ? '' : `-${uuidv4()}`; - const name = `${normalizedPrefix}${randomPostfix}`; - - if (name.length > this.MAX_NAME_LENGTH) { - const maxPrefixLength = this.MAX_NAME_LENGTH - randomPostfix.length; - - throw new Error( - `Name prefix must be less than or equal to ${maxPrefixLength} characters.` - ); - } - - return name; - } - private registerRequestRunners( container: DependencyContainer, requestRunners: ( diff --git a/packages/repeater/src/lib/index.ts b/packages/repeater/src/lib/index.ts index 4d4794bb..7d26c760 100644 --- a/packages/repeater/src/lib/index.ts +++ b/packages/repeater/src/lib/index.ts @@ -1,4 +1,4 @@ -export { Repeater, RunningStatus, RepeaterId } from './Repeater'; +export { Repeater, RunningStatus } from './Repeater'; export { RepeaterFactory } from './RepeaterFactory'; export { RepeaterOptions } from './RepeaterOptions'; export { RepeaterRequestRunnerOptions } from './RepeaterRequestRunnerOptions'; diff --git a/packages/repeater/src/register.ts b/packages/repeater/src/register.ts index d6fe37e4..cd620d60 100644 --- a/packages/repeater/src/register.ts +++ b/packages/repeater/src/register.ts @@ -1,13 +1,11 @@ -import { RepeaterFactory, RepeaterId } from './lib'; -import { DefaultRepeatersManager, RepeatersManager } from './api'; +import { RepeaterFactory } from './lib'; import { DefaultRepeaterBusFactory, - DefaultRepeaterCommandHub, - DefaultRepeaterEventHub, + DefaultRepeaterCommands, DefaultRepeaterServer, + RepeaterBus, RepeaterBusFactory, - RepeaterCommandHub, - RepeaterEventHub, + RepeaterCommands, RepeaterServer } from './bus'; import { @@ -71,7 +69,9 @@ container.register(RMQEventBusConfig, { (childContainer: DependencyContainer) => ({ exchange: 'EventBus', appQueue: 'app', - clientQueue: `agent:${childContainer.resolve(RepeaterId)}` + clientQueue: `agent:${ + (childContainer.resolve(RepeaterBus) as RepeaterBus).repeaterId + }` }) ) }); @@ -95,8 +95,6 @@ container.register(EventBus, { } }); -container.register(RepeatersManager, { useClass: DefaultRepeatersManager }); container.register(RepeaterServer, { useClass: DefaultRepeaterServer }); -container.register(RepeaterEventHub, { useClass: DefaultRepeaterEventHub }); -container.register(RepeaterCommandHub, { useClass: DefaultRepeaterCommandHub }); +container.register(RepeaterCommands, { useClass: DefaultRepeaterCommands }); container.register(RepeaterBusFactory, { useClass: DefaultRepeaterBusFactory }); diff --git a/packages/runner/src/lib/SecRunner.spec.ts b/packages/runner/src/lib/SecRunner.spec.ts index 466b0dbc..4294b9b5 100644 --- a/packages/runner/src/lib/SecRunner.spec.ts +++ b/packages/runner/src/lib/SecRunner.spec.ts @@ -13,11 +13,7 @@ import { when } from 'ts-mockito'; import { DependencyContainer } from 'tsyringe'; -import { - Repeater, - RepeaterFactory, - RepeatersManager -} from '@sectester/repeater'; +import { Repeater, RepeaterFactory } from '@sectester/repeater'; // eslint-disable-next-line jest/no-export export const resolvableInstance = (m: T): T => @@ -44,7 +40,6 @@ describe('SecRunner', () => { const mockedContainer = mock(); const mockedConfiguration = mock(); const mockedRepeaterFactory = mock(); - const mockedRepeaterManager = mock(); const mockedRepeater = mock(); const mockedLogger = mock(); @@ -57,9 +52,6 @@ describe('SecRunner', () => { afterAll(() => process.setMaxListeners(maxListeners)); beforeEach(() => { - when( - mockedContainer.resolve(RepeatersManager) - ).thenReturn(instance(mockedRepeaterManager)); when(mockedContainer.resolve(Logger)).thenReturn( instance(mockedLogger) ); @@ -95,7 +87,6 @@ describe('SecRunner', () => { | DependencyContainer | Configuration | RepeaterFactory - | RepeatersManager | Repeater | Logger | NodeJS.Process @@ -103,7 +94,6 @@ describe('SecRunner', () => { mockedContainer, mockedConfiguration, mockedRepeaterFactory, - mockedRepeaterManager, mockedRepeater, mockedLogger, spiedProcess @@ -119,7 +109,6 @@ describe('SecRunner', () => { await terminationCallback(); verify(mockedRepeater.stop()).once(); - verify(mockedRepeaterManager.deleteRepeater(repeaterId)).once(); }); it('should log an error on failed stop() on process termination', async () => { @@ -161,7 +150,6 @@ describe('SecRunner', () => { await secRunner.clear(); verify(mockedRepeater.stop()).once(); - verify(mockedRepeaterManager.deleteRepeater(repeaterId)).once(); expect(secRunner.repeaterId).toBeUndefined(); }); diff --git a/packages/runner/src/lib/SecRunner.ts b/packages/runner/src/lib/SecRunner.ts index d5734d05..20fc4938 100644 --- a/packages/runner/src/lib/SecRunner.ts +++ b/packages/runner/src/lib/SecRunner.ts @@ -1,11 +1,7 @@ import { SecScanOptions } from './SecScanOptions'; import { SecScan } from './SecScan'; import { Configuration, ConfigurationOptions, Logger } from '@sectester/core'; -import { - Repeater, - RepeaterFactory, - RepeatersManager -} from '@sectester/repeater'; +import { Repeater, RepeaterFactory } from '@sectester/repeater'; import { ScanFactory } from '@sectester/scan'; import { Formatter, PlainTextFormatter } from '@sectester/reporter'; @@ -19,7 +15,6 @@ export class SecRunner { private readonly logger: Logger; private repeater: Repeater | undefined; private repeaterFactory: RepeaterFactory | undefined; - private repeatersManager: RepeatersManager | undefined; get repeaterId(): string | undefined { return this.repeater?.repeaterId; @@ -32,14 +27,12 @@ export class SecRunner { } public async init(): Promise { - if (this.repeatersManager && this.repeaterFactory) { + if (this.repeaterFactory) { throw new Error('Already initialized.'); } await this.initConfiguration(this.configuration); - this.repeatersManager = - this.configuration.container.resolve(RepeatersManager); this.repeaterFactory = this.configuration.container.resolve(RepeaterFactory); @@ -52,14 +45,12 @@ export class SecRunner { public async clear(): Promise { try { - if (this.repeater && this.repeatersManager) { + if (this.repeater) { await this.repeater.stop(); - await this.repeatersManager.deleteRepeater(this.repeater.repeaterId); } } finally { this.removeShutdownHandler(); delete this.repeater; - delete this.repeatersManager; delete this.repeaterFactory; } } From 504ebfe05a4031ac650c52de10005e72eceac1af Mon Sep 17 00:00:00 2001 From: Dmitry Ostrikov Date: Mon, 10 Jun 2024 21:06:50 +0700 Subject: [PATCH 07/17] feat(repeater): address pr comments closes #196 --- package-lock.json | 145 +++++++++- package.json | 4 + packages/core/src/logger/Logger.ts | 4 +- .../src/api/ExecuteRequestEventHandler.ts | 4 +- .../src/api/commands/CreateRepeaterRequest.ts | 24 -- .../src/api/commands/DeleteRepeaterRequest.ts | 12 - .../api/commands/RegisterRepeaterCommand.ts | 29 -- packages/repeater/src/api/commands/index.ts | 8 - .../src/api/events/RepeaterStatusEvent.ts | 13 - packages/repeater/src/api/events/index.ts | 1 - packages/repeater/src/api/index.ts | 7 - .../repeater/src/bus/DefaultRepeaterBus.ts | 16 +- packages/repeater/src/bus/RepeaterServer.ts | 2 +- packages/repeater/src/lib/RepeaterFactory.ts | 2 +- packages/repeater/src/register.ts | 2 + .../repeater/src/request-runner/Request.ts | 60 +--- .../protocols/HttpRequestRunner.spec.ts | 273 ++++++++++-------- .../protocols/HttpRequestRunner.ts | 242 +++++++++------- .../src/utils/DefaultProxyFactory.spec.ts | 105 +++++++ .../repeater/src/utils/DefaultProxyFactory.ts | 83 ++++++ ...ormalizeZlibDeflateTransformStream.spec.ts | 54 ++++ .../NormalizeZlibDeflateTransformStream.ts | 23 ++ .../src/utils/PatchedHttpsProxyAgent.ts | 28 ++ packages/repeater/src/utils/ProxyFactory.ts | 22 ++ packages/repeater/src/utils/index.ts | 3 + 25 files changed, 768 insertions(+), 398 deletions(-) delete mode 100644 packages/repeater/src/api/commands/CreateRepeaterRequest.ts delete mode 100644 packages/repeater/src/api/commands/DeleteRepeaterRequest.ts delete mode 100644 packages/repeater/src/api/commands/RegisterRepeaterCommand.ts delete mode 100644 packages/repeater/src/api/commands/index.ts delete mode 100644 packages/repeater/src/api/events/RepeaterStatusEvent.ts delete mode 100644 packages/repeater/src/api/events/index.ts create mode 100644 packages/repeater/src/utils/DefaultProxyFactory.spec.ts create mode 100644 packages/repeater/src/utils/DefaultProxyFactory.ts create mode 100644 packages/repeater/src/utils/NormalizeZlibDeflateTransformStream.spec.ts create mode 100644 packages/repeater/src/utils/NormalizeZlibDeflateTransformStream.ts create mode 100644 packages/repeater/src/utils/PatchedHttpsProxyAgent.ts create mode 100644 packages/repeater/src/utils/ProxyFactory.ts create mode 100644 packages/repeater/src/utils/index.ts diff --git a/package-lock.json b/package-lock.json index b72a25bc..8d85f9e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,11 @@ "chalk": "^4.1.2", "ci-info": "^3.3.0", "content-type": "^1.0.4", + "fast-content-type-parse": "^1.1.0", "form-data": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.4", + "iconv-lite": "^0.6.3", "reflect-metadata": "^0.1.13", "semver": "^7.5.2", "socket.io-client": "^4.7.5", @@ -5700,6 +5704,11 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "node_modules/fast-content-type-parse": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz", + "integrity": "sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6488,6 +6497,52 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", + "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -6513,12 +6568,11 @@ } }, "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" @@ -12790,8 +12844,7 @@ "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "node_modules/saxes": { "version": "5.0.1", @@ -14841,6 +14894,18 @@ "iconv-lite": "0.4.24" } }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/whatwg-mimetype": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz", @@ -19601,6 +19666,11 @@ "jest-message-util": "^27.5.1" } }, + "fast-content-type-parse": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz", + "integrity": "sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==" + }, "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -20203,6 +20273,44 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "requires": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "dependencies": { + "agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "requires": { + "debug": "^4.3.4" + } + } + } + }, + "https-proxy-agent": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", + "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", + "requires": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "dependencies": { + "agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "requires": { + "debug": "^4.3.4" + } + } + } + }, "human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -20216,12 +20324,11 @@ "dev": true }, "iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "requires": { - "safer-buffer": ">= 2.1.2 < 3" + "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "identity-obj-proxy": { @@ -24718,8 +24825,7 @@ "safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "saxes": { "version": "5.0.1", @@ -26311,6 +26417,17 @@ "dev": true, "requires": { "iconv-lite": "0.4.24" + }, + "dependencies": { + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + } } }, "whatwg-mimetype": { diff --git a/package.json b/package.json index 418acc6a..812168df 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,11 @@ "chalk": "^4.1.2", "ci-info": "^3.3.0", "content-type": "^1.0.4", + "fast-content-type-parse": "^1.1.0", "form-data": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.4", + "iconv-lite": "^0.6.3", "reflect-metadata": "^0.1.13", "semver": "^7.5.2", "socket.io-client": "^4.7.5", diff --git a/packages/core/src/logger/Logger.ts b/packages/core/src/logger/Logger.ts index 55e9bb47..cf0fb13b 100644 --- a/packages/core/src/logger/Logger.ts +++ b/packages/core/src/logger/Logger.ts @@ -28,8 +28,8 @@ export class Logger { this._logLevel = logLevel; } - public error(errorOrMessage: string, ...args: any[]): void { - this.write(errorOrMessage, LogLevel.ERROR, ...args); + public error(message: string, ...args: any[]): void { + this.write(message, LogLevel.ERROR, ...args); } public warn(message: string, ...args: any[]): void { diff --git a/packages/repeater/src/api/ExecuteRequestEventHandler.ts b/packages/repeater/src/api/ExecuteRequestEventHandler.ts index 36d129be..f8dd43d1 100644 --- a/packages/repeater/src/api/ExecuteRequestEventHandler.ts +++ b/packages/repeater/src/api/ExecuteRequestEventHandler.ts @@ -42,9 +42,7 @@ export class ExecuteRequestEventHandler throw new Error(`Unsupported protocol "${protocol}"`); } - const response: Response = await runner.run( - new Request({ ...event, correlationIdRegex: event.correlation_id_regex }) - ); + const response: Response = await runner.run(new Request({ ...event })); const { statusCode, message, errorCode, body, headers } = response; diff --git a/packages/repeater/src/api/commands/CreateRepeaterRequest.ts b/packages/repeater/src/api/commands/CreateRepeaterRequest.ts deleted file mode 100644 index 7a493d3f..00000000 --- a/packages/repeater/src/api/commands/CreateRepeaterRequest.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { HttpRequest } from '@sectester/bus'; - -export interface CreateRepeaterRequestPayload { - name: string; - description?: string; - projectIds?: string[]; -} - -export interface CreateRepeaterResponsePayload { - id: string; -} - -export class CreateRepeaterRequest extends HttpRequest< - CreateRepeaterRequestPayload, - CreateRepeaterResponsePayload -> { - constructor(payload: CreateRepeaterRequestPayload) { - super({ - payload, - url: '/api/v1/repeaters', - method: 'POST' - }); - } -} diff --git a/packages/repeater/src/api/commands/DeleteRepeaterRequest.ts b/packages/repeater/src/api/commands/DeleteRepeaterRequest.ts deleted file mode 100644 index e6b26620..00000000 --- a/packages/repeater/src/api/commands/DeleteRepeaterRequest.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { HttpRequest } from '@sectester/bus'; - -export class DeleteRepeaterRequest extends HttpRequest { - constructor(payload: { repeaterId: string }) { - super({ - url: `/api/v1/repeaters/${payload.repeaterId}`, - method: 'DELETE', - payload: undefined, - expectReply: false - }); - } -} diff --git a/packages/repeater/src/api/commands/RegisterRepeaterCommand.ts b/packages/repeater/src/api/commands/RegisterRepeaterCommand.ts deleted file mode 100644 index 75bc759c..00000000 --- a/packages/repeater/src/api/commands/RegisterRepeaterCommand.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Command } from '@sectester/core'; - -export interface RegisterRepeaterCommandPayload { - version: string; - repeaterId: string; -} - -export type RegisterRepeaterResult = - | { - version: string; - script: string | Record; - } - | { error: RepeaterRegisteringError }; - -export enum RepeaterRegisteringError { - NOT_ACTIVE = 'not_active', - BUSY = 'busy', - REQUIRES_TO_BE_UPDATED = 'requires_to_be_updated', - NOT_FOUND = 'not_found' -} - -export class RegisterRepeaterCommand extends Command< - RegisterRepeaterCommandPayload, - { payload: RegisterRepeaterResult } -> { - constructor(payload: RegisterRepeaterCommandPayload) { - super(payload, { type: 'RepeaterRegistering' }); - } -} diff --git a/packages/repeater/src/api/commands/index.ts b/packages/repeater/src/api/commands/index.ts deleted file mode 100644 index 08f6202f..00000000 --- a/packages/repeater/src/api/commands/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { CreateRepeaterRequest } from './CreateRepeaterRequest'; -export { DeleteRepeaterRequest } from './DeleteRepeaterRequest'; -export { - RegisterRepeaterCommand, - RegisterRepeaterCommandPayload, - RegisterRepeaterResult, - RepeaterRegisteringError -} from './RegisterRepeaterCommand'; diff --git a/packages/repeater/src/api/events/RepeaterStatusEvent.ts b/packages/repeater/src/api/events/RepeaterStatusEvent.ts deleted file mode 100644 index 956eeb49..00000000 --- a/packages/repeater/src/api/events/RepeaterStatusEvent.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { RepeaterStatus } from '../../lib'; -import { Event } from '@sectester/core'; - -interface RepeaterStatusEventPayload { - repeaterId: string; - status: RepeaterStatus; -} - -export class RepeaterStatusEvent extends Event { - constructor(payload: RepeaterStatusEventPayload) { - super(payload, 'RepeaterStatusUpdated'); - } -} diff --git a/packages/repeater/src/api/events/index.ts b/packages/repeater/src/api/events/index.ts deleted file mode 100644 index a16866ed..00000000 --- a/packages/repeater/src/api/events/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './RepeaterStatusEvent'; diff --git a/packages/repeater/src/api/index.ts b/packages/repeater/src/api/index.ts index 218dacc3..70203c15 100644 --- a/packages/repeater/src/api/index.ts +++ b/packages/repeater/src/api/index.ts @@ -1,8 +1 @@ -export { - RepeaterRegisteringError, - RegisterRepeaterResult, - RegisterRepeaterCommandPayload, - RegisterRepeaterCommand -} from './commands'; -export { RepeaterStatusEvent } from './events'; export * from './ExecuteRequestEventHandler'; diff --git a/packages/repeater/src/bus/DefaultRepeaterBus.ts b/packages/repeater/src/bus/DefaultRepeaterBus.ts index 9de54e90..e5da19cd 100644 --- a/packages/repeater/src/bus/DefaultRepeaterBus.ts +++ b/packages/repeater/src/bus/DefaultRepeaterBus.ts @@ -45,7 +45,7 @@ export class DefaultRepeaterBus implements RepeaterBus { this.logger.log('Connecting the Bridges'); - this.subscribeToEvents(); + this.subscribeDiagnosticEvents(); await this.repeaterServer.connect(); @@ -54,15 +54,23 @@ export class DefaultRepeaterBus implements RepeaterBus { await this.deploy(); this.logger.log('The Repeater (%s) started', this.repeaterId); + + this.subscribeRedeploymentEvent(); } private async deploy() { - const response = await this.repeaterServer.deploy(); + const { repeaterId } = await this.repeaterServer.deploy({ + repeaterId: this.repeaterId + }); + + this._repeaterId = repeaterId; + } - this._repeaterId = response.repeaterId; + private subscribeRedeploymentEvent() { + this.repeaterServer.on(RepeaterServerEvents.CONNECTED, this.deploy); } - private subscribeToEvents() { + private subscribeDiagnosticEvents() { this.repeaterServer.on(RepeaterServerEvents.ERROR, this.handleError); this.repeaterServer.on( diff --git a/packages/repeater/src/bus/RepeaterServer.ts b/packages/repeater/src/bus/RepeaterServer.ts index 947f4113..02a4fb97 100644 --- a/packages/repeater/src/bus/RepeaterServer.ts +++ b/packages/repeater/src/bus/RepeaterServer.ts @@ -105,7 +105,7 @@ export type CallbackFunction = (arg: T) => unknown; export type HandlerFunction = (args: unknown[]) => unknown; export interface RepeaterServer { - connect(hostname?: string): Promise; + connect(domain?: string): Promise; disconnect(): void; diff --git a/packages/repeater/src/lib/RepeaterFactory.ts b/packages/repeater/src/lib/RepeaterFactory.ts index c5d175f5..a9b6c4cf 100644 --- a/packages/repeater/src/lib/RepeaterFactory.ts +++ b/packages/repeater/src/lib/RepeaterFactory.ts @@ -50,7 +50,7 @@ export class RepeaterFactory { { useValue: { uri: `${this.configuration.api}/workstations`, - token: this.configuration.credentials?.token as string, + token: this.configuration.credentials.token, connectTimeout: 10000 } } diff --git a/packages/repeater/src/register.ts b/packages/repeater/src/register.ts index cd620d60..b0df5b12 100644 --- a/packages/repeater/src/register.ts +++ b/packages/repeater/src/register.ts @@ -13,6 +13,7 @@ import { RequestRunner, RequestRunnerOptions } from './request-runner'; +import { DefaultProxyFactory, ProxyFactory } from './utils'; import { container, DependencyContainer, @@ -95,6 +96,7 @@ container.register(EventBus, { } }); +container.register(ProxyFactory, { useClass: DefaultProxyFactory }); container.register(RepeaterServer, { useClass: DefaultRepeaterServer }); container.register(RepeaterCommands, { useClass: DefaultRepeaterCommands }); container.register(RepeaterBusFactory, { useClass: DefaultRepeaterBusFactory }); diff --git a/packages/repeater/src/request-runner/Request.ts b/packages/repeater/src/request-runner/Request.ts index 4ab420c9..73395963 100644 --- a/packages/repeater/src/request-runner/Request.ts +++ b/packages/repeater/src/request-runner/Request.ts @@ -7,7 +7,6 @@ export interface RequestOptions { headers?: Record; method?: string; body?: string; - correlationIdRegex?: string | RegExp; encoding?: 'base64'; maxContentSize?: number; timeout?: number; @@ -34,43 +33,19 @@ export class Request { public readonly protocol: Protocol; public readonly url: string; + public readonly method: string; public readonly body?: string; - public readonly correlationIdRegex?: RegExp; public readonly encoding?: 'base64'; public readonly maxContentSize?: number; public readonly decompress?: boolean; public readonly timeout?: number; - private _method: string; - - get method(): string { - return this._method; - } - private _headers: Record = {}; get headers(): Readonly> { return this._headers; } - private _ca?: Buffer; - - get ca() { - return this._ca; - } - - private _pfx?: Buffer; - - get pfx() { - return this._pfx; - } - - private _passphrase?: string; - - get passphrase() { - return this._passphrase; - } - get secureEndpoint(): boolean { return this.url.startsWith('https'); } @@ -81,14 +56,13 @@ export class Request { url, body, timeout, - correlationIdRegex, maxContentSize, encoding, decompress = true, headers = {} }: RequestOptions) { this.protocol = protocol; - this._method = method?.toUpperCase() ?? 'GET'; + this.method = method?.toUpperCase() ?? 'GET'; this.validateUrl(url); this.url = url.trim(); @@ -96,9 +70,6 @@ export class Request { this.precheckBody(body); this.body = body; - this.correlationIdRegex = - this.normalizeCorrelationIdRegex(correlationIdRegex); - this.setHeaders(headers); this.encoding = encoding; @@ -113,17 +84,16 @@ export class Request { ...headers }; - this._headers = Object.entries(mergedHeaders).reduce( - (result, [field, value]: [string, string | string[]]) => { - result[field] = + this._headers = Object.fromEntries( + Object.entries(mergedHeaders).map( + ([field, value]: [string, string | string[]]) => [ + field, Array.isArray(value) && Request.SINGLE_VALUE_HEADERS.has(field.toLowerCase()) ? value.join(', ') - : value; - - return result; - }, - {} + : value + ] + ) ); } @@ -140,16 +110,4 @@ export class Request { throw new Error('Body must be string.'); } } - - private normalizeCorrelationIdRegex( - correlationIdRegex: RegExp | string | undefined - ): RegExp | undefined { - if (correlationIdRegex) { - try { - return new RegExp(correlationIdRegex, 'i'); - } catch { - throw new Error('Correlation id must be regular expression.'); - } - } - } } diff --git a/packages/repeater/src/request-runner/protocols/HttpRequestRunner.spec.ts b/packages/repeater/src/request-runner/protocols/HttpRequestRunner.spec.ts index a1d194b6..95019c14 100644 --- a/packages/repeater/src/request-runner/protocols/HttpRequestRunner.spec.ts +++ b/packages/repeater/src/request-runner/protocols/HttpRequestRunner.spec.ts @@ -1,20 +1,26 @@ +import 'reflect-metadata'; import { HttpRequestRunner } from './HttpRequestRunner'; +import { Protocol } from '../../models/Protocol'; import { Request, RequestOptions } from '../Request'; import { RequestRunnerOptions } from '../RequestRunnerOptions'; -import { Protocol } from '../../models'; +import { ProxyFactory } from '../../utils'; +import { Logger } from '@sectester/core'; import nock from 'nock'; -import 'reflect-metadata'; -import { anything, spy, verify, when } from 'ts-mockito'; -import { Logger, LogLevel } from '@sectester/core'; -import { SocksProxyAgent } from 'socks-proxy-agent'; -import { brotliCompress, constants, gzip } from 'zlib'; -import { promisify } from 'util'; - -const createRequest = (options: Partial = {}) => { +import { anything, instance, mock, reset, spy, verify, when } from 'ts-mockito'; +import { promisify } from 'node:util'; +import { + brotliCompress, + constants, + gzip, + deflate, + deflateRaw +} from 'node:zlib'; + +const createRequest = (options?: Partial) => { const requestOptions = { - protocol: Protocol.HTTP, url: 'https://foo.bar', - method: 'GET', + headers: {}, + protocol: Protocol.HTTP, ...options }; const request = new Request(requestOptions); @@ -25,74 +31,89 @@ const createRequest = (options: Partial = {}) => { }; describe('HttpRequestRunner', () => { - const setupRunner = ( - options: RequestRunnerOptions = {}, - logger: Logger = new Logger() - ): HttpRequestRunner => new HttpRequestRunner(options, logger); - - beforeAll(() => nock.disableNetConnect()); - afterAll(() => nock.enableNetConnect()); + const loggerMock = mock(); + const proxyFactoryMock = mock(); + let spiedRunnerOptions!: RequestRunnerOptions; - afterEach(() => { - nock.cleanAll(); - nock.restore(); - }); + let sut!: HttpRequestRunner; beforeEach(() => { - if (!nock.isActive()) { - nock.activate(); - } + const RunnerOptions: RequestRunnerOptions = {}; + spiedRunnerOptions = spy(RunnerOptions); + + sut = new HttpRequestRunner( + instance(loggerMock), + instance(proxyFactoryMock), + RunnerOptions + ); }); + afterEach(() => + reset( + loggerMock, + spiedRunnerOptions, + proxyFactoryMock + ) + ); + describe('protocol', () => { - const runner = setupRunner(); - it('should return HTTP', () => expect(runner.protocol).toBe(Protocol.HTTP)); + it('should return HTTP', () => { + const protocol = sut.protocol; + expect(protocol).toBe(Protocol.HTTP); + }); }); describe('run', () => { it('should call setHeaders on the provided request if additional headers were configured globally', async () => { const headers = { testHeader: 'test-header-value' }; - const runner = setupRunner({ headers }); + when(spiedRunnerOptions.headers).thenReturn(headers); const { request, spiedRequest } = createRequest(); - nock('https://foo.bar').get('/').reply(200, {}); - await runner.run(request); + await sut.run(request); verify(spiedRequest.setHeaders(headers)).once(); }); it('should not call setHeaders on the provided request if there were no additional headers configured', async () => { - const runner = setupRunner(); const { request, spiedRequest } = createRequest(); - nock('https://foo.bar').get('/').reply(200, {}); - await runner.run(request); + await sut.run(request); verify(spiedRequest.setHeaders(anything())).never(); }); it('should perform an external http request', async () => { - const runner = setupRunner(); const { request, requestOptions } = createRequest(); nock(requestOptions.url).get('/').reply(200, {}); - const response = await runner.run(request); + const response = await sut.run(request); expect(response).toMatchObject({ statusCode: 200, - body: {} + body: '{}' + }); + }); + + it('should handle HTTP errors', async () => { + const { request, requestOptions } = createRequest(); + nock(requestOptions.url).get('/').reply(500, {}); + + const response = await sut.run(request); + + expect(response).toMatchObject({ + statusCode: 500, + body: '{}' }); }); it('should preserve directory traversal', async () => { - const runner = setupRunner(); const path = 'public/../../../../../../etc/passwd'; const { request } = createRequest({ url: `http://localhost:8080/${path}` }); nock('http://localhost:8080').get(`/${path}`).reply(200, {}); - const response = await runner.run(request); + const response = await sut.run(request); expect(response).toMatchObject({ statusCode: 200, @@ -100,77 +121,101 @@ describe('HttpRequestRunner', () => { }); }); - it('should handle HTTP errors', async () => { - const runner = setupRunner(); - const { request, requestOptions } = createRequest(); - nock(requestOptions.url).get('/').reply(500, {}); - - const response = await runner.run(request); - - expect(response).toMatchObject({ - statusCode: 500, - body: {} - }); - }); - it('should handle timeout', async () => { + when(spiedRunnerOptions.timeout).thenReturn(1); const { request, requestOptions } = createRequest(); nock(requestOptions.url).get('/').delayBody(2).reply(204); - const runner = setupRunner({ timeout: 1 }); - const response = await runner.run(request); + const response = await sut.run(request); expect(response).toMatchObject({ errorCode: 'Error', - message: 'This operation was aborted' + message: 'Waiting response has timed out' }); }); it('should handle non-HTTP errors', async () => { - const runner = setupRunner({}, new Logger(LogLevel.SILENT)); const { request } = createRequest(); - const response = await runner.run(request); + const response = await sut.run(request); - expect(response.statusCode).toBeUndefined(); + expect(response).toMatchObject({ + statusCode: undefined + }); }); it('should truncate response body with not white-listed mime type', async () => { - const runner = setupRunner({ - maxContentLength: 1 - }); + when(spiedRunnerOptions.maxContentLength).thenReturn(1); const { request, requestOptions } = createRequest(); const bigBody = 'x'.repeat(1025); nock(requestOptions.url) .get('/') .reply(200, bigBody, { 'content-type': 'application/x-custom' }); - const response = await runner.run(request); + const response = await sut.run(request); + expect(response.body?.length).toEqual(1024); expect(response.body).toEqual(bigBody.slice(0, 1024)); }); it('should not truncate response body if it is in allowed mime types', async () => { - const runner = setupRunner({ - maxContentLength: 1, - allowedMimes: ['application/x-custom'] - }); + when(spiedRunnerOptions.maxContentLength).thenReturn(1); + when(spiedRunnerOptions.allowedMimes).thenReturn([ + 'application/x-custom' + ]); const { request, requestOptions } = createRequest(); const bigBody = 'x'.repeat(1025); nock(requestOptions.url).get('/').reply(200, bigBody, { 'content-type': 'application/x-custom' }); - const response = await runner.run(request); + const response = await sut.run(request); expect(response.body).toEqual(bigBody); }); - it('should decode response body if content-encoding is gzip', async () => { - const runner = setupRunner({ - maxContentLength: 1, - allowedMimes: ['text/plain'] + it('should decode response body if content-encoding is brotli', async () => { + when(spiedRunnerOptions.maxContentLength).thenReturn(1); + when(spiedRunnerOptions.allowedMimes).thenReturn(['text/plain']); + const { request, requestOptions } = createRequest(); + const expected = 'x'.repeat(1025); + const bigBody = await promisify(brotliCompress)(expected); + nock(requestOptions.url).get('/').reply(200, bigBody, { + 'content-type': 'text/plain', + 'content-encoding': 'br' + }); + + const response = await sut.run(request); + + expect(response.body).toEqual(expected); + }); + + it('should prevent decoding response body if decompress option is disabled', async () => { + when(spiedRunnerOptions.maxContentLength).thenReturn(1); + when(spiedRunnerOptions.allowedMimes).thenReturn(['text/plain']); + const { request, requestOptions } = createRequest({ + decompress: false, + encoding: 'base64' + }); + const expected = 'x'.repeat(100); + const body = await promisify(gzip)(expected, { + flush: constants.Z_SYNC_FLUSH, + finishFlush: constants.Z_SYNC_FLUSH + }); + nock(requestOptions.url).get('/').reply(200, body, { + 'content-type': 'text/plain', + 'content-encoding': 'gzip' }); + + const response = await sut.run(request); + + expect(response.body).toEqual(body.toString('base64')); + expect(response.headers).toMatchObject({ 'content-encoding': 'gzip' }); + }); + + it('should decode response body if content-encoding is gzip', async () => { + when(spiedRunnerOptions.maxContentLength).thenReturn(1); + when(spiedRunnerOptions.allowedMimes).thenReturn(['text/plain']); const { request, requestOptions } = createRequest(); const expected = 'x'.repeat(1025); const bigBody = await promisify(gzip)(expected, { @@ -182,34 +227,52 @@ describe('HttpRequestRunner', () => { 'content-encoding': 'gzip' }); - const response = await runner.run(request); + const response = await sut.run(request); expect(response.body).toEqual(expected); }); - it('should decode response body if content-encoding is brotli', async () => { - const runner = setupRunner({ - maxContentLength: 1, - allowedMimes: ['text/plain'] + it('should decode response body if content-encoding is deflate', async () => { + when(spiedRunnerOptions.maxContentLength).thenReturn(1); + when(spiedRunnerOptions.allowedMimes).thenReturn(['text/plain']); + const { request, requestOptions } = createRequest(); + const expected = 'x'.repeat(1025); + const bigBody = await promisify(deflate)(expected, { + flush: constants.Z_SYNC_FLUSH, + finishFlush: constants.Z_SYNC_FLUSH }); + nock(requestOptions.url).get('/').reply(200, bigBody, { + 'content-type': 'text/plain', + 'content-encoding': 'deflate' + }); + + const response = await sut.run(request); + + expect(response.body).toEqual(expected); + }); + + it('should decode response body if content-encoding is deflate and content does not have zlib headers', async () => { + when(spiedRunnerOptions.maxContentLength).thenReturn(1); + when(spiedRunnerOptions.allowedMimes).thenReturn(['text/plain']); const { request, requestOptions } = createRequest(); const expected = 'x'.repeat(1025); - const bigBody = await promisify(brotliCompress)(expected); + const bigBody = await promisify(deflateRaw)(expected, { + flush: constants.Z_SYNC_FLUSH, + finishFlush: constants.Z_SYNC_FLUSH + }); nock(requestOptions.url).get('/').reply(200, bigBody, { 'content-type': 'text/plain', - 'content-encoding': 'br' + 'content-encoding': 'deflate' }); - const response = await runner.run(request); + const response = await sut.run(request); expect(response.body).toEqual(expected); }); it('should decode and truncate gzipped response body if content-type is not in allowed list', async () => { - const runner = setupRunner({ - maxContentLength: 1, - allowedMimes: ['text/plain'] - }); + when(spiedRunnerOptions.maxContentLength).thenReturn(1); + when(spiedRunnerOptions.allowedMimes).thenReturn(['text/plain']); const { request, requestOptions } = createRequest(); const bigBody = 'x'.repeat(1025); const expected = bigBody.slice(0, 1024); @@ -222,64 +285,34 @@ describe('HttpRequestRunner', () => { 'content-encoding': 'gzip' }); - const response = await runner.run(request); + const response = await sut.run(request); expect(response.body).toEqual(expected); }); it('should not truncate response body if allowed mime type starts with actual one', async () => { - const runner = setupRunner({ - maxContentLength: 1, - allowedMimes: ['application/x-custom'] - }); + when(spiedRunnerOptions.maxContentLength).thenReturn(1); + when(spiedRunnerOptions.allowedMimes).thenReturn([ + 'application/x-custom' + ]); const { request, requestOptions } = createRequest(); const bigBody = 'x'.repeat(1025); nock(requestOptions.url).get('/').reply(200, bigBody, { 'content-type': 'application/x-custom-with-suffix' }); - const response = await runner.run(request); + const response = await sut.run(request); expect(response.body).toEqual(bigBody); }); it('should skip truncate on 204 response status', async () => { - const runner = setupRunner({ - maxContentLength: 1 - }); + when(spiedRunnerOptions.maxContentLength).thenReturn(1); const { request, requestOptions } = createRequest(); nock(requestOptions.url).get('/').reply(204); - const response = await runner.run(request); + const response = await sut.run(request); expect(response.body).toEqual(''); }); - - it('should use SocksProxyAgent if socks proxyUrl provided', async () => { - const runner = setupRunner({ - proxyUrl: 'socks://proxy.baz' - }); - const { request, requestOptions } = createRequest(); - const scope = nock(requestOptions.url).get('/').reply(200, 'Dummy'); - - scope.on('request', req => - expect(req.options.agent).toBeInstanceOf(SocksProxyAgent) - ); - - await runner.run(request); - }); - - it('should use keepAlive agent on if reuseConnection enabled', async () => { - const runner = setupRunner({ - reuseConnection: true - }); - const { request, requestOptions } = createRequest(); - const scope = nock(requestOptions.url).get('/').reply(200, 'Dummy'); - - scope.on('request', req => { - expect(req.options.agent.options.keepAlive).toBeTruthy(); - }); - - await runner.run(request); - }); }); }); diff --git a/packages/repeater/src/request-runner/protocols/HttpRequestRunner.ts b/packages/repeater/src/request-runner/protocols/HttpRequestRunner.ts index 623b332d..61ebb86e 100644 --- a/packages/repeater/src/request-runner/protocols/HttpRequestRunner.ts +++ b/packages/repeater/src/request-runner/protocols/HttpRequestRunner.ts @@ -1,58 +1,55 @@ import { RequestRunner } from '../RequestRunner'; +import { Response } from '../Response'; +import { Request } from '../Request'; import { Protocol } from '../../models'; import { RequestRunnerOptions } from '../RequestRunnerOptions'; -import { Request } from '../Request'; -import { Response } from '../Response'; -import { parse as parseMimetype } from 'content-type'; +import { ProxyFactory, NormalizeZlibDeflateTransformStream } from '../../utils'; import { Logger } from '@sectester/core'; -import { SocksProxyAgent } from 'socks-proxy-agent'; import { inject, injectable } from 'tsyringe'; -import { parse as parseUrl } from 'url'; -import { once } from 'events'; -import https, { RequestOptions } from 'https'; +import iconv from 'iconv-lite'; +import { safeParse } from 'fast-content-type-parse'; +import { parse as parseUrl } from 'node:url'; import http, { - AgentOptions, ClientRequest, IncomingMessage, OutgoingMessage -} from 'http'; +} from 'node:http'; +import https, { + AgentOptions, + RequestOptions as ClientRequestOptions +} from 'node:https'; +import { once } from 'node:events'; +import { Readable } from 'node:stream'; import { constants, createBrotliDecompress, createGunzip, createInflate -} from 'zlib'; -import { Readable } from 'stream'; - -type IncomingResponse = IncomingMessage & { body?: string }; +} from 'node:zlib'; +import { IncomingHttpHeaders } from 'http'; @injectable() export class HttpRequestRunner implements RequestRunner { - private readonly proxy?: SocksProxyAgent; + private readonly httpProxyAgent?: http.Agent; + private readonly httpsProxyAgent?: https.Agent; private readonly httpAgent?: http.Agent; private readonly httpsAgent?: https.Agent; - private readonly maxContentLength: number; get protocol(): Protocol { return Protocol.HTTP; } constructor( + private readonly logger: Logger, + @inject(ProxyFactory) private readonly proxyFactory: ProxyFactory, @inject(RequestRunnerOptions) - private readonly options: RequestRunnerOptions, - private readonly logger: Logger + private readonly options: RequestRunnerOptions ) { if (this.options.proxyUrl) { - this.proxy = new SocksProxyAgent({ - ...parseUrl(this.options.proxyUrl) - }); + ({ httpsAgent: this.httpsProxyAgent, httpAgent: this.httpProxyAgent } = + this.proxyFactory.createProxy({ proxyUrl: this.options.proxyUrl })); } - this.maxContentLength = - typeof this.options.maxContentLength === 'number' - ? this.options.maxContentLength - : -1; - if (this.options.reuseConnection) { const agentOptions: AgentOptions = { keepAlive: true, @@ -76,74 +73,76 @@ export class HttpRequestRunner implements RequestRunner { options ); - const response = await this.request(options); + const { res, body } = await this.request(options); return new Response({ + body, protocol: this.protocol, - statusCode: response.statusCode, - headers: (response.headers ?? {}) as unknown as Record< - string, - string | string[] - >, - body: response.body + statusCode: res.statusCode, + headers: this.convertHeaders(res.headers), + encoding: options.encoding }); } catch (err) { - return this.handleRequestError(err, options); - } - } - - private handleRequestError(err: any, options: Request): Response { - const { cause } = err; - const { message, code, syscall, name } = cause ?? err; - let errorCode = code ?? syscall ?? name; + const { cause } = err; + const { message, code, syscall, name } = cause ?? err; + const errorCode = code ?? syscall ?? name; + + this.logger.error( + 'Error executing request: "%s %s HTTP/1.1"', + options.method, + options.url + ); + this.logger.error('Cause: %s', message); - if (typeof errorCode !== 'string') { - errorCode = Error.name; + return new Response({ + message, + errorCode, + protocol: this.protocol + }); } + } - this.logger.error( - 'Error executing request: "%s %s HTTP/1.1"', - options.method, - options.url + private convertHeaders( + headers: IncomingHttpHeaders + ): Record { + return Object.fromEntries( + Object.entries(headers).map( + ([name, value]: [string, string | string[] | undefined]) => [ + name, + value === undefined ? '' : value + ] + ) ); - this.logger.error('Cause: %s', message); - - return new Response({ - message, - errorCode, - protocol: this.protocol - }); } - private async request(options: Request): Promise { - const ac = new AbortController(); - const { signal } = ac; + private async request(options: Request) { let timer: NodeJS.Timeout | undefined; let res!: IncomingMessage; try { - const req = this.createRequest(options, { signal }); + const req = this.createRequest(options); - timer = this.setTimeout(ac); - process.nextTick(() => req.end(options.body)); + process.nextTick(() => + req.end( + options.encoding && options.body + ? iconv.encode(options.body, options.encoding) + : options.body + ) + ); + timer = this.setTimeout(req, options.timeout); - [res] = (await once(req, 'response', { - signal - })) as [IncomingMessage]; + [res] = (await once(req, 'response')) as [IncomingMessage]; } finally { clearTimeout(timer); } - return this.truncateResponse(res); + return this.truncateResponse(options, res); } - private createRequest( - request: Request, - options?: { signal?: AbortSignal } - ): ClientRequest { + private createRequest(request: Request): ClientRequest { const protocol = request.secureEndpoint ? https : http; const outgoingMessage = protocol.request( - this.createRequestOptions(request, options) + this.createRequestOptions(request) ); this.setHeaders(outgoingMessage, request); @@ -154,19 +153,20 @@ export class HttpRequestRunner implements RequestRunner { return outgoingMessage; } - private setTimeout(ac: AbortController): NodeJS.Timeout | undefined { - if (typeof this.options.timeout === 'number') { + private setTimeout( + req: ClientRequest, + timeout?: number + ): NodeJS.Timeout | undefined { + timeout ??= this.options.timeout; + if (typeof timeout === 'number') { return setTimeout( - () => ac.abort(/*'Waiting response has timed out'*/), - this.options.timeout + () => req.destroy(new Error('Waiting response has timed out')), + timeout ); } } - private createRequestOptions( - request: Request, - options?: { signal?: AbortSignal } - ): RequestOptions { + private createRequestOptions(request: Request): ClientRequestOptions { const { auth, hostname, @@ -177,61 +177,82 @@ export class HttpRequestRunner implements RequestRunner { } = parseUrl(request.url); const path = `${pathname ?? '/'}${search ?? ''}${hash ?? ''}`; const agent = this.getRequestAgent(request); + const timeout = request.timeout ?? this.options.timeout; return { - ...options, hostname, port, path, auth, agent, + timeout, method: request.method, - timeout: this.options.timeout, rejectUnauthorized: false }; } private getRequestAgent(options: Request) { - return ( - this.proxy ?? (options.secureEndpoint ? this.httpsAgent : this.httpAgent) - ); + return options.secureEndpoint + ? this.httpsProxyAgent ?? this.httpsAgent + : this.httpProxyAgent ?? this.httpAgent; } private async truncateResponse( - res: IncomingResponse - ): Promise { + { decompress, encoding, maxContentSize }: Request, + res: IncomingMessage + ) { if (this.responseHasNoBody(res)) { this.logger.debug('The response does not contain any body.'); - res.body = ''; - - return res; + return { res, body: '' }; } - const type = this.parseContentType(res); - const maxBodySize = this.maxContentLength * 1024; - const requiresTruncating = !this.options.allowedMimes?.some( - (mime: string) => type.startsWith(mime) - ); + const contentType = this.parseContentType(res); + const { type } = contentType; - const body = await this.parseBody(res, { maxBodySize, requiresTruncating }); + const requiresTruncating = + this.options.maxContentLength !== -1 && + !this.options.allowedMimes?.some((mime: string) => type.startsWith(mime)); - res.body = body.toString(); - res.headers['content-length'] = String(body.byteLength); + const maxBodySize = + typeof maxContentSize === 'number' + ? maxContentSize * 1024 + : this.options.maxContentLength + ? Math.abs(this.options.maxContentLength) * 1024 + : undefined; - return res; + const body = await this.parseBody(res, { + decompress, + maxBodySize: requiresTruncating ? maxBodySize : undefined + }); + + res.headers['content-length'] = body.byteLength.toFixed(); + + if (decompress) { + delete res.headers['content-encoding']; + } + + return { res, body: iconv.decode(body, encoding ?? contentType.encoding) }; } - private parseContentType(res: IncomingMessage): string { - let type = res.headers['content-type'] || 'text/plain'; + private parseContentType(res: IncomingMessage): { + type: string; + encoding: string; + } { + const contentType = + res.headers['content-type'] || 'application/octet-stream'; + const { + type, + parameters: { charset } + } = safeParse(contentType); - try { - ({ type } = parseMimetype(type)); - } catch { - // noop + let encoding: string | undefined = charset; + + if (!encoding || !iconv.encodingExists(encoding)) { + encoding = 'utf-8'; } - return type; + return { type, encoding }; } private unzipBody(response: IncomingMessage): Readable { @@ -252,7 +273,9 @@ export class HttpRequestRunner implements RequestRunner { body = response.pipe(createGunzip(zlibOptions)); break; case 'deflate': - body = response.pipe(createInflate(zlibOptions)); + body = response + .pipe(new NormalizeZlibDeflateTransformStream()) + .pipe(createInflate(zlibOptions)); break; case 'br': body = response.pipe(createBrotliDecompress()); @@ -275,20 +298,23 @@ export class HttpRequestRunner implements RequestRunner { private async parseBody( res: IncomingMessage, - options: { maxBodySize: number; requiresTruncating: boolean } + options: { + maxBodySize?: number; + decompress?: boolean; + } ): Promise { const chunks: Buffer[] = []; + const stream = options.decompress ? this.unzipBody(res) : res; - for await (const chuck of this.unzipBody(res)) { + for await (const chuck of stream) { chunks.push(chuck); } let body = Buffer.concat(chunks); const truncated = - this.maxContentLength !== -1 && - body.byteLength > options.maxBodySize && - options.requiresTruncating; + typeof options.maxBodySize === 'number' && + body.byteLength > options.maxBodySize; if (truncated) { this.logger.debug( diff --git a/packages/repeater/src/utils/DefaultProxyFactory.spec.ts b/packages/repeater/src/utils/DefaultProxyFactory.spec.ts new file mode 100644 index 00000000..4d68240d --- /dev/null +++ b/packages/repeater/src/utils/DefaultProxyFactory.spec.ts @@ -0,0 +1,105 @@ +import { DefaultProxyFactory } from './DefaultProxyFactory'; +import { HttpsProxyAgent } from 'https-proxy-agent'; +import { HttpProxyAgent } from 'http-proxy-agent'; +import { SocksProxyAgent } from 'socks-proxy-agent'; + +describe('DefaultProxyFactory', () => { + let defaultProxyFactory: DefaultProxyFactory; + + beforeEach(() => { + defaultProxyFactory = new DefaultProxyFactory(); + }); + + describe('createProxy', () => { + it('should create http and https proxy agents for http protocol', () => { + // arrange + const proxyOptions = { + proxyUrl: 'http://proxy.example.com', + rejectUnauthorized: false + }; + + // act + const result = defaultProxyFactory.createProxy(proxyOptions); + + // assert + expect(result.httpAgent).toBeInstanceOf(HttpProxyAgent); + expect(result.httpsAgent).toBeInstanceOf(HttpsProxyAgent); + }); + + it('should create socks proxy agents for socks protocol', () => { + // arrange + const proxyOptions = { + proxyUrl: 'socks://proxy.example.com', + rejectUnauthorized: false + }; + + // act + const result = defaultProxyFactory.createProxy(proxyOptions); + + // assert + expect(result.httpAgent).toBeInstanceOf(SocksProxyAgent); + expect(result.httpsAgent).toBeInstanceOf(SocksProxyAgent); + }); + + it('should throw error for unsupported protocol', () => { + // arrange + const proxyOptions = { + proxyUrl: 'unsupported://proxy.example.com', + rejectUnauthorized: false + }; + + // act & assert + expect(() => defaultProxyFactory.createProxy(proxyOptions)).toThrowError( + 'Unsupported proxy protocol' + ); + }); + }); + + describe('createProxyForClient', () => { + it('should return http agent for http protocol', () => { + // arrange + const targetProxyOptions = { + targetUrl: 'http://target.example.com', + proxyUrl: 'http://proxy.example.com', + rejectUnauthorized: false + }; + + // act + const result = + defaultProxyFactory.createProxyForClient(targetProxyOptions); + + // assert + expect(result).toBeInstanceOf(HttpProxyAgent); + }); + + it('should return https agent for https protocol', () => { + // arrange + const targetProxyOptions = { + targetUrl: 'https://target.example.com', + proxyUrl: 'http://proxy.example.com', + rejectUnauthorized: false + }; + + // act + const result = + defaultProxyFactory.createProxyForClient(targetProxyOptions); + + // assert + expect(result).toBeInstanceOf(HttpsProxyAgent); + }); + + it('should throw error for unsupported protocol', () => { + // arrange + const targetProxyOptions = { + targetUrl: 'unsupported://target.example.com', + proxyUrl: 'http://proxy.example.com', + rejectUnauthorized: false + }; + + // act & assert + expect(() => + defaultProxyFactory.createProxyForClient(targetProxyOptions) + ).toThrowError('Proxy not supported for protocol'); + }); + }); +}); diff --git a/packages/repeater/src/utils/DefaultProxyFactory.ts b/packages/repeater/src/utils/DefaultProxyFactory.ts new file mode 100644 index 00000000..597477a6 --- /dev/null +++ b/packages/repeater/src/utils/DefaultProxyFactory.ts @@ -0,0 +1,83 @@ +import { ProxyFactory, ProxyOptions, TargetProxyOptions } from './ProxyFactory'; +import { PatchedHttpsProxyAgent } from './PatchedHttpsProxyAgent'; +import { HttpProxyAgent } from 'http-proxy-agent'; +import { SocksProxyAgent } from 'socks-proxy-agent'; +import https from 'node:https'; +import http from 'node:http'; + +export class DefaultProxyFactory implements ProxyFactory { + public createProxy({ proxyUrl, rejectUnauthorized = false }: ProxyOptions) { + let protocol: string; + try { + ({ protocol } = new URL(proxyUrl)); + } catch (error) { + throw new Error( + `Invalid Proxy URL: '${proxyUrl}'. Please provide a valid URL.` + ); + } + + switch (protocol) { + case 'http:': + case 'https:': + return this.createHttpProxy(proxyUrl, rejectUnauthorized); + case 'socks:': + case 'socks4:': + case 'socks4a:': + case 'socks5:': + case 'socks5h:': + return this.createSocksProxy(proxyUrl); + default: + throw new Error( + `Unsupported proxy protocol: '${protocol.replace( + ':', + '' + )}'. Please use a supported protocol (HTTP(S), SOCKS4, or SOCKS5).` + ); + } + } + + public createProxyForClient({ + targetUrl, + ...options + }: TargetProxyOptions): https.Agent | http.Agent { + const proxies = this.createProxy(options); + let protocol: string; + try { + ({ protocol } = new URL(targetUrl)); + } catch (error) { + throw new Error( + `Invalid Target URL: '${targetUrl}'. Please contact support at support@brightsec.com` + ); + } + + switch (protocol) { + case 'http:': + case 'ws:': + return proxies.httpAgent; + case 'https:': + case 'wss:': + return proxies.httpsAgent; + default: + throw new Error( + `Proxy not supported for protocol '${protocol}'. Please contact support at support@brightsec.com` + ); + } + } + + private createHttpProxy(proxyUrl: string, rejectUnauthorized?: boolean) { + return { + httpsAgent: new PatchedHttpsProxyAgent(proxyUrl, { + rejectUnauthorized + }), + httpAgent: new HttpProxyAgent(proxyUrl, { + rejectUnauthorized + }) + }; + } + + private createSocksProxy(proxyUrl: string) { + const common = new SocksProxyAgent(proxyUrl); + + return { httpAgent: common, httpsAgent: common }; + } +} diff --git a/packages/repeater/src/utils/NormalizeZlibDeflateTransformStream.spec.ts b/packages/repeater/src/utils/NormalizeZlibDeflateTransformStream.spec.ts new file mode 100644 index 00000000..29422fd2 --- /dev/null +++ b/packages/repeater/src/utils/NormalizeZlibDeflateTransformStream.spec.ts @@ -0,0 +1,54 @@ +import { NormalizeZlibDeflateTransformStream } from './NormalizeZlibDeflateTransformStream'; +import { promisify } from 'node:util'; +import { constants, createInflate, deflate, deflateRaw } from 'node:zlib'; +import { Readable } from 'node:stream'; + +const zOpts = { + flush: constants.Z_SYNC_FLUSH, + finishFlush: constants.Z_SYNC_FLUSH +}; + +// TODO: replace with Readable.from once support for Node 10 is dropped +const readableFrom = (buffer: Buffer) => { + const stream = new Readable(); + stream.push(buffer); + stream.push(null); + + return stream; +}; + +describe('NormalizeZlibDeflateTransformStream', () => { + it('should add zlib headers to raw deflate', async () => { + // arrange + const data = 'xyz'.repeat(200); + + const stream = readableFrom(await promisify(deflateRaw)(data, zOpts)); + // act + const inflated = stream + .pipe(new NormalizeZlibDeflateTransformStream()) + .pipe(createInflate(zOpts)); + // assert + const result = []; + for await (const chunk of inflated) { + result.push(chunk); + } + expect(result.join('')).toBe(data); + }); + + it('should not affect deflate with zlib headers', async () => { + // arrange + const data = 'xyz'.repeat(200); + + const stream = readableFrom(await promisify(deflate)(data, zOpts)); + // act + const inflated = stream + .pipe(new NormalizeZlibDeflateTransformStream()) + .pipe(createInflate(zOpts)); + // assert + const result = []; + for await (const chunk of inflated) { + result.push(chunk); + } + expect(result.join('')).toBe(data); + }); +}); diff --git a/packages/repeater/src/utils/NormalizeZlibDeflateTransformStream.ts b/packages/repeater/src/utils/NormalizeZlibDeflateTransformStream.ts new file mode 100644 index 00000000..064e87ba --- /dev/null +++ b/packages/repeater/src/utils/NormalizeZlibDeflateTransformStream.ts @@ -0,0 +1,23 @@ +import { Transform, TransformCallback } from 'node:stream'; + +export class NormalizeZlibDeflateTransformStream extends Transform { + private hasCheckedHead = false; + private readonly header = Buffer.from([0x78, 0x9c]); + // eslint-disable-next-line @typescript-eslint/naming-convention + public _transform( + chunk: any, + encoding: BufferEncoding, + callback: TransformCallback + ) { + if (!this.hasCheckedHead && chunk.length !== 0) { + // ADHOC: detects raw deflate: https://stackoverflow.com/a/37528114 + if (chunk.compare(this.header, 0, 1, 0, 1) !== 0) { + this.push(this.header, encoding); + } + this.hasCheckedHead = true; + } + + this.push(chunk, encoding); + callback(); + } +} diff --git a/packages/repeater/src/utils/PatchedHttpsProxyAgent.ts b/packages/repeater/src/utils/PatchedHttpsProxyAgent.ts new file mode 100644 index 00000000..8a38efa9 --- /dev/null +++ b/packages/repeater/src/utils/PatchedHttpsProxyAgent.ts @@ -0,0 +1,28 @@ +import { + HttpsProxyAgent, + type HttpsProxyAgentOptions +} from 'https-proxy-agent'; +import { type URL } from 'node:url'; +import type http from 'node:http'; +import type net from 'node:net'; + +const kTlsUpgradeOptions = Symbol('tlsUpgradeOptions'); + +// ADHOC: This is a workaround for this issue: https://github.com/TooTallNate/node-https-proxy-agent/issues/89 +export class PatchedHttpsProxyAgent< + T extends string +> extends HttpsProxyAgent { + private readonly [kTlsUpgradeOptions]?: HttpsProxyAgentOptions; + + constructor(proxy: T | URL, opts?: HttpsProxyAgentOptions) { + super(proxy, opts); + this[kTlsUpgradeOptions] = opts; + } + + public override connect( + req: http.ClientRequest, + opts: Parameters['connect']>[1] + ): Promise { + return super.connect(req, { ...this[kTlsUpgradeOptions], ...opts }); + } +} diff --git a/packages/repeater/src/utils/ProxyFactory.ts b/packages/repeater/src/utils/ProxyFactory.ts new file mode 100644 index 00000000..cfbda2be --- /dev/null +++ b/packages/repeater/src/utils/ProxyFactory.ts @@ -0,0 +1,22 @@ +import https from 'node:https'; +import http from 'node:http'; + +export interface ProxyOptions { + proxyUrl: string; + rejectUnauthorized?: boolean; +} + +export interface TargetProxyOptions extends ProxyOptions { + targetUrl: string; +} + +export interface ProxyFactory { + createProxy(options: ProxyOptions): { + httpsAgent: https.Agent; + httpAgent: http.Agent; + }; + + createProxyForClient(options: TargetProxyOptions): https.Agent | http.Agent; +} + +export const ProxyFactory: unique symbol = Symbol('ProxyFactory'); diff --git a/packages/repeater/src/utils/index.ts b/packages/repeater/src/utils/index.ts new file mode 100644 index 00000000..c47972ae --- /dev/null +++ b/packages/repeater/src/utils/index.ts @@ -0,0 +1,3 @@ +export * from './ProxyFactory'; +export * from './NormalizeZlibDeflateTransformStream'; +export * from './DefaultProxyFactory'; From 956b06527f4b9be25c61d10f29fb2a9314cbccbf Mon Sep 17 00:00:00 2001 From: Dmitry Ostrikov Date: Mon, 10 Jun 2024 21:18:41 +0700 Subject: [PATCH 08/17] feat(repeater): fix build closes #196 --- packages/repeater/src/bus/DefaultRepeaterBus.spec.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/repeater/src/bus/DefaultRepeaterBus.spec.ts b/packages/repeater/src/bus/DefaultRepeaterBus.spec.ts index fb050744..1dad4c9f 100644 --- a/packages/repeater/src/bus/DefaultRepeaterBus.spec.ts +++ b/packages/repeater/src/bus/DefaultRepeaterBus.spec.ts @@ -44,7 +44,9 @@ describe('DefaultRepeaterBus', () => { } ); - when(mockedRepeaterServer.deploy()).thenResolve({ repeaterId: RepeaterId }); + when(mockedRepeaterServer.deploy(anything())).thenResolve({ + repeaterId: RepeaterId + }); sut = new DefaultRepeaterBus( instance(mockedLogger), @@ -68,7 +70,9 @@ describe('DefaultRepeaterBus', () => { // assert verify(mockedRepeaterServer.connect()).once(); - verify(mockedRepeaterServer.deploy()).once(); + verify( + mockedRepeaterServer.deploy(objectContaining({ repeaterId: undefined })) + ).once(); }); it('should allow connect more than once', async () => { @@ -95,7 +99,9 @@ describe('DefaultRepeaterBus', () => { it('should throw when underlying deploy throws', async () => { // arrange - when(mockedRepeaterServer.deploy()).thenReject(new Error('foo')); + when(mockedRepeaterServer.deploy(anything())).thenReject( + new Error('foo') + ); // act const act = () => sut.connect(); From 5f50d9f9b688423e9e73455617b219fe1efcae4d Mon Sep 17 00:00:00 2001 From: Dmitry Ostrikov Date: Thu, 13 Jun 2024 13:47:04 +0700 Subject: [PATCH 09/17] feat(repeater): address `undefined` encoding issue closes #196 --- package-lock.json | 14 +++++++------- package.json | 5 +++++ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8d85f9e7..968458a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9117,9 +9117,9 @@ } }, "node_modules/notepack.io": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/notepack.io/-/notepack.io-2.2.0.tgz", - "integrity": "sha512-9b5w3t5VSH6ZPosoYnyDONnUTF8o0UkBw7JLA6eBlYJWyGT1Q3vQa8Hmuj1/X6RYvHjjygBDgw6fJhe0JEojfw==" + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/notepack.io/-/notepack.io-3.0.1.tgz", + "integrity": "sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==" }, "node_modules/npm": { "version": "8.12.0", @@ -22195,9 +22195,9 @@ "dev": true }, "notepack.io": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/notepack.io/-/notepack.io-2.2.0.tgz", - "integrity": "sha512-9b5w3t5VSH6ZPosoYnyDONnUTF8o0UkBw7JLA6eBlYJWyGT1Q3vQa8Hmuj1/X6RYvHjjygBDgw6fJhe0JEojfw==" + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/notepack.io/-/notepack.io-3.0.1.tgz", + "integrity": "sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==" }, "npm": { "version": "8.12.0", @@ -25319,7 +25319,7 @@ "integrity": "sha512-1e76bJ1PCKi9H+JiYk+S29PBJvknHjQWM7Mtj0hjF2KxDA6b6rQxv3rTsnwBoz/haZOhlCDIMQvPATbqYeuMxg==", "requires": { "component-emitter": "~1.3.0", - "notepack.io": "~2.2.0" + "notepack.io": "~3.0.1" } }, "socket.io-parser": { diff --git a/package.json b/package.json index 812168df..f6cee5ba 100644 --- a/package.json +++ b/package.json @@ -139,5 +139,10 @@ "ts-jest": "27.1.4", "ts-mockito": "^2.6.1", "typescript": "4.7.4" + }, + "overrides": { + "socket.io-msgpack-parser": { + "notepack.io": "~3.0.1" + } } } From 26492b89edbd98a140332466fad0e7c19f7d623d Mon Sep 17 00:00:00 2001 From: Dmitry Ostrikov Date: Tue, 18 Jun 2024 14:11:55 +0700 Subject: [PATCH 10/17] feat(repeater): revert `RepeaterManager` closes #196 --- .../src/api/DefaultRepeatersManager.spec.ts | 79 +++++++++++++++++++ .../src/api/DefaultRepeatersManager.ts | 40 ++++++++++ packages/repeater/src/api/RepeatersManager.ts | 11 +++ .../src/api/commands/CreateRepeaterRequest.ts | 24 ++++++ .../src/api/commands/DeleteRepeaterRequest.ts | 12 +++ packages/repeater/src/api/commands/index.ts | 2 + packages/repeater/src/api/index.ts | 3 +- .../src/bus/DefaultRepeaterBus.spec.ts | 5 +- .../repeater/src/bus/DefaultRepeaterBus.ts | 15 ++-- .../src/bus/DefaultRepeaterBusFactory.spec.ts | 5 ++ .../src/bus/DefaultRepeaterBusFactory.ts | 4 + .../repeater/src/bus/DefaultRepeaterServer.ts | 4 +- packages/repeater/src/bus/RepeaterBus.ts | 1 - packages/repeater/src/index.ts | 7 +- packages/repeater/src/lib/Repeater.spec.ts | 4 +- packages/repeater/src/lib/Repeater.ts | 12 +-- .../repeater/src/lib/RepeaterFactory.spec.ts | 27 +++---- packages/repeater/src/lib/RepeaterFactory.ts | 72 ++++++++++------- packages/repeater/src/lib/index.ts | 2 +- packages/repeater/src/register.ts | 28 +++++-- .../protocols/HttpRequestRunner.spec.ts | 19 ++++- .../protocols/HttpRequestRunner.ts | 2 +- ...ormalizeZlibDeflateTransformStream.spec.ts | 69 ++++++++-------- packages/runner/src/lib/SecRunner.spec.ts | 14 +++- packages/runner/src/lib/SecRunner.ts | 15 +++- 25 files changed, 352 insertions(+), 124 deletions(-) create mode 100644 packages/repeater/src/api/DefaultRepeatersManager.spec.ts create mode 100644 packages/repeater/src/api/DefaultRepeatersManager.ts create mode 100644 packages/repeater/src/api/RepeatersManager.ts create mode 100644 packages/repeater/src/api/commands/CreateRepeaterRequest.ts create mode 100644 packages/repeater/src/api/commands/DeleteRepeaterRequest.ts create mode 100644 packages/repeater/src/api/commands/index.ts diff --git a/packages/repeater/src/api/DefaultRepeatersManager.spec.ts b/packages/repeater/src/api/DefaultRepeatersManager.spec.ts new file mode 100644 index 00000000..6b2dcd0e --- /dev/null +++ b/packages/repeater/src/api/DefaultRepeatersManager.spec.ts @@ -0,0 +1,79 @@ +import 'reflect-metadata'; +import { CreateRepeaterRequest, DeleteRepeaterRequest } from './commands'; +import { DefaultRepeatersManager } from './DefaultRepeatersManager'; +import { RepeatersManager } from './RepeatersManager'; +import { CommandDispatcher } from '@sectester/core'; +import { + anyOfClass, + instance, + mock, + objectContaining, + reset, + verify, + when +} from 'ts-mockito'; + +describe('DefaultRepeatersManager', () => { + const mockedCommandDispatcher = mock(); + let manager!: RepeatersManager; + + beforeEach(() => { + manager = new DefaultRepeatersManager(instance(mockedCommandDispatcher)); + }); + + afterEach(() => reset(mockedCommandDispatcher)); + + describe('createRepeater', () => { + it('should create repeater', async () => { + when( + mockedCommandDispatcher.execute(anyOfClass(CreateRepeaterRequest)) + ).thenResolve({ id: '142' }); + + const result = await manager.createRepeater({ name: 'foo' }); + + verify( + mockedCommandDispatcher.execute(anyOfClass(CreateRepeaterRequest)) + ).once(); + expect(result).toMatchObject({ repeaterId: '142' }); + }); + + it('should create repeater under a specific project', async () => { + when( + mockedCommandDispatcher.execute( + objectContaining({ payload: { name: 'foo', projectIds: ['321'] } }) + ) + ).thenResolve({ id: '142' }); + + const result = await manager.createRepeater({ + name: 'foo', + projectId: '321' + }); + + expect(result).toMatchObject({ repeaterId: '142' }); + }); + + it('should throw an error if cannot find created repeater', async () => { + when( + mockedCommandDispatcher.execute(anyOfClass(CreateRepeaterRequest)) + ).thenResolve(); + + const res = manager.createRepeater({ name: 'foo' }); + + await expect(res).rejects.toThrow('Cannot create a new repeater'); + }); + }); + + describe('deleteRepeater', () => { + it('should remove repeater', async () => { + when( + mockedCommandDispatcher.execute(anyOfClass(DeleteRepeaterRequest)) + ).thenResolve(); + + await manager.deleteRepeater('fooId'); + + verify( + mockedCommandDispatcher.execute(anyOfClass(DeleteRepeaterRequest)) + ).once(); + }); + }); +}); diff --git a/packages/repeater/src/api/DefaultRepeatersManager.ts b/packages/repeater/src/api/DefaultRepeatersManager.ts new file mode 100644 index 00000000..630ec035 --- /dev/null +++ b/packages/repeater/src/api/DefaultRepeatersManager.ts @@ -0,0 +1,40 @@ +import { RepeatersManager } from './RepeatersManager'; +import { CreateRepeaterRequest, DeleteRepeaterRequest } from './commands'; +import { inject, injectable } from 'tsyringe'; +import { CommandDispatcher } from '@sectester/core'; + +@injectable() +export class DefaultRepeatersManager implements RepeatersManager { + constructor( + @inject(CommandDispatcher) + private readonly commandDispatcher: CommandDispatcher + ) {} + + public async createRepeater({ + projectId, + ...options + }: { + name: string; + description?: string; + projectId?: string; + }): Promise<{ repeaterId: string }> { + const repeater = await this.commandDispatcher.execute( + new CreateRepeaterRequest({ + ...options, + ...(projectId ? { projectIds: [projectId] } : {}) + }) + ); + + if (!repeater?.id) { + throw new Error('Cannot create a new repeater.'); + } + + return { repeaterId: repeater.id }; + } + + public async deleteRepeater(repeaterId: string): Promise { + return this.commandDispatcher.execute( + new DeleteRepeaterRequest({ repeaterId }) + ); + } +} diff --git a/packages/repeater/src/api/RepeatersManager.ts b/packages/repeater/src/api/RepeatersManager.ts new file mode 100644 index 00000000..c53bdd3e --- /dev/null +++ b/packages/repeater/src/api/RepeatersManager.ts @@ -0,0 +1,11 @@ +export interface RepeatersManager { + createRepeater(options: { + name: string; + projectId?: string; + description?: string; + }): Promise<{ repeaterId: string }>; + + deleteRepeater(repeaterId: string): Promise; +} + +export const RepeatersManager: unique symbol = Symbol('RepeatersManager'); diff --git a/packages/repeater/src/api/commands/CreateRepeaterRequest.ts b/packages/repeater/src/api/commands/CreateRepeaterRequest.ts new file mode 100644 index 00000000..7a493d3f --- /dev/null +++ b/packages/repeater/src/api/commands/CreateRepeaterRequest.ts @@ -0,0 +1,24 @@ +import { HttpRequest } from '@sectester/bus'; + +export interface CreateRepeaterRequestPayload { + name: string; + description?: string; + projectIds?: string[]; +} + +export interface CreateRepeaterResponsePayload { + id: string; +} + +export class CreateRepeaterRequest extends HttpRequest< + CreateRepeaterRequestPayload, + CreateRepeaterResponsePayload +> { + constructor(payload: CreateRepeaterRequestPayload) { + super({ + payload, + url: '/api/v1/repeaters', + method: 'POST' + }); + } +} diff --git a/packages/repeater/src/api/commands/DeleteRepeaterRequest.ts b/packages/repeater/src/api/commands/DeleteRepeaterRequest.ts new file mode 100644 index 00000000..e6b26620 --- /dev/null +++ b/packages/repeater/src/api/commands/DeleteRepeaterRequest.ts @@ -0,0 +1,12 @@ +import { HttpRequest } from '@sectester/bus'; + +export class DeleteRepeaterRequest extends HttpRequest { + constructor(payload: { repeaterId: string }) { + super({ + url: `/api/v1/repeaters/${payload.repeaterId}`, + method: 'DELETE', + payload: undefined, + expectReply: false + }); + } +} diff --git a/packages/repeater/src/api/commands/index.ts b/packages/repeater/src/api/commands/index.ts new file mode 100644 index 00000000..3e70f8bb --- /dev/null +++ b/packages/repeater/src/api/commands/index.ts @@ -0,0 +1,2 @@ +export { CreateRepeaterRequest } from './CreateRepeaterRequest'; +export { DeleteRepeaterRequest } from './DeleteRepeaterRequest'; diff --git a/packages/repeater/src/api/index.ts b/packages/repeater/src/api/index.ts index 70203c15..af424c58 100644 --- a/packages/repeater/src/api/index.ts +++ b/packages/repeater/src/api/index.ts @@ -1 +1,2 @@ -export * from './ExecuteRequestEventHandler'; +export * from './RepeatersManager'; +export * from './DefaultRepeatersManager'; diff --git a/packages/repeater/src/bus/DefaultRepeaterBus.spec.ts b/packages/repeater/src/bus/DefaultRepeaterBus.spec.ts index 1dad4c9f..1664e385 100644 --- a/packages/repeater/src/bus/DefaultRepeaterBus.spec.ts +++ b/packages/repeater/src/bus/DefaultRepeaterBus.spec.ts @@ -49,6 +49,7 @@ describe('DefaultRepeaterBus', () => { }); sut = new DefaultRepeaterBus( + RepeaterId, instance(mockedLogger), instance(mockedRepeaterServer), instance(repeaterCommands) @@ -71,7 +72,9 @@ describe('DefaultRepeaterBus', () => { // assert verify(mockedRepeaterServer.connect()).once(); verify( - mockedRepeaterServer.deploy(objectContaining({ repeaterId: undefined })) + mockedRepeaterServer.deploy( + objectContaining({ repeaterId: RepeaterId }) + ) ).once(); }); diff --git a/packages/repeater/src/bus/DefaultRepeaterBus.ts b/packages/repeater/src/bus/DefaultRepeaterBus.ts index e5da19cd..d62614f0 100644 --- a/packages/repeater/src/bus/DefaultRepeaterBus.ts +++ b/packages/repeater/src/bus/DefaultRepeaterBus.ts @@ -9,6 +9,7 @@ import { RepeaterServerRequestEvent, RepeaterUpgradeAvailableEvent } from './RepeaterServer'; +import { RepeaterId } from '../lib/Repeater'; import { RepeaterCommands } from './RepeaterCommands'; import { Request } from '../request-runner/Request'; import { Logger } from '@sectester/core'; @@ -16,16 +17,12 @@ import chalk from 'chalk'; export class DefaultRepeaterBus implements RepeaterBus { private repeaterRunning: boolean = false; - private _repeaterId?: string; - - get repeaterId(): string | undefined { - return this._repeaterId; - } constructor( + private readonly repeaterId: RepeaterId, private readonly logger: Logger, private readonly repeaterServer: RepeaterServer, - private readonly commandHub: RepeaterCommands + private readonly repeaterCommands: RepeaterCommands ) {} public close() { @@ -59,11 +56,9 @@ export class DefaultRepeaterBus implements RepeaterBus { } private async deploy() { - const { repeaterId } = await this.repeaterServer.deploy({ + await this.repeaterServer.deploy({ repeaterId: this.repeaterId }); - - this._repeaterId = repeaterId; } private subscribeRedeploymentEvent() { @@ -158,7 +153,7 @@ export class DefaultRepeaterBus implements RepeaterBus { }; private requestReceived = async (event: RepeaterServerRequestEvent) => { - const response = await this.commandHub.sendRequest( + const response = await this.repeaterCommands.sendRequest( new Request({ ...event }) ); diff --git a/packages/repeater/src/bus/DefaultRepeaterBusFactory.spec.ts b/packages/repeater/src/bus/DefaultRepeaterBusFactory.spec.ts index 321af4df..98659218 100644 --- a/packages/repeater/src/bus/DefaultRepeaterBusFactory.spec.ts +++ b/packages/repeater/src/bus/DefaultRepeaterBusFactory.spec.ts @@ -6,6 +6,7 @@ import { Configuration, Logger } from '@sectester/core'; import { instance, mock, reset } from 'ts-mockito'; describe('DefaultRepeaterBusFactory', () => { + const repeaterId = 'fooId'; const mockedLogger = mock(); const mockedConfiguration = mock(); const mockedRepeaterServer = mock(); @@ -17,6 +18,7 @@ describe('DefaultRepeaterBusFactory', () => { beforeEach(() => { sut = new DefaultRepeaterBusFactory( + repeaterId, instance(mockedLogger), configuration, instance(mockedRepeaterServer), @@ -40,6 +42,9 @@ describe('DefaultRepeaterBusFactory', () => { // assert expect(res).toBeInstanceOf(DefaultRepeaterBus); + expect(res).toMatchObject({ + repeaterId + }); }); }); }); diff --git a/packages/repeater/src/bus/DefaultRepeaterBusFactory.ts b/packages/repeater/src/bus/DefaultRepeaterBusFactory.ts index a467fec8..75dfd6db 100644 --- a/packages/repeater/src/bus/DefaultRepeaterBusFactory.ts +++ b/packages/repeater/src/bus/DefaultRepeaterBusFactory.ts @@ -3,12 +3,15 @@ import { DefaultRepeaterBus } from './DefaultRepeaterBus'; import { RepeaterBusFactory } from './RepeaterBusFactory'; import { RepeaterCommands } from './RepeaterCommands'; import { RepeaterServer } from './RepeaterServer'; +import { RepeaterId } from '../lib/Repeater'; import { Configuration, Logger } from '@sectester/core'; import { inject, injectable } from 'tsyringe'; @injectable() export class DefaultRepeaterBusFactory implements RepeaterBusFactory { constructor( + @inject(RepeaterId) + private readonly repeaterId: RepeaterId, private readonly logger: Logger, private readonly configuration: Configuration, @inject(RepeaterServer) private readonly repeaterServer: RepeaterServer, @@ -23,6 +26,7 @@ export class DefaultRepeaterBusFactory implements RepeaterBusFactory { ); return new DefaultRepeaterBus( + this.repeaterId, this.logger, this.repeaterServer, this.repeaterCommands diff --git a/packages/repeater/src/bus/DefaultRepeaterServer.ts b/packages/repeater/src/bus/DefaultRepeaterServer.ts index ab514a47..c557d60e 100644 --- a/packages/repeater/src/bus/DefaultRepeaterServer.ts +++ b/packages/repeater/src/bus/DefaultRepeaterServer.ts @@ -134,14 +134,14 @@ export class DefaultRepeaterServer implements RepeaterServer { return result; } - public async connect(domain: string = hostname()) { + public async connect(namePrefix: string = hostname()) { this._socket = io(this.options.uri, { parser, path: '/api/ws/v1', transports: ['websocket'], reconnectionAttempts: this.MAX_RECONNECTION_ATTEMPTS, auth: { - domain, + domain: namePrefix, token: this.options.token } }); diff --git a/packages/repeater/src/bus/RepeaterBus.ts b/packages/repeater/src/bus/RepeaterBus.ts index 9deda133..a3fd758f 100644 --- a/packages/repeater/src/bus/RepeaterBus.ts +++ b/packages/repeater/src/bus/RepeaterBus.ts @@ -1,5 +1,4 @@ export interface RepeaterBus { - readonly repeaterId?: string; connect(): Promise; close(): Promise; } diff --git a/packages/repeater/src/index.ts b/packages/repeater/src/index.ts index 235af287..dc35f21f 100644 --- a/packages/repeater/src/index.ts +++ b/packages/repeater/src/index.ts @@ -1,10 +1,5 @@ import './register'; - -export { - ExecuteRequestEventHandler, - ExecuteRequestPayload, - ExecuteRequestResult -} from './api'; +export { RepeatersManager } from './api'; export * from './lib'; export * from './models'; export * from './request-runner'; diff --git a/packages/repeater/src/lib/Repeater.spec.ts b/packages/repeater/src/lib/Repeater.spec.ts index d6007cd0..41d73773 100644 --- a/packages/repeater/src/lib/Repeater.spec.ts +++ b/packages/repeater/src/lib/Repeater.spec.ts @@ -8,10 +8,10 @@ describe('Repeater', () => { let repeater!: Repeater; const mockedRepeaterBus = mock(); - const createRepeater = () => new Repeater(instance(mockedRepeaterBus)); + const createRepeater = () => + new Repeater(repeaterId, instance(mockedRepeaterBus)); beforeEach(() => { - when(mockedRepeaterBus.repeaterId).thenReturn(repeaterId); repeater = createRepeater(); }); diff --git a/packages/repeater/src/lib/Repeater.ts b/packages/repeater/src/lib/Repeater.ts index d7c42ca1..eed8bd3c 100644 --- a/packages/repeater/src/lib/Repeater.ts +++ b/packages/repeater/src/lib/Repeater.ts @@ -6,18 +6,20 @@ export enum RunningStatus { RUNNING } +export type RepeaterId = string; +export const RepeaterId = Symbol('RepeaterId'); + export class Repeater { private _runningStatus = RunningStatus.OFF; - get repeaterId(): string | undefined { - return this.bus.repeaterId; - } - get runningStatus(): RunningStatus { return this._runningStatus; } - constructor(private readonly bus: RepeaterBus) {} + constructor( + public readonly repeaterId: RepeaterId, + private readonly bus: RepeaterBus + ) {} public async start(): Promise { if (this.runningStatus !== RunningStatus.OFF) { diff --git a/packages/repeater/src/lib/RepeaterFactory.spec.ts b/packages/repeater/src/lib/RepeaterFactory.spec.ts index 9fee127e..db194575 100644 --- a/packages/repeater/src/lib/RepeaterFactory.spec.ts +++ b/packages/repeater/src/lib/RepeaterFactory.spec.ts @@ -7,6 +7,7 @@ import { RequestRunnerOptions } from '../request-runner'; import { Repeater } from './Repeater'; +import { RepeatersManager } from '../api'; import { Configuration } from '@sectester/core'; import { anything, @@ -21,6 +22,8 @@ import { import { DependencyContainer, Lifecycle } from 'tsyringe'; describe('RepeaterFactory', () => { + const repeaterId = 'fooId'; + const defaultOptions = { timeout: 30000, maxContentLength: 100, @@ -48,6 +51,7 @@ describe('RepeaterFactory', () => { const mockedConfiguration = mock(); const mockedRepeaterBus = mock(); const mockedRepeaterBusFactory = mock(); + const mockedRepeaterManager = mock(); const configuration = instance(mockedConfiguration); @@ -64,9 +68,17 @@ describe('RepeaterFactory', () => { mockedChildContainer.resolve(RepeaterBusFactory) ).thenReturn(instance(mockedRepeaterBusFactory)); + when( + mockedContainer.resolve(RepeatersManager) + ).thenReturn(instance(mockedRepeaterManager)); + when(mockedRepeaterBusFactory.create()).thenReturn( instance(mockedRepeaterBus) ); + + when(mockedRepeaterManager.createRepeater(anything())).thenResolve({ + repeaterId + }); }); afterEach(() => { @@ -167,20 +179,5 @@ describe('RepeaterFactory', () => { ) ).once(); }); - - it('should throw an error when credentials was not provided', async () => { - // arrange - const factory = new RepeaterFactory(configuration); - - when(mockedConfiguration.credentials).thenReturn(undefined); - - // act - const res = factory.createRepeater(); - - // assert - await expect(res).rejects.toThrow( - 'Please provide credentials to establish a connection with the bus.' - ); - }); }); }); diff --git a/packages/repeater/src/lib/RepeaterFactory.ts b/packages/repeater/src/lib/RepeaterFactory.ts index a9b6c4cf..4d9d9915 100644 --- a/packages/repeater/src/lib/RepeaterFactory.ts +++ b/packages/repeater/src/lib/RepeaterFactory.ts @@ -1,9 +1,10 @@ -import { Repeater } from './Repeater'; +import { Repeater, RepeaterId } from './Repeater'; import { RequestRunner, RequestRunnerOptions } from '../request-runner'; +import { RepeatersManager } from '../api'; import { RepeaterOptions } from './RepeaterOptions'; import { RepeaterBusFactory } from '../bus/RepeaterBusFactory'; -import { DefaultRepeaterServerOptions } from '../bus/DefaultRepeaterServer'; import { Configuration } from '@sectester/core'; +import { v4 as uuidv4 } from 'uuid'; import { DependencyContainer, injectable, Lifecycle } from 'tsyringe'; /** @@ -11,50 +12,44 @@ import { DependencyContainer, injectable, Lifecycle } from 'tsyringe'; */ @injectable() export class RepeaterFactory { + private readonly MAX_NAME_LENGTH = 80; + private readonly repeatersManager: RepeatersManager; private readonly runnerOptions: Readonly; constructor(private readonly configuration: Configuration) { + this.repeatersManager = + this.configuration.container.resolve(RepeatersManager); this.runnerOptions = this.configuration.container.resolve(RequestRunnerOptions); } public async createRepeater({ + projectId, + description, + disableRandomNameGeneration, + namePrefix = 'sectester', requestRunnerOptions, - requestRunners = [] + requestRunners }: RepeaterOptions = {}): Promise { + await this.configuration.loadCredentials(); + + const { repeaterId } = await this.repeatersManager.createRepeater({ + description, + projectId, + name: this.generateName(namePrefix, disableRandomNameGeneration) + }); + const container = this.configuration.container.createChildContainer(); - await this.registerRepeaterServerOptions(container); + container.register(RepeaterId, { useValue: repeaterId }); + this.registerRequestRunnerOptions(container, requestRunnerOptions); - this.registerRequestRunners(container, requestRunners); + this.registerRequestRunners(container, requestRunners ?? []); const busFactory = container.resolve(RepeaterBusFactory); - return new Repeater(busFactory.create()); - } - - private async registerRepeaterServerOptions( - container: DependencyContainer - ): Promise { - await this.configuration.loadCredentials(); - - if (!this.configuration.credentials) { - throw new Error( - 'Please provide credentials to establish a connection with the bus.' - ); - } - - container.register( - DefaultRepeaterServerOptions, - { - useValue: { - uri: `${this.configuration.api}/workstations`, - token: this.configuration.credentials.token, - connectTimeout: 10000 - } - } - ); + return new Repeater(repeaterId, busFactory.create()); } private registerRequestRunners( @@ -94,4 +89,23 @@ export class RepeaterFactory { } }); } + + private generateName( + namePrefix: string, + disableRandomNameGeneration: boolean = false + ) { + const normalizedPrefix = namePrefix?.trim(); + const randomPostfix = disableRandomNameGeneration ? '' : `-${uuidv4()}`; + const name = `${normalizedPrefix}${randomPostfix}`; + + if (name.length > this.MAX_NAME_LENGTH) { + const maxPrefixLength = this.MAX_NAME_LENGTH - randomPostfix.length; + + throw new Error( + `Name prefix must be less than or equal to ${maxPrefixLength} characters.` + ); + } + + return name; + } } diff --git a/packages/repeater/src/lib/index.ts b/packages/repeater/src/lib/index.ts index 7d26c760..4d4794bb 100644 --- a/packages/repeater/src/lib/index.ts +++ b/packages/repeater/src/lib/index.ts @@ -1,4 +1,4 @@ -export { Repeater, RunningStatus } from './Repeater'; +export { Repeater, RunningStatus, RepeaterId } from './Repeater'; export { RepeaterFactory } from './RepeaterFactory'; export { RepeaterOptions } from './RepeaterOptions'; export { RepeaterRequestRunnerOptions } from './RepeaterRequestRunnerOptions'; diff --git a/packages/repeater/src/register.ts b/packages/repeater/src/register.ts index b0df5b12..315af601 100644 --- a/packages/repeater/src/register.ts +++ b/packages/repeater/src/register.ts @@ -1,9 +1,9 @@ -import { RepeaterFactory } from './lib'; +import { RepeaterFactory, RepeaterId } from './lib'; import { DefaultRepeaterBusFactory, DefaultRepeaterCommands, DefaultRepeaterServer, - RepeaterBus, + DefaultRepeaterServerOptions, RepeaterBusFactory, RepeaterCommands, RepeaterServer @@ -13,6 +13,7 @@ import { RequestRunner, RequestRunnerOptions } from './request-runner'; +import { DefaultRepeatersManager, RepeatersManager } from './api'; import { DefaultProxyFactory, ProxyFactory } from './utils'; import { container, @@ -70,9 +71,7 @@ container.register(RMQEventBusConfig, { (childContainer: DependencyContainer) => ({ exchange: 'EventBus', appQueue: 'app', - clientQueue: `agent:${ - (childContainer.resolve(RepeaterBus) as RepeaterBus).repeaterId - }` + clientQueue: `agent:${childContainer.resolve(RepeaterId)}` }) ) }); @@ -96,7 +95,26 @@ container.register(EventBus, { } }); +container.register(DefaultRepeaterServerOptions, { + useFactory: (childContainer: DependencyContainer) => { + const configuration = childContainer.resolve(Configuration); + + if (!configuration.credentials) { + throw new Error( + 'Please provide credentials to establish a connection with the bridges.' + ); + } + + return { + uri: `${configuration.api}/workstations`, + token: configuration.credentials.token, + connectTimeout: 10000 + }; + } +}); + container.register(ProxyFactory, { useClass: DefaultProxyFactory }); container.register(RepeaterServer, { useClass: DefaultRepeaterServer }); container.register(RepeaterCommands, { useClass: DefaultRepeaterCommands }); container.register(RepeaterBusFactory, { useClass: DefaultRepeaterBusFactory }); +container.register(RepeatersManager, { useClass: DefaultRepeatersManager }); diff --git a/packages/repeater/src/request-runner/protocols/HttpRequestRunner.spec.ts b/packages/repeater/src/request-runner/protocols/HttpRequestRunner.spec.ts index 95019c14..b1bdf5b1 100644 --- a/packages/repeater/src/request-runner/protocols/HttpRequestRunner.spec.ts +++ b/packages/repeater/src/request-runner/protocols/HttpRequestRunner.spec.ts @@ -37,7 +37,17 @@ describe('HttpRequestRunner', () => { let sut!: HttpRequestRunner; + beforeAll(() => { + nock.disableNetConnect(); + nock.enableNetConnect('127.0.0.1'); + }); + afterAll(() => nock.enableNetConnect()); + beforeEach(() => { + if (!nock.isActive()) { + nock.activate(); + } + const RunnerOptions: RequestRunnerOptions = {}; spiedRunnerOptions = spy(RunnerOptions); @@ -48,13 +58,16 @@ describe('HttpRequestRunner', () => { ); }); - afterEach(() => + afterEach(() => { + nock.cleanAll(); + nock.restore(); + reset( loggerMock, spiedRunnerOptions, proxyFactoryMock - ) - ); + ); + }); describe('protocol', () => { it('should return HTTP', () => { diff --git a/packages/repeater/src/request-runner/protocols/HttpRequestRunner.ts b/packages/repeater/src/request-runner/protocols/HttpRequestRunner.ts index 61ebb86e..e9722838 100644 --- a/packages/repeater/src/request-runner/protocols/HttpRequestRunner.ts +++ b/packages/repeater/src/request-runner/protocols/HttpRequestRunner.ts @@ -109,7 +109,7 @@ export class HttpRequestRunner implements RequestRunner { Object.entries(headers).map( ([name, value]: [string, string | string[] | undefined]) => [ name, - value === undefined ? '' : value + value ?? '' ] ) ); diff --git a/packages/repeater/src/utils/NormalizeZlibDeflateTransformStream.spec.ts b/packages/repeater/src/utils/NormalizeZlibDeflateTransformStream.spec.ts index 29422fd2..20a379f5 100644 --- a/packages/repeater/src/utils/NormalizeZlibDeflateTransformStream.spec.ts +++ b/packages/repeater/src/utils/NormalizeZlibDeflateTransformStream.spec.ts @@ -8,47 +8,40 @@ const zOpts = { finishFlush: constants.Z_SYNC_FLUSH }; -// TODO: replace with Readable.from once support for Node 10 is dropped -const readableFrom = (buffer: Buffer) => { - const stream = new Readable(); - stream.push(buffer); - stream.push(null); - - return stream; -}; - describe('NormalizeZlibDeflateTransformStream', () => { - it('should add zlib headers to raw deflate', async () => { - // arrange - const data = 'xyz'.repeat(200); + describe('pipe', () => { + it('should add zlib headers to raw deflate', async () => { + // arrange + const data = 'xyz'.repeat(200); - const stream = readableFrom(await promisify(deflateRaw)(data, zOpts)); - // act - const inflated = stream - .pipe(new NormalizeZlibDeflateTransformStream()) - .pipe(createInflate(zOpts)); - // assert - const result = []; - for await (const chunk of inflated) { - result.push(chunk); - } - expect(result.join('')).toBe(data); - }); + const stream = Readable.from(await promisify(deflateRaw)(data, zOpts)); + // act + const inflated = stream + .pipe(new NormalizeZlibDeflateTransformStream()) + .pipe(createInflate(zOpts)); + // assert + const result = []; + for await (const chunk of inflated) { + result.push(chunk); + } + expect(result.join('')).toBe(data); + }); - it('should not affect deflate with zlib headers', async () => { - // arrange - const data = 'xyz'.repeat(200); + it('should not affect deflate with zlib headers', async () => { + // arrange + const data = 'xyz'.repeat(200); - const stream = readableFrom(await promisify(deflate)(data, zOpts)); - // act - const inflated = stream - .pipe(new NormalizeZlibDeflateTransformStream()) - .pipe(createInflate(zOpts)); - // assert - const result = []; - for await (const chunk of inflated) { - result.push(chunk); - } - expect(result.join('')).toBe(data); + const stream = Readable.from(await promisify(deflate)(data, zOpts)); + // act + const inflated = stream + .pipe(new NormalizeZlibDeflateTransformStream()) + .pipe(createInflate(zOpts)); + // assert + const result = []; + for await (const chunk of inflated) { + result.push(chunk); + } + expect(result.join('')).toBe(data); + }); }); }); diff --git a/packages/runner/src/lib/SecRunner.spec.ts b/packages/runner/src/lib/SecRunner.spec.ts index 4294b9b5..466b0dbc 100644 --- a/packages/runner/src/lib/SecRunner.spec.ts +++ b/packages/runner/src/lib/SecRunner.spec.ts @@ -13,7 +13,11 @@ import { when } from 'ts-mockito'; import { DependencyContainer } from 'tsyringe'; -import { Repeater, RepeaterFactory } from '@sectester/repeater'; +import { + Repeater, + RepeaterFactory, + RepeatersManager +} from '@sectester/repeater'; // eslint-disable-next-line jest/no-export export const resolvableInstance = (m: T): T => @@ -40,6 +44,7 @@ describe('SecRunner', () => { const mockedContainer = mock(); const mockedConfiguration = mock(); const mockedRepeaterFactory = mock(); + const mockedRepeaterManager = mock(); const mockedRepeater = mock(); const mockedLogger = mock(); @@ -52,6 +57,9 @@ describe('SecRunner', () => { afterAll(() => process.setMaxListeners(maxListeners)); beforeEach(() => { + when( + mockedContainer.resolve(RepeatersManager) + ).thenReturn(instance(mockedRepeaterManager)); when(mockedContainer.resolve(Logger)).thenReturn( instance(mockedLogger) ); @@ -87,6 +95,7 @@ describe('SecRunner', () => { | DependencyContainer | Configuration | RepeaterFactory + | RepeatersManager | Repeater | Logger | NodeJS.Process @@ -94,6 +103,7 @@ describe('SecRunner', () => { mockedContainer, mockedConfiguration, mockedRepeaterFactory, + mockedRepeaterManager, mockedRepeater, mockedLogger, spiedProcess @@ -109,6 +119,7 @@ describe('SecRunner', () => { await terminationCallback(); verify(mockedRepeater.stop()).once(); + verify(mockedRepeaterManager.deleteRepeater(repeaterId)).once(); }); it('should log an error on failed stop() on process termination', async () => { @@ -150,6 +161,7 @@ describe('SecRunner', () => { await secRunner.clear(); verify(mockedRepeater.stop()).once(); + verify(mockedRepeaterManager.deleteRepeater(repeaterId)).once(); expect(secRunner.repeaterId).toBeUndefined(); }); diff --git a/packages/runner/src/lib/SecRunner.ts b/packages/runner/src/lib/SecRunner.ts index 20fc4938..d5734d05 100644 --- a/packages/runner/src/lib/SecRunner.ts +++ b/packages/runner/src/lib/SecRunner.ts @@ -1,7 +1,11 @@ import { SecScanOptions } from './SecScanOptions'; import { SecScan } from './SecScan'; import { Configuration, ConfigurationOptions, Logger } from '@sectester/core'; -import { Repeater, RepeaterFactory } from '@sectester/repeater'; +import { + Repeater, + RepeaterFactory, + RepeatersManager +} from '@sectester/repeater'; import { ScanFactory } from '@sectester/scan'; import { Formatter, PlainTextFormatter } from '@sectester/reporter'; @@ -15,6 +19,7 @@ export class SecRunner { private readonly logger: Logger; private repeater: Repeater | undefined; private repeaterFactory: RepeaterFactory | undefined; + private repeatersManager: RepeatersManager | undefined; get repeaterId(): string | undefined { return this.repeater?.repeaterId; @@ -27,12 +32,14 @@ export class SecRunner { } public async init(): Promise { - if (this.repeaterFactory) { + if (this.repeatersManager && this.repeaterFactory) { throw new Error('Already initialized.'); } await this.initConfiguration(this.configuration); + this.repeatersManager = + this.configuration.container.resolve(RepeatersManager); this.repeaterFactory = this.configuration.container.resolve(RepeaterFactory); @@ -45,12 +52,14 @@ export class SecRunner { public async clear(): Promise { try { - if (this.repeater) { + if (this.repeater && this.repeatersManager) { await this.repeater.stop(); + await this.repeatersManager.deleteRepeater(this.repeater.repeaterId); } } finally { this.removeShutdownHandler(); delete this.repeater; + delete this.repeatersManager; delete this.repeaterFactory; } } From b738876a2b41c12d0316820044bd738eacebbf68 Mon Sep 17 00:00:00 2001 From: Dmitry Ostrikov Date: Tue, 18 Jun 2024 19:34:36 +0700 Subject: [PATCH 11/17] feat(repeater): eliminate 'Bus' term misuse closes #196 --- .../src/bus/DefaultRepeaterBusFactory.spec.ts | 50 ------- .../src/bus/DefaultRepeaterBusFactory.ts | 35 ----- packages/repeater/src/bus/RepeaterBus.ts | 6 - .../repeater/src/bus/RepeaterBusFactory.ts | 7 - packages/repeater/src/bus/index.ts | 7 - .../DefaultRepeater.spec.ts} | 127 +++++++++++++----- .../DefaultRepeater.ts} | 52 +++++-- .../DefaultRepeaterCommands.spec.ts | 0 .../{bus => lib}/DefaultRepeaterCommands.ts | 0 .../DefaultRepeaterServer.spec.ts | 0 .../src/{bus => lib}/DefaultRepeaterServer.ts | 0 packages/repeater/src/lib/Repeater.spec.ts | 116 ---------------- packages/repeater/src/lib/Repeater.ts | 47 +------ .../src/{bus => lib}/RepeaterCommands.ts | 0 .../repeater/src/lib/RepeaterFactory.spec.ts | 24 +--- packages/repeater/src/lib/RepeaterFactory.ts | 6 +- .../src/{bus => lib}/RepeaterServer.ts | 0 packages/repeater/src/lib/index.ts | 14 +- packages/repeater/src/register.ts | 13 +- 19 files changed, 163 insertions(+), 341 deletions(-) delete mode 100644 packages/repeater/src/bus/DefaultRepeaterBusFactory.spec.ts delete mode 100644 packages/repeater/src/bus/DefaultRepeaterBusFactory.ts delete mode 100644 packages/repeater/src/bus/RepeaterBus.ts delete mode 100644 packages/repeater/src/bus/RepeaterBusFactory.ts delete mode 100644 packages/repeater/src/bus/index.ts rename packages/repeater/src/{bus/DefaultRepeaterBus.spec.ts => lib/DefaultRepeater.spec.ts} (69%) rename packages/repeater/src/{bus/DefaultRepeaterBus.ts => lib/DefaultRepeater.ts} (78%) rename packages/repeater/src/{bus => lib}/DefaultRepeaterCommands.spec.ts (100%) rename packages/repeater/src/{bus => lib}/DefaultRepeaterCommands.ts (100%) rename packages/repeater/src/{bus => lib}/DefaultRepeaterServer.spec.ts (100%) rename packages/repeater/src/{bus => lib}/DefaultRepeaterServer.ts (100%) delete mode 100644 packages/repeater/src/lib/Repeater.spec.ts rename packages/repeater/src/{bus => lib}/RepeaterCommands.ts (100%) rename packages/repeater/src/{bus => lib}/RepeaterServer.ts (100%) diff --git a/packages/repeater/src/bus/DefaultRepeaterBusFactory.spec.ts b/packages/repeater/src/bus/DefaultRepeaterBusFactory.spec.ts deleted file mode 100644 index 98659218..00000000 --- a/packages/repeater/src/bus/DefaultRepeaterBusFactory.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { DefaultRepeaterBusFactory } from './DefaultRepeaterBusFactory'; -import { RepeaterServer } from './RepeaterServer'; -import { RepeaterCommands } from './RepeaterCommands'; -import { DefaultRepeaterBus } from './DefaultRepeaterBus'; -import { Configuration, Logger } from '@sectester/core'; -import { instance, mock, reset } from 'ts-mockito'; - -describe('DefaultRepeaterBusFactory', () => { - const repeaterId = 'fooId'; - const mockedLogger = mock(); - const mockedConfiguration = mock(); - const mockedRepeaterServer = mock(); - const mockedRepeaterCommandHub = mock(); - - const configuration = instance(mockedConfiguration); - - let sut!: DefaultRepeaterBusFactory; - - beforeEach(() => { - sut = new DefaultRepeaterBusFactory( - repeaterId, - instance(mockedLogger), - configuration, - instance(mockedRepeaterServer), - instance(mockedRepeaterCommandHub) - ); - }); - - afterEach(() => { - reset( - mockedLogger, - mockedConfiguration, - mockedRepeaterServer, - mockedRepeaterCommandHub - ); - }); - - describe('create', () => { - it('should create', () => { - // act - const res = sut.create(); - - // assert - expect(res).toBeInstanceOf(DefaultRepeaterBus); - expect(res).toMatchObject({ - repeaterId - }); - }); - }); -}); diff --git a/packages/repeater/src/bus/DefaultRepeaterBusFactory.ts b/packages/repeater/src/bus/DefaultRepeaterBusFactory.ts deleted file mode 100644 index 75dfd6db..00000000 --- a/packages/repeater/src/bus/DefaultRepeaterBusFactory.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { RepeaterBus } from './RepeaterBus'; -import { DefaultRepeaterBus } from './DefaultRepeaterBus'; -import { RepeaterBusFactory } from './RepeaterBusFactory'; -import { RepeaterCommands } from './RepeaterCommands'; -import { RepeaterServer } from './RepeaterServer'; -import { RepeaterId } from '../lib/Repeater'; -import { Configuration, Logger } from '@sectester/core'; -import { inject, injectable } from 'tsyringe'; - -@injectable() -export class DefaultRepeaterBusFactory implements RepeaterBusFactory { - constructor( - @inject(RepeaterId) - private readonly repeaterId: RepeaterId, - private readonly logger: Logger, - private readonly configuration: Configuration, - @inject(RepeaterServer) private readonly repeaterServer: RepeaterServer, - @inject(RepeaterCommands) - private readonly repeaterCommands: RepeaterCommands - ) {} - - public create(): RepeaterBus { - this.logger.log( - 'Creating the repeater (%s)...', - this.configuration.version - ); - - return new DefaultRepeaterBus( - this.repeaterId, - this.logger, - this.repeaterServer, - this.repeaterCommands - ); - } -} diff --git a/packages/repeater/src/bus/RepeaterBus.ts b/packages/repeater/src/bus/RepeaterBus.ts deleted file mode 100644 index a3fd758f..00000000 --- a/packages/repeater/src/bus/RepeaterBus.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface RepeaterBus { - connect(): Promise; - close(): Promise; -} - -export const RepeaterBus: unique symbol = Symbol('RepeaterBus'); diff --git a/packages/repeater/src/bus/RepeaterBusFactory.ts b/packages/repeater/src/bus/RepeaterBusFactory.ts deleted file mode 100644 index a470caa6..00000000 --- a/packages/repeater/src/bus/RepeaterBusFactory.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { RepeaterBus } from './RepeaterBus'; - -export interface RepeaterBusFactory { - create(): RepeaterBus; -} - -export const RepeaterBusFactory: unique symbol = Symbol('RepeaterBusFactory'); diff --git a/packages/repeater/src/bus/index.ts b/packages/repeater/src/bus/index.ts deleted file mode 100644 index 79611d29..00000000 --- a/packages/repeater/src/bus/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from './DefaultRepeaterBusFactory'; -export * from './DefaultRepeaterCommands'; -export * from './DefaultRepeaterServer'; -export * from './RepeaterBus'; -export * from './RepeaterBusFactory'; -export * from './RepeaterCommands'; -export * from './RepeaterServer'; diff --git a/packages/repeater/src/bus/DefaultRepeaterBus.spec.ts b/packages/repeater/src/lib/DefaultRepeater.spec.ts similarity index 69% rename from packages/repeater/src/bus/DefaultRepeaterBus.spec.ts rename to packages/repeater/src/lib/DefaultRepeater.spec.ts index 1664e385..2ff5cec5 100644 --- a/packages/repeater/src/bus/DefaultRepeaterBus.spec.ts +++ b/packages/repeater/src/lib/DefaultRepeater.spec.ts @@ -1,4 +1,5 @@ -import { DefaultRepeaterBus } from './DefaultRepeaterBus'; +import { RunningStatus } from './Repeater'; +import { DefaultRepeater } from './DefaultRepeater'; import { Protocol } from '../models/Protocol'; import { Request, Response } from '../request-runner'; import { @@ -20,11 +21,11 @@ import { } from 'ts-mockito'; import { EventEmitter } from 'events'; -describe('DefaultRepeaterBus', () => { +describe('DefaultRepeater', () => { const RepeaterId = 'fooId'; let events!: EventEmitter; - let sut!: DefaultRepeaterBus; + let sut!: DefaultRepeater; const mockedRepeaterServer = mock(); const repeaterCommands = mock(); @@ -48,7 +49,7 @@ describe('DefaultRepeaterBus', () => { repeaterId: RepeaterId }); - sut = new DefaultRepeaterBus( + sut = new DefaultRepeater( RepeaterId, instance(mockedLogger), instance(mockedRepeaterServer), @@ -64,10 +65,10 @@ describe('DefaultRepeaterBus', () => { ) ); - describe('connect', () => { - it('should connect', async () => { + describe('start', () => { + it('should start', async () => { // act - await sut.connect(); + await sut.start(); // assert verify(mockedRepeaterServer.connect()).once(); @@ -78,23 +79,12 @@ describe('DefaultRepeaterBus', () => { ).once(); }); - it('should allow connect more than once', async () => { - // arrange - await sut.connect(); - - // act - const act = sut.connect(); - - // assert - await expect(act).resolves.not.toThrow(); - }); - it('should throw when underlying connect throws', async () => { // arrange when(mockedRepeaterServer.connect()).thenReject(new Error('foo')); // act - const act = () => sut.connect(); + const act = () => sut.start(); // assert await expect(act).rejects.toThrowError('foo'); @@ -107,27 +97,104 @@ describe('DefaultRepeaterBus', () => { ); // act - const act = () => sut.connect(); + const act = () => sut.start(); // assert await expect(act).rejects.toThrowError('foo'); }); + + it('should have RunningStatus.STARTING just after start() call', () => { + // act + void sut.start(); + + // assert + expect(sut.runningStatus).toBe(RunningStatus.STARTING); + }); + + it('should have RunningStatus.RUNNING after successful start()', async () => { + // act + await sut.start(); + + // assert + expect(sut.runningStatus).toBe(RunningStatus.RUNNING); + }); + + it('should throw an error on start() twice', async () => { + // arrange + await sut.start(); + + // act + const res = sut.start(); + + // assert + await expect(res).rejects.toThrow('Repeater is already active.'); + }); + + it('should be possible to start() after start() error', async () => { + // act + when(mockedRepeaterServer.connect()).thenReject().thenResolve(); + + // assert + await expect(sut.start()).rejects.toThrow(); + await expect(sut.start()).resolves.not.toThrow(); + }); }); - describe('close', () => { - it('should close', async () => { + describe('stop', () => { + it('should stop', async () => { + // arrange + await sut.start(); + // act - await sut.close(); + await sut.stop(); // assert verify(mockedRepeaterServer.disconnect()).once(); }); + + it('should have RunningStatus.OFF after start() and stop()', async () => { + // arrange + await sut.start(); + + // act + await sut.stop(); + + // assert + expect(sut.runningStatus).toBe(RunningStatus.OFF); + }); + + it('should do nothing on stop() without start()', async () => { + // act + await sut.stop(); + + // assert + expect(sut.runningStatus).toBe(RunningStatus.OFF); + }); + + it('should do nothing on second stop() call', async () => { + // arrange + await sut.start(); + await sut.stop(); + + // assert + await sut.stop(); + + // assert + expect(sut.runningStatus).toBe(RunningStatus.OFF); + }); + }); + + describe('runningStatus', () => { + it('should have RunningStatus.OFF initially', () => { + // assert + expect(sut.runningStatus).toBe(RunningStatus.OFF); + }); }); describe('events', () => { it(`should subscribe to ${RepeaterServerEvents.UPDATE_AVAILABLE}`, async () => { // arrange - await sut.connect(); + await sut.start(); // act events.emit(RepeaterServerEvents.UPDATE_AVAILABLE, { version: '1.0.0' }); @@ -159,7 +226,7 @@ describe('DefaultRepeaterBus', () => { }) ); - await sut.connect(); + await sut.start(); // act events.emit(RepeaterServerEvents.REQUEST, requestEvent); @@ -171,7 +238,7 @@ describe('DefaultRepeaterBus', () => { it(`should subscribe to ${RepeaterServerEvents.RECONNECT_ATTEMPT}`, async () => { // arrange - await sut.connect(); + await sut.start(); // act events.emit(RepeaterServerEvents.RECONNECT_ATTEMPT, { @@ -191,7 +258,7 @@ describe('DefaultRepeaterBus', () => { it(`should subscribe to ${RepeaterServerEvents.ERROR} and proceed on error`, async () => { // arrange - await sut.connect(); + await sut.start(); // act events.emit(RepeaterServerEvents.ERROR, { @@ -205,7 +272,7 @@ describe('DefaultRepeaterBus', () => { it(`should subscribe to ${RepeaterServerEvents.ERROR} and proceed on critical error`, async () => { // arrange - await sut.connect(); + await sut.start(); // act events.emit(RepeaterServerEvents.ERROR, { @@ -229,7 +296,7 @@ describe('DefaultRepeaterBus', () => { it(`should subscribe to ${RepeaterServerEvents.RECONNECTION_FAILED}`, async () => { // arrange const error = new Error('test error'); - await sut.connect(); + await sut.start(); // act events.emit(RepeaterServerEvents.RECONNECTION_FAILED, { @@ -243,7 +310,7 @@ describe('DefaultRepeaterBus', () => { it(`should subscribe to ${RepeaterServerEvents.RECONNECTION_SUCCEEDED}`, async () => { // arrange - await sut.connect(); + await sut.start(); // act events.emit(RepeaterServerEvents.RECONNECTION_SUCCEEDED); diff --git a/packages/repeater/src/bus/DefaultRepeaterBus.ts b/packages/repeater/src/lib/DefaultRepeater.ts similarity index 78% rename from packages/repeater/src/bus/DefaultRepeaterBus.ts rename to packages/repeater/src/lib/DefaultRepeater.ts index d62614f0..d2a42fe0 100644 --- a/packages/repeater/src/bus/DefaultRepeaterBus.ts +++ b/packages/repeater/src/lib/DefaultRepeater.ts @@ -1,4 +1,4 @@ -import { RepeaterBus } from './RepeaterBus'; +import { Repeater, RepeaterId, RunningStatus } from './Repeater'; import { RepeaterServer, RepeaterErrorCodes, @@ -9,37 +9,61 @@ import { RepeaterServerRequestEvent, RepeaterUpgradeAvailableEvent } from './RepeaterServer'; -import { RepeaterId } from '../lib/Repeater'; import { RepeaterCommands } from './RepeaterCommands'; import { Request } from '../request-runner/Request'; import { Logger } from '@sectester/core'; import chalk from 'chalk'; +import { inject, injectable, Lifecycle, scoped } from 'tsyringe'; -export class DefaultRepeaterBus implements RepeaterBus { - private repeaterRunning: boolean = false; +@scoped(Lifecycle.ContainerScoped) +@injectable() +export class DefaultRepeater implements Repeater { + private _runningStatus = RunningStatus.OFF; + + get runningStatus(): RunningStatus { + return this._runningStatus; + } constructor( - private readonly repeaterId: RepeaterId, + @inject(RepeaterId) + public readonly repeaterId: RepeaterId, private readonly logger: Logger, + @inject(RepeaterServer) private readonly repeaterServer: RepeaterServer, + @inject(RepeaterCommands) private readonly repeaterCommands: RepeaterCommands ) {} - public close() { - this.repeaterRunning = false; + public async start(): Promise { + if (this.runningStatus !== RunningStatus.OFF) { + throw new Error('Repeater is already active.'); + } + + this._runningStatus = RunningStatus.STARTING; - this.repeaterServer.disconnect(); + try { + await this.connect(); - return Promise.resolve(); + this._runningStatus = RunningStatus.RUNNING; + } catch (e) { + this._runningStatus = RunningStatus.OFF; + throw e; + } } - public async connect(): Promise { - if (this.repeaterRunning) { + public async stop(): Promise { + if (this.runningStatus !== RunningStatus.RUNNING) { return; } - this.repeaterRunning = true; + this._runningStatus = RunningStatus.OFF; + + this.repeaterServer.disconnect(); + + return Promise.resolve(); + } + private async connect(): Promise { this.logger.log('Connecting the Bridges'); this.subscribeDiagnosticEvents(); @@ -123,7 +147,7 @@ export class DefaultRepeaterBus implements RepeaterBus { message, remediation ); - this.close().catch(this.logger.error); + this.stop().catch(this.logger.error); } private upgradeAvailable = (event: RepeaterUpgradeAvailableEvent) => { @@ -149,7 +173,7 @@ export class DefaultRepeaterBus implements RepeaterBus { error }: RepeaterServerReconnectionFailedEvent) => { this.logger.error(error.message); - this.close().catch(this.logger.error); + this.stop().catch(this.logger.error); }; private requestReceived = async (event: RepeaterServerRequestEvent) => { diff --git a/packages/repeater/src/bus/DefaultRepeaterCommands.spec.ts b/packages/repeater/src/lib/DefaultRepeaterCommands.spec.ts similarity index 100% rename from packages/repeater/src/bus/DefaultRepeaterCommands.spec.ts rename to packages/repeater/src/lib/DefaultRepeaterCommands.spec.ts diff --git a/packages/repeater/src/bus/DefaultRepeaterCommands.ts b/packages/repeater/src/lib/DefaultRepeaterCommands.ts similarity index 100% rename from packages/repeater/src/bus/DefaultRepeaterCommands.ts rename to packages/repeater/src/lib/DefaultRepeaterCommands.ts diff --git a/packages/repeater/src/bus/DefaultRepeaterServer.spec.ts b/packages/repeater/src/lib/DefaultRepeaterServer.spec.ts similarity index 100% rename from packages/repeater/src/bus/DefaultRepeaterServer.spec.ts rename to packages/repeater/src/lib/DefaultRepeaterServer.spec.ts diff --git a/packages/repeater/src/bus/DefaultRepeaterServer.ts b/packages/repeater/src/lib/DefaultRepeaterServer.ts similarity index 100% rename from packages/repeater/src/bus/DefaultRepeaterServer.ts rename to packages/repeater/src/lib/DefaultRepeaterServer.ts diff --git a/packages/repeater/src/lib/Repeater.spec.ts b/packages/repeater/src/lib/Repeater.spec.ts deleted file mode 100644 index 41d73773..00000000 --- a/packages/repeater/src/lib/Repeater.spec.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { Repeater, RunningStatus } from './Repeater'; -import { RepeaterBus } from '../bus'; -import { instance, mock, reset, verify, when } from 'ts-mockito'; - -describe('Repeater', () => { - const repeaterId = 'fooId'; - - let repeater!: Repeater; - const mockedRepeaterBus = mock(); - - const createRepeater = () => - new Repeater(repeaterId, instance(mockedRepeaterBus)); - - beforeEach(() => { - repeater = createRepeater(); - }); - - afterEach(() => reset(mockedRepeaterBus)); - - describe('start', () => { - it('should start', async () => { - // act - await repeater.start(); - - // assert - verify(mockedRepeaterBus.connect()).once(); - }); - - it('should have RunningStatus.STARTING just after start() call', () => { - // act - void repeater.start(); - - // assert - expect(repeater.runningStatus).toBe(RunningStatus.STARTING); - }); - - it('should have RunningStatus.RUNNING after successful start()', async () => { - // act - await repeater.start(); - - // assert - expect(repeater.runningStatus).toBe(RunningStatus.RUNNING); - }); - - it('should throw an error on start() twice', async () => { - // arrange - await repeater.start(); - - // act - const res = repeater.start(); - - // assert - await expect(res).rejects.toThrow('Repeater is already active.'); - }); - - it('should be possible to start() after start() error', async () => { - // act - when(mockedRepeaterBus.connect()).thenReject().thenResolve(); - - // assert - await expect(repeater.start()).rejects.toThrow(); - await expect(repeater.start()).resolves.not.toThrow(); - }); - }); - - describe('stop', () => { - it('should stop', async () => { - // arrange - await repeater.start(); - - // act - await repeater.stop(); - - // assert - verify(mockedRepeaterBus.close()).once(); - }); - - it('should have RunningStatus.OFF after start() and stop()', async () => { - // arrange - await repeater.start(); - - // act - await repeater.stop(); - - // assert - expect(repeater.runningStatus).toBe(RunningStatus.OFF); - }); - - it('should do nothing on stop() without start()', async () => { - // act - await repeater.stop(); - - // assert - expect(repeater.runningStatus).toBe(RunningStatus.OFF); - }); - - it('should do nothing on second stop() call', async () => { - // arrange - await repeater.start(); - await repeater.stop(); - - // assert - await repeater.stop(); - - // assert - expect(repeater.runningStatus).toBe(RunningStatus.OFF); - }); - }); - - describe('runningStatus', () => { - it('should have RunningStatus.OFF initially', () => { - // assert - expect(repeater.runningStatus).toBe(RunningStatus.OFF); - }); - }); -}); diff --git a/packages/repeater/src/lib/Repeater.ts b/packages/repeater/src/lib/Repeater.ts index eed8bd3c..a497be0f 100644 --- a/packages/repeater/src/lib/Repeater.ts +++ b/packages/repeater/src/lib/Repeater.ts @@ -1,5 +1,3 @@ -import { RepeaterBus } from '../bus'; - export enum RunningStatus { OFF, STARTING, @@ -9,42 +7,11 @@ export enum RunningStatus { export type RepeaterId = string; export const RepeaterId = Symbol('RepeaterId'); -export class Repeater { - private _runningStatus = RunningStatus.OFF; - - get runningStatus(): RunningStatus { - return this._runningStatus; - } - - constructor( - public readonly repeaterId: RepeaterId, - private readonly bus: RepeaterBus - ) {} - - public async start(): Promise { - if (this.runningStatus !== RunningStatus.OFF) { - throw new Error('Repeater is already active.'); - } - - this._runningStatus = RunningStatus.STARTING; - - try { - await this.bus.connect(); - - this._runningStatus = RunningStatus.RUNNING; - } catch (e) { - this._runningStatus = RunningStatus.OFF; - throw e; - } - } - - public async stop(): Promise { - if (this.runningStatus !== RunningStatus.RUNNING) { - return; - } - - this._runningStatus = RunningStatus.OFF; - - await this.bus.close(); - } +export interface Repeater { + readonly repeaterId: RepeaterId; + readonly runningStatus: RunningStatus; + start(): Promise; + stop(): Promise; } + +export const Repeater: unique symbol = Symbol('Repeater'); diff --git a/packages/repeater/src/bus/RepeaterCommands.ts b/packages/repeater/src/lib/RepeaterCommands.ts similarity index 100% rename from packages/repeater/src/bus/RepeaterCommands.ts rename to packages/repeater/src/lib/RepeaterCommands.ts diff --git a/packages/repeater/src/lib/RepeaterFactory.spec.ts b/packages/repeater/src/lib/RepeaterFactory.spec.ts index db194575..19a51435 100644 --- a/packages/repeater/src/lib/RepeaterFactory.spec.ts +++ b/packages/repeater/src/lib/RepeaterFactory.spec.ts @@ -1,6 +1,4 @@ import { RepeaterFactory } from './RepeaterFactory'; -import { RepeaterBus } from '../bus/RepeaterBus'; -import { RepeaterBusFactory } from '../bus/RepeaterBusFactory'; import { HttpRequestRunner, RequestRunner, @@ -49,8 +47,6 @@ describe('RepeaterFactory', () => { const mockedContainer = mock(); const mockedChildContainer = mock(); const mockedConfiguration = mock(); - const mockedRepeaterBus = mock(); - const mockedRepeaterBusFactory = mock(); const mockedRepeaterManager = mock(); const configuration = instance(mockedConfiguration); @@ -64,32 +60,20 @@ describe('RepeaterFactory', () => { instance(mockedChildContainer) ); - when( - mockedChildContainer.resolve(RepeaterBusFactory) - ).thenReturn(instance(mockedRepeaterBusFactory)); - when( mockedContainer.resolve(RepeatersManager) ).thenReturn(instance(mockedRepeaterManager)); - when(mockedRepeaterBusFactory.create()).thenReturn( - instance(mockedRepeaterBus) - ); - when(mockedRepeaterManager.createRepeater(anything())).thenResolve({ repeaterId }); }); afterEach(() => { - reset< - DependencyContainer | Configuration | RepeaterBus | RepeaterBusFactory - >( + reset( mockedContainer, mockedChildContainer, - mockedConfiguration, - mockedRepeaterBus, - mockedRepeaterBusFactory + mockedConfiguration ); }); @@ -99,10 +83,10 @@ describe('RepeaterFactory', () => { const factory = new RepeaterFactory(configuration); // act - const res = await factory.createRepeater(); + await factory.createRepeater(); // assert - expect(res).toBeInstanceOf(Repeater); + verify(mockedChildContainer.resolve(Repeater)).once(); }); it('should register custom request runner options', async () => { diff --git a/packages/repeater/src/lib/RepeaterFactory.ts b/packages/repeater/src/lib/RepeaterFactory.ts index 4d9d9915..d081ccf6 100644 --- a/packages/repeater/src/lib/RepeaterFactory.ts +++ b/packages/repeater/src/lib/RepeaterFactory.ts @@ -2,7 +2,6 @@ import { Repeater, RepeaterId } from './Repeater'; import { RequestRunner, RequestRunnerOptions } from '../request-runner'; import { RepeatersManager } from '../api'; import { RepeaterOptions } from './RepeaterOptions'; -import { RepeaterBusFactory } from '../bus/RepeaterBusFactory'; import { Configuration } from '@sectester/core'; import { v4 as uuidv4 } from 'uuid'; import { DependencyContainer, injectable, Lifecycle } from 'tsyringe'; @@ -46,10 +45,7 @@ export class RepeaterFactory { this.registerRequestRunnerOptions(container, requestRunnerOptions); this.registerRequestRunners(container, requestRunners ?? []); - const busFactory = - container.resolve(RepeaterBusFactory); - - return new Repeater(repeaterId, busFactory.create()); + return container.resolve(Repeater); } private registerRequestRunners( diff --git a/packages/repeater/src/bus/RepeaterServer.ts b/packages/repeater/src/lib/RepeaterServer.ts similarity index 100% rename from packages/repeater/src/bus/RepeaterServer.ts rename to packages/repeater/src/lib/RepeaterServer.ts diff --git a/packages/repeater/src/lib/index.ts b/packages/repeater/src/lib/index.ts index 4d4794bb..5e98fa4f 100644 --- a/packages/repeater/src/lib/index.ts +++ b/packages/repeater/src/lib/index.ts @@ -1,5 +1,9 @@ -export { Repeater, RunningStatus, RepeaterId } from './Repeater'; -export { RepeaterFactory } from './RepeaterFactory'; -export { RepeaterOptions } from './RepeaterOptions'; -export { RepeaterRequestRunnerOptions } from './RepeaterRequestRunnerOptions'; -export { RepeaterStatus } from '../models/RepeaterStatus'; +export * from './Repeater'; +export * from './RepeaterFactory'; +export * from './RepeaterOptions'; +export * from './RepeaterRequestRunnerOptions'; +export * from './DefaultRepeater'; +export * from './DefaultRepeaterCommands'; +export * from './DefaultRepeaterServer'; +export * from './RepeaterCommands'; +export * from './RepeaterServer'; diff --git a/packages/repeater/src/register.ts b/packages/repeater/src/register.ts index 315af601..5a4009e5 100644 --- a/packages/repeater/src/register.ts +++ b/packages/repeater/src/register.ts @@ -1,13 +1,14 @@ -import { RepeaterFactory, RepeaterId } from './lib'; import { - DefaultRepeaterBusFactory, + RepeaterFactory, + RepeaterId, DefaultRepeaterCommands, DefaultRepeaterServer, + DefaultRepeater, DefaultRepeaterServerOptions, - RepeaterBusFactory, RepeaterCommands, - RepeaterServer -} from './bus'; + RepeaterServer, + Repeater +} from './lib'; import { HttpRequestRunner, RequestRunner, @@ -113,8 +114,8 @@ container.register(DefaultRepeaterServerOptions, { } }); +container.register(Repeater, { useClass: DefaultRepeater }); container.register(ProxyFactory, { useClass: DefaultProxyFactory }); container.register(RepeaterServer, { useClass: DefaultRepeaterServer }); container.register(RepeaterCommands, { useClass: DefaultRepeaterCommands }); -container.register(RepeaterBusFactory, { useClass: DefaultRepeaterBusFactory }); container.register(RepeatersManager, { useClass: DefaultRepeatersManager }); From ed80f20af15a9aa074531214e7a4ea01f7956c2f Mon Sep 17 00:00:00 2001 From: Dmitry Ostrikov Date: Tue, 18 Jun 2024 20:11:06 +0700 Subject: [PATCH 12/17] feat(repeater): update README closes #196 --- packages/repeater/README.md | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/packages/repeater/README.md b/packages/repeater/README.md index cd529543..8406da8a 100644 --- a/packages/repeater/README.md +++ b/packages/repeater/README.md @@ -95,33 +95,6 @@ export interface RequestRunnerOptions { } ``` -The `RepeaterFactory` also provides a method to create a `Repeater` instance using an existing repeater ID. You can use the `createRepeaterFromExisting` method to accomplish this: - -```ts -const existingRepeaterId = ''; -const repeater = await repeaterFactory.createRepeaterFromExisting( - existingRepeaterId -); -``` - -This method retrieves the existing repeater's details from the cloud using its ID and returns a `Repeater` instance associated with the specified ID. - -You can also customize the request runner options for the existing repeater by passing them as options: - -```ts -const existingRepeaterId = ''; -const repeater = await repeaterFactory.createRepeaterFromExisting( - existingRepeaterId, - { - requestRunnerOptions: { - timeout: 10000, - maxContentLength: 200, - allowedMimes: ['text/html'] - } - } -); -``` - The `Repeater` instance provides the `start` method. This method is required to establish a connection with the Bright cloud engine and interact with other services. ```ts From f14760cc3aaec4a2c1c7a3720ecbbe3fb24e92ef Mon Sep 17 00:00:00 2001 From: Dmitry Ostrikov Date: Tue, 18 Jun 2024 20:53:43 +0700 Subject: [PATCH 13/17] feat(repeater): update README closes #196 --- packages/repeater/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/repeater/README.md b/packages/repeater/README.md index 8406da8a..4b26d981 100644 --- a/packages/repeater/README.md +++ b/packages/repeater/README.md @@ -142,8 +142,8 @@ describe('Scan', () => { ### Implementation details -Under the hood `Repeater` register `ExecuteRequestEventHandler` in bus, -which in turn uses the `RequestRunner` to proceed with request: +Under the hood `Repeater` connects to the Bright engine using web socket protocol, then listens for incoming commands from the engine. +Which in turn get executed with the `RequestRunner` to proceed with the request coming from the engine: ```ts export interface RequestRunner { @@ -152,7 +152,7 @@ export interface RequestRunner { } ``` -Package contains `RequestRunner` implementations for both HTTP and WS protocols. +Package contains `RequestRunner` implementations for HTTP protocol only. To support other protocol new class implementation of `RequestRunner` should be registered in global IoC container: ```ts From cfb4b11c5b958c21c1c35cf3c79e10d65c8372b4 Mon Sep 17 00:00:00 2001 From: Dmitry Ostrikov Date: Wed, 19 Jun 2024 16:37:35 +0700 Subject: [PATCH 14/17] feat(repeater): address pr comments closes #196 --- package-lock.json | 43 +-- package.json | 5 +- packages/repeater/README.md | 31 +- .../src/api/DefaultRepeatersManager.spec.ts | 32 +- .../src/api/DefaultRepeatersManager.ts | 21 +- packages/repeater/src/api/RepeatersManager.ts | 2 + .../src/api/commands/GetRepeaterRequest.ts | 20 ++ packages/repeater/src/api/commands/index.ts | 1 + .../repeater/src/lib/DefaultRepeater.spec.ts | 324 ------------------ packages/repeater/src/lib/DefaultRepeater.ts | 204 ----------- .../repeater/src/lib/DefaultRepeaterServer.ts | 2 +- packages/repeater/src/lib/Repeater.spec.ts | 186 ++++++++++ packages/repeater/src/lib/Repeater.ts | 209 ++++++++++- .../repeater/src/lib/RepeaterFactory.spec.ts | 77 +++++ packages/repeater/src/lib/RepeaterFactory.ts | 23 +- packages/repeater/src/lib/index.ts | 1 - packages/repeater/src/register.ts | 5 +- 17 files changed, 600 insertions(+), 586 deletions(-) create mode 100644 packages/repeater/src/api/commands/GetRepeaterRequest.ts delete mode 100644 packages/repeater/src/lib/DefaultRepeater.spec.ts delete mode 100644 packages/repeater/src/lib/DefaultRepeater.ts create mode 100644 packages/repeater/src/lib/Repeater.spec.ts diff --git a/package-lock.json b/package-lock.json index 76c4a524..39cee224 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,8 +33,7 @@ "tslib": "~2.3.1", "tsyringe": "^4.6.0", "tty-table": "^4.1.5", - "uuid": "^8.3.2", - "ws": "^8.17.1" + "uuid": "^8.3.2" }, "devDependencies": { "@commitlint/cli": "^17.0.3", @@ -56,7 +55,7 @@ "@types/request-promise": "^4.1.48", "@types/semver": "^7.3.9", "@types/uuid": "^8.3.4", - "@types/ws": "^8.5.3", + "@types/ws": "^8.5.10", "@typescript-eslint/eslint-plugin": "5.33.1", "@typescript-eslint/parser": "5.33.1", "eslint": "8.15.0", @@ -2675,9 +2674,9 @@ "dev": true }, "node_modules/@types/ws": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz", - "integrity": "sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w==", + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", + "integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==", "dev": true, "dependencies": { "@types/node": "*" @@ -15104,26 +15103,6 @@ "typedarray-to-buffer": "^3.1.5" } }, - "node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/xml-name-validator": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", @@ -17450,9 +17429,9 @@ "dev": true }, "@types/ws": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz", - "integrity": "sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w==", + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", + "integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==", "dev": true, "requires": { "@types/node": "*" @@ -26629,12 +26608,6 @@ "typedarray-to-buffer": "^3.1.5" } }, - "ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "requires": {} - }, "xml-name-validator": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", diff --git a/package.json b/package.json index b42d8051..41f0fa94 100644 --- a/package.json +++ b/package.json @@ -96,8 +96,7 @@ "tslib": "~2.3.1", "tsyringe": "^4.6.0", "tty-table": "^4.1.5", - "uuid": "^8.3.2", - "ws": "^8.17.1" + "uuid": "^8.3.2" }, "devDependencies": { "@commitlint/cli": "^17.0.3", @@ -119,7 +118,7 @@ "@types/request-promise": "^4.1.48", "@types/semver": "^7.3.9", "@types/uuid": "^8.3.4", - "@types/ws": "^8.5.3", + "@types/ws": "^8.5.10", "@typescript-eslint/eslint-plugin": "5.33.1", "@typescript-eslint/parser": "5.33.1", "eslint": "8.15.0", diff --git a/packages/repeater/README.md b/packages/repeater/README.md index 4b26d981..79cf4e27 100644 --- a/packages/repeater/README.md +++ b/packages/repeater/README.md @@ -95,6 +95,33 @@ export interface RequestRunnerOptions { } ``` +The `RepeaterFactory` also provides a method to create a `Repeater` instance using an existing repeater ID. You can use the `createRepeaterFromExisting` method to accomplish this: + +```ts +const existingRepeaterId = ''; +const repeater = await repeaterFactory.createRepeaterFromExisting( + existingRepeaterId +); +``` + +This method retrieves the existing repeater's details from the cloud using its ID and returns a `Repeater` instance associated with the specified ID. + +You can also customize the request runner options for the existing repeater by passing them as options: + +```ts +const existingRepeaterId = ''; +const repeater = await repeaterFactory.createRepeaterFromExisting( + existingRepeaterId, + { + requestRunnerOptions: { + timeout: 10000, + maxContentLength: 200, + allowedMimes: ['text/html'] + } + } +); +``` + The `Repeater` instance provides the `start` method. This method is required to establish a connection with the Bright cloud engine and interact with other services. ```ts @@ -142,8 +169,8 @@ describe('Scan', () => { ### Implementation details -Under the hood `Repeater` connects to the Bright engine using web socket protocol, then listens for incoming commands from the engine. -Which in turn get executed with the `RequestRunner` to proceed with the request coming from the engine: +Under the hood, `Repeater` connects to the Bright engine using the WebSocket protocol and then listens for incoming commands from the engine. +These commands are executed by the `RequestRunner` to process the requests coming from the engine: ```ts export interface RequestRunner { diff --git a/packages/repeater/src/api/DefaultRepeatersManager.spec.ts b/packages/repeater/src/api/DefaultRepeatersManager.spec.ts index 6b2dcd0e..c2c449e6 100644 --- a/packages/repeater/src/api/DefaultRepeatersManager.spec.ts +++ b/packages/repeater/src/api/DefaultRepeatersManager.spec.ts @@ -1,5 +1,9 @@ import 'reflect-metadata'; -import { CreateRepeaterRequest, DeleteRepeaterRequest } from './commands'; +import { + CreateRepeaterRequest, + DeleteRepeaterRequest, + GetRepeaterRequest +} from './commands'; import { DefaultRepeatersManager } from './DefaultRepeatersManager'; import { RepeatersManager } from './RepeatersManager'; import { CommandDispatcher } from '@sectester/core'; @@ -63,6 +67,32 @@ describe('DefaultRepeatersManager', () => { }); }); + describe('getRepeater', () => { + it('should return repeater', async () => { + const repeaterId = '142'; + when( + mockedCommandDispatcher.execute(anyOfClass(GetRepeaterRequest)) + ).thenResolve({ id: repeaterId }); + + const result = await manager.getRepeater(repeaterId); + + verify( + mockedCommandDispatcher.execute(anyOfClass(GetRepeaterRequest)) + ).once(); + expect(result).toMatchObject({ repeaterId }); + }); + + it('should throw an error if cannot find repeater', async () => { + when( + mockedCommandDispatcher.execute(anyOfClass(GetRepeaterRequest)) + ).thenResolve(); + + const act = manager.getRepeater('123'); + + await expect(act).rejects.toThrow('Cannot find repeater'); + }); + }); + describe('deleteRepeater', () => { it('should remove repeater', async () => { when( diff --git a/packages/repeater/src/api/DefaultRepeatersManager.ts b/packages/repeater/src/api/DefaultRepeatersManager.ts index 630ec035..7b199b36 100644 --- a/packages/repeater/src/api/DefaultRepeatersManager.ts +++ b/packages/repeater/src/api/DefaultRepeatersManager.ts @@ -1,5 +1,9 @@ import { RepeatersManager } from './RepeatersManager'; -import { CreateRepeaterRequest, DeleteRepeaterRequest } from './commands'; +import { + CreateRepeaterRequest, + DeleteRepeaterRequest, + GetRepeaterRequest +} from './commands'; import { inject, injectable } from 'tsyringe'; import { CommandDispatcher } from '@sectester/core'; @@ -10,6 +14,20 @@ export class DefaultRepeatersManager implements RepeatersManager { private readonly commandDispatcher: CommandDispatcher ) {} + public async getRepeater( + repeaterId: string + ): Promise<{ repeaterId: string }> { + const repeater = await this.commandDispatcher.execute( + new GetRepeaterRequest(repeaterId) + ); + + if (!repeater?.id) { + throw new Error('Cannot find repeater'); + } + + return { repeaterId: repeater.id }; + } + public async createRepeater({ projectId, ...options @@ -24,7 +42,6 @@ export class DefaultRepeatersManager implements RepeatersManager { ...(projectId ? { projectIds: [projectId] } : {}) }) ); - if (!repeater?.id) { throw new Error('Cannot create a new repeater.'); } diff --git a/packages/repeater/src/api/RepeatersManager.ts b/packages/repeater/src/api/RepeatersManager.ts index c53bdd3e..845c5bc0 100644 --- a/packages/repeater/src/api/RepeatersManager.ts +++ b/packages/repeater/src/api/RepeatersManager.ts @@ -1,4 +1,6 @@ export interface RepeatersManager { + getRepeater(repeaterId: string): Promise<{ repeaterId: string }>; + createRepeater(options: { name: string; projectId?: string; diff --git a/packages/repeater/src/api/commands/GetRepeaterRequest.ts b/packages/repeater/src/api/commands/GetRepeaterRequest.ts new file mode 100644 index 00000000..05882ff5 --- /dev/null +++ b/packages/repeater/src/api/commands/GetRepeaterRequest.ts @@ -0,0 +1,20 @@ +import { HttpRequest } from '@sectester/bus'; + +export interface GetRepeaterResponsePayload { + id: string; + name: string; + projectIds: string[]; +} + +export class GetRepeaterRequest extends HttpRequest< + undefined, + GetRepeaterResponsePayload +> { + constructor(repeaterId: string) { + super({ + url: `/api/v1/repeaters/${repeaterId}`, + method: 'GET', + payload: undefined + }); + } +} diff --git a/packages/repeater/src/api/commands/index.ts b/packages/repeater/src/api/commands/index.ts index 3e70f8bb..e1bd9204 100644 --- a/packages/repeater/src/api/commands/index.ts +++ b/packages/repeater/src/api/commands/index.ts @@ -1,2 +1,3 @@ export { CreateRepeaterRequest } from './CreateRepeaterRequest'; export { DeleteRepeaterRequest } from './DeleteRepeaterRequest'; +export { GetRepeaterRequest } from './GetRepeaterRequest'; diff --git a/packages/repeater/src/lib/DefaultRepeater.spec.ts b/packages/repeater/src/lib/DefaultRepeater.spec.ts deleted file mode 100644 index 2ff5cec5..00000000 --- a/packages/repeater/src/lib/DefaultRepeater.spec.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { RunningStatus } from './Repeater'; -import { DefaultRepeater } from './DefaultRepeater'; -import { Protocol } from '../models/Protocol'; -import { Request, Response } from '../request-runner'; -import { - RepeaterErrorCodes, - RepeaterServer, - RepeaterServerEvents, - RepeaterServerRequestEvent -} from './RepeaterServer'; -import { RepeaterCommands } from './RepeaterCommands'; -import { delay, Logger } from '@sectester/core'; -import { - anything, - instance, - mock, - objectContaining, - reset, - verify, - when -} from 'ts-mockito'; -import { EventEmitter } from 'events'; - -describe('DefaultRepeater', () => { - const RepeaterId = 'fooId'; - - let events!: EventEmitter; - let sut!: DefaultRepeater; - - const mockedRepeaterServer = mock(); - const repeaterCommands = mock(); - const mockedLogger = mock(); - - beforeEach(() => { - events = new EventEmitter(); - when(mockedRepeaterServer.on(anything(), anything())).thenCall( - (event, handler) => { - events.on(event, handler); - } - ); - - when(mockedRepeaterServer.off(anything(), anything())).thenCall( - (event, listener) => { - events.off(event, listener); - } - ); - - when(mockedRepeaterServer.deploy(anything())).thenResolve({ - repeaterId: RepeaterId - }); - - sut = new DefaultRepeater( - RepeaterId, - instance(mockedLogger), - instance(mockedRepeaterServer), - instance(repeaterCommands) - ); - }); - - afterEach(() => - reset( - mockedRepeaterServer, - repeaterCommands, - mockedLogger - ) - ); - - describe('start', () => { - it('should start', async () => { - // act - await sut.start(); - - // assert - verify(mockedRepeaterServer.connect()).once(); - verify( - mockedRepeaterServer.deploy( - objectContaining({ repeaterId: RepeaterId }) - ) - ).once(); - }); - - it('should throw when underlying connect throws', async () => { - // arrange - when(mockedRepeaterServer.connect()).thenReject(new Error('foo')); - - // act - const act = () => sut.start(); - - // assert - await expect(act).rejects.toThrowError('foo'); - }); - - it('should throw when underlying deploy throws', async () => { - // arrange - when(mockedRepeaterServer.deploy(anything())).thenReject( - new Error('foo') - ); - - // act - const act = () => sut.start(); - - // assert - await expect(act).rejects.toThrowError('foo'); - }); - - it('should have RunningStatus.STARTING just after start() call', () => { - // act - void sut.start(); - - // assert - expect(sut.runningStatus).toBe(RunningStatus.STARTING); - }); - - it('should have RunningStatus.RUNNING after successful start()', async () => { - // act - await sut.start(); - - // assert - expect(sut.runningStatus).toBe(RunningStatus.RUNNING); - }); - - it('should throw an error on start() twice', async () => { - // arrange - await sut.start(); - - // act - const res = sut.start(); - - // assert - await expect(res).rejects.toThrow('Repeater is already active.'); - }); - - it('should be possible to start() after start() error', async () => { - // act - when(mockedRepeaterServer.connect()).thenReject().thenResolve(); - - // assert - await expect(sut.start()).rejects.toThrow(); - await expect(sut.start()).resolves.not.toThrow(); - }); - }); - - describe('stop', () => { - it('should stop', async () => { - // arrange - await sut.start(); - - // act - await sut.stop(); - - // assert - verify(mockedRepeaterServer.disconnect()).once(); - }); - - it('should have RunningStatus.OFF after start() and stop()', async () => { - // arrange - await sut.start(); - - // act - await sut.stop(); - - // assert - expect(sut.runningStatus).toBe(RunningStatus.OFF); - }); - - it('should do nothing on stop() without start()', async () => { - // act - await sut.stop(); - - // assert - expect(sut.runningStatus).toBe(RunningStatus.OFF); - }); - - it('should do nothing on second stop() call', async () => { - // arrange - await sut.start(); - await sut.stop(); - - // assert - await sut.stop(); - - // assert - expect(sut.runningStatus).toBe(RunningStatus.OFF); - }); - }); - - describe('runningStatus', () => { - it('should have RunningStatus.OFF initially', () => { - // assert - expect(sut.runningStatus).toBe(RunningStatus.OFF); - }); - }); - - describe('events', () => { - it(`should subscribe to ${RepeaterServerEvents.UPDATE_AVAILABLE}`, async () => { - // arrange - await sut.start(); - - // act - events.emit(RepeaterServerEvents.UPDATE_AVAILABLE, { version: '1.0.0' }); - - // assert - verify( - mockedLogger.warn( - '%s: A new Repeater version (%s) is available, for update instruction visit https://docs.brightsec.com/docs/installation-options', - anything(), - '1.0.0' - ) - ).once(); - }); - - it(`should subscribe to ${RepeaterServerEvents.REQUEST}`, async () => { - // arrange - const requestEvent: RepeaterServerRequestEvent = { - protocol: Protocol.HTTP, - url: 'http://foo.bar', - method: 'GET' - }; - - const request = new Request(requestEvent); - - when(repeaterCommands.sendRequest(anything())).thenResolve( - new Response({ - protocol: Protocol.HTTP, - statusCode: 200 - }) - ); - - await sut.start(); - - // act - events.emit(RepeaterServerEvents.REQUEST, requestEvent); - - // assert - await delay(200); - verify(repeaterCommands.sendRequest(objectContaining(request))).once(); - }); - - it(`should subscribe to ${RepeaterServerEvents.RECONNECT_ATTEMPT}`, async () => { - // arrange - await sut.start(); - - // act - events.emit(RepeaterServerEvents.RECONNECT_ATTEMPT, { - attempt: 1, - maxAttempts: 3 - }); - - // assert - verify( - mockedLogger.warn( - 'Failed to connect to Bright cloud (attempt %d/%d)', - anything(), - anything() - ) - ).once(); - }); - - it(`should subscribe to ${RepeaterServerEvents.ERROR} and proceed on error`, async () => { - // arrange - await sut.start(); - - // act - events.emit(RepeaterServerEvents.ERROR, { - code: RepeaterErrorCodes.UNKNOWN_ERROR, - message: 'error' - }); - - // assert - verify(mockedLogger.error('error')).once(); - }); - - it(`should subscribe to ${RepeaterServerEvents.ERROR} and proceed on critical error`, async () => { - // arrange - await sut.start(); - - // act - events.emit(RepeaterServerEvents.ERROR, { - code: RepeaterErrorCodes.UNEXPECTED_ERROR, - message: 'unexpected error', - remediation: 'remediation' - }); - - // assert - verify( - mockedLogger.error( - '%s: %s. %s', - anything(), - 'unexpected error', - 'remediation' - ) - ).once(); - verify(mockedRepeaterServer.disconnect()).once(); - }); - - it(`should subscribe to ${RepeaterServerEvents.RECONNECTION_FAILED}`, async () => { - // arrange - const error = new Error('test error'); - await sut.start(); - - // act - events.emit(RepeaterServerEvents.RECONNECTION_FAILED, { - error - }); - - // assert - verify(mockedLogger.error(error.message)).once(); - verify(mockedRepeaterServer.disconnect()).once(); - }); - - it(`should subscribe to ${RepeaterServerEvents.RECONNECTION_SUCCEEDED}`, async () => { - // arrange - await sut.start(); - - // act - events.emit(RepeaterServerEvents.RECONNECTION_SUCCEEDED); - - // assert - verify( - mockedLogger.log('The Repeater (%s) connected', RepeaterId) - ).once(); - }); - }); -}); diff --git a/packages/repeater/src/lib/DefaultRepeater.ts b/packages/repeater/src/lib/DefaultRepeater.ts deleted file mode 100644 index d2a42fe0..00000000 --- a/packages/repeater/src/lib/DefaultRepeater.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { Repeater, RepeaterId, RunningStatus } from './Repeater'; -import { - RepeaterServer, - RepeaterErrorCodes, - RepeaterServerErrorEvent, - RepeaterServerEvents, - RepeaterServerReconnectionAttemptedEvent, - RepeaterServerReconnectionFailedEvent, - RepeaterServerRequestEvent, - RepeaterUpgradeAvailableEvent -} from './RepeaterServer'; -import { RepeaterCommands } from './RepeaterCommands'; -import { Request } from '../request-runner/Request'; -import { Logger } from '@sectester/core'; -import chalk from 'chalk'; -import { inject, injectable, Lifecycle, scoped } from 'tsyringe'; - -@scoped(Lifecycle.ContainerScoped) -@injectable() -export class DefaultRepeater implements Repeater { - private _runningStatus = RunningStatus.OFF; - - get runningStatus(): RunningStatus { - return this._runningStatus; - } - - constructor( - @inject(RepeaterId) - public readonly repeaterId: RepeaterId, - private readonly logger: Logger, - @inject(RepeaterServer) - private readonly repeaterServer: RepeaterServer, - @inject(RepeaterCommands) - private readonly repeaterCommands: RepeaterCommands - ) {} - - public async start(): Promise { - if (this.runningStatus !== RunningStatus.OFF) { - throw new Error('Repeater is already active.'); - } - - this._runningStatus = RunningStatus.STARTING; - - try { - await this.connect(); - - this._runningStatus = RunningStatus.RUNNING; - } catch (e) { - this._runningStatus = RunningStatus.OFF; - throw e; - } - } - - public async stop(): Promise { - if (this.runningStatus !== RunningStatus.RUNNING) { - return; - } - - this._runningStatus = RunningStatus.OFF; - - this.repeaterServer.disconnect(); - - return Promise.resolve(); - } - - private async connect(): Promise { - this.logger.log('Connecting the Bridges'); - - this.subscribeDiagnosticEvents(); - - await this.repeaterServer.connect(); - - this.logger.log('Deploying the repeater'); - - await this.deploy(); - - this.logger.log('The Repeater (%s) started', this.repeaterId); - - this.subscribeRedeploymentEvent(); - } - - private async deploy() { - await this.repeaterServer.deploy({ - repeaterId: this.repeaterId - }); - } - - private subscribeRedeploymentEvent() { - this.repeaterServer.on(RepeaterServerEvents.CONNECTED, this.deploy); - } - - private subscribeDiagnosticEvents() { - this.repeaterServer.on(RepeaterServerEvents.ERROR, this.handleError); - - this.repeaterServer.on( - RepeaterServerEvents.RECONNECTION_FAILED, - this.reconnectionFailed - ); - this.repeaterServer.on(RepeaterServerEvents.REQUEST, this.requestReceived); - this.repeaterServer.on( - RepeaterServerEvents.UPDATE_AVAILABLE, - this.upgradeAvailable - ); - this.repeaterServer.on( - RepeaterServerEvents.RECONNECT_ATTEMPT, - this.reconnectAttempt - ); - this.repeaterServer.on(RepeaterServerEvents.RECONNECTION_SUCCEEDED, () => - this.logger.log('The Repeater (%s) connected', this.repeaterId) - ); - } - - private handleError = ({ - code, - message, - remediation - }: RepeaterServerErrorEvent) => { - const normalizedMessage = this.normalizeMessage(message); - const normalizedRemediation = this.normalizeMessage(remediation ?? ''); - - if (this.isCriticalError(code)) { - this.handleCriticalError(normalizedMessage, normalizedRemediation); - } else { - this.logger.error(normalizedMessage); - } - }; - - private normalizeMessage(message: string): string { - return message.replace(/\.$/, ''); - } - - private isCriticalError(code: RepeaterErrorCodes): boolean { - return [ - RepeaterErrorCodes.REPEATER_DEACTIVATED, - RepeaterErrorCodes.REPEATER_NO_LONGER_SUPPORTED, - RepeaterErrorCodes.REPEATER_UNAUTHORIZED, - RepeaterErrorCodes.REPEATER_ALREADY_STARTED, - RepeaterErrorCodes.REPEATER_NOT_PERMITTED, - RepeaterErrorCodes.UNEXPECTED_ERROR - ].includes(code); - } - - private handleCriticalError(message: string, remediation: string): void { - this.logger.error( - '%s: %s. %s', - chalk.red('(!) CRITICAL'), - message, - remediation - ); - this.stop().catch(this.logger.error); - } - - private upgradeAvailable = (event: RepeaterUpgradeAvailableEvent) => { - this.logger.warn( - '%s: A new Repeater version (%s) is available, for update instruction visit https://docs.brightsec.com/docs/installation-options', - chalk.yellow('(!) IMPORTANT'), - event.version - ); - }; - - private reconnectAttempt = ({ - attempt, - maxAttempts - }: RepeaterServerReconnectionAttemptedEvent) => { - this.logger.warn( - 'Failed to connect to Bright cloud (attempt %d/%d)', - attempt, - maxAttempts - ); - }; - - private reconnectionFailed = ({ - error - }: RepeaterServerReconnectionFailedEvent) => { - this.logger.error(error.message); - this.stop().catch(this.logger.error); - }; - - private requestReceived = async (event: RepeaterServerRequestEvent) => { - const response = await this.repeaterCommands.sendRequest( - new Request({ ...event }) - ); - - const { - statusCode, - message, - errorCode, - body, - headers, - protocol, - encoding - } = response; - - return { - protocol, - body, - headers, - statusCode, - errorCode, - message, - encoding - }; - }; -} diff --git a/packages/repeater/src/lib/DefaultRepeaterServer.ts b/packages/repeater/src/lib/DefaultRepeaterServer.ts index c557d60e..653a245d 100644 --- a/packages/repeater/src/lib/DefaultRepeaterServer.ts +++ b/packages/repeater/src/lib/DefaultRepeaterServer.ts @@ -20,7 +20,7 @@ import { Logger } from '@sectester/core'; import { inject, injectable, Lifecycle, scoped } from 'tsyringe'; import io, { Socket } from 'socket.io-client'; import parser from 'socket.io-msgpack-parser'; -import { ErrorEvent } from 'ws'; +import { type ErrorEvent } from 'ws'; import { EventEmitter, once } from 'events'; import { hostname } from 'os'; import Timer = NodeJS.Timer; diff --git a/packages/repeater/src/lib/Repeater.spec.ts b/packages/repeater/src/lib/Repeater.spec.ts new file mode 100644 index 00000000..384dd961 --- /dev/null +++ b/packages/repeater/src/lib/Repeater.spec.ts @@ -0,0 +1,186 @@ +import { Repeater, RunningStatus } from './Repeater'; +import { RepeaterServer, RepeaterServerEvents } from './RepeaterServer'; +import { RepeaterCommands } from './RepeaterCommands'; +import { Logger } from '@sectester/core'; +import { + anything, + instance, + mock, + objectContaining, + reset, + verify, + when +} from 'ts-mockito'; + +describe('Repeater', () => { + const RepeaterId = 'fooId'; + + let sut!: Repeater; + + const mockedRepeaterServer = mock(); + const repeaterCommands = mock(); + const mockedLogger = mock(); + + beforeEach(() => { + when(mockedRepeaterServer.deploy(anything())).thenResolve({ + repeaterId: RepeaterId + }); + + sut = new Repeater( + RepeaterId, + instance(mockedLogger), + instance(mockedRepeaterServer), + instance(repeaterCommands) + ); + }); + + afterEach(() => + reset( + mockedRepeaterServer, + repeaterCommands, + mockedLogger + ) + ); + + describe('start', () => { + it('should start', async () => { + // act + await sut.start(); + + // assert + verify(mockedRepeaterServer.connect()).once(); + verify( + mockedRepeaterServer.deploy( + objectContaining({ repeaterId: RepeaterId }) + ) + ).once(); + }); + + it('should throw when underlying connect throws', async () => { + // arrange + when(mockedRepeaterServer.connect()).thenReject(new Error('foo')); + + // act + const act = () => sut.start(); + + // assert + await expect(act).rejects.toThrowError('foo'); + }); + + it('should throw when underlying deploy throws', async () => { + // arrange + when(mockedRepeaterServer.deploy(anything())).thenReject( + new Error('foo') + ); + + // act + const act = () => sut.start(); + + // assert + await expect(act).rejects.toThrowError('foo'); + }); + + it('should have RunningStatus.STARTING just after start() call', () => { + // act + void sut.start(); + + // assert + expect(sut.runningStatus).toBe(RunningStatus.STARTING); + }); + + it('should have RunningStatus.RUNNING after successful start()', async () => { + // act + await sut.start(); + + // assert + expect(sut.runningStatus).toBe(RunningStatus.RUNNING); + }); + + it('should throw an error on start() twice', async () => { + // arrange + await sut.start(); + + // act + const res = sut.start(); + + // assert + await expect(res).rejects.toThrow('Repeater is already active.'); + }); + + it('should be possible to start() after start() error', async () => { + // act + when(mockedRepeaterServer.connect()).thenReject().thenResolve(); + + // assert + await expect(sut.start()).rejects.toThrow(); + await expect(sut.start()).resolves.not.toThrow(); + }); + + it.each([ + RepeaterServerEvents.UPDATE_AVAILABLE, + RepeaterServerEvents.REQUEST, + RepeaterServerEvents.RECONNECT_ATTEMPT, + RepeaterServerEvents.ERROR, + RepeaterServerEvents.RECONNECTION_FAILED, + RepeaterServerEvents.RECONNECTION_SUCCEEDED, + RepeaterServerEvents.CONNECTED + ])(`should subscribe to %s`, async input => { + // act + await sut.start(); + + // assert + verify(mockedRepeaterServer.on(input, anything())).once(); + }); + }); + + describe('stop', () => { + it('should stop', async () => { + // arrange + await sut.start(); + + // act + await sut.stop(); + + // assert + verify(mockedRepeaterServer.disconnect()).once(); + }); + + it('should have RunningStatus.OFF after start() and stop()', async () => { + // arrange + await sut.start(); + + // act + await sut.stop(); + + // assert + expect(sut.runningStatus).toBe(RunningStatus.OFF); + }); + + it('should do nothing on stop() without start()', async () => { + // act + await sut.stop(); + + // assert + expect(sut.runningStatus).toBe(RunningStatus.OFF); + }); + + it('should do nothing on second stop() call', async () => { + // arrange + await sut.start(); + await sut.stop(); + + // assert + await sut.stop(); + + // assert + expect(sut.runningStatus).toBe(RunningStatus.OFF); + }); + }); + + describe('runningStatus', () => { + it('should have RunningStatus.OFF initially', () => { + // assert + expect(sut.runningStatus).toBe(RunningStatus.OFF); + }); + }); +}); diff --git a/packages/repeater/src/lib/Repeater.ts b/packages/repeater/src/lib/Repeater.ts index a497be0f..e59475c2 100644 --- a/packages/repeater/src/lib/Repeater.ts +++ b/packages/repeater/src/lib/Repeater.ts @@ -1,3 +1,19 @@ +import { + RepeaterServer, + RepeaterErrorCodes, + RepeaterServerErrorEvent, + RepeaterServerEvents, + RepeaterServerReconnectionAttemptedEvent, + RepeaterServerReconnectionFailedEvent, + RepeaterServerRequestEvent, + RepeaterUpgradeAvailableEvent +} from './RepeaterServer'; +import { RepeaterCommands } from './RepeaterCommands'; +import { Request } from '../request-runner/Request'; +import { Logger } from '@sectester/core'; +import chalk from 'chalk'; +import { inject, injectable, Lifecycle, scoped } from 'tsyringe'; + export enum RunningStatus { OFF, STARTING, @@ -7,11 +23,190 @@ export enum RunningStatus { export type RepeaterId = string; export const RepeaterId = Symbol('RepeaterId'); -export interface Repeater { - readonly repeaterId: RepeaterId; - readonly runningStatus: RunningStatus; - start(): Promise; - stop(): Promise; -} +@scoped(Lifecycle.ContainerScoped) +@injectable() +export class Repeater { + private _runningStatus = RunningStatus.OFF; + + get runningStatus(): RunningStatus { + return this._runningStatus; + } + + constructor( + @inject(RepeaterId) + public readonly repeaterId: RepeaterId, + private readonly logger: Logger, + @inject(RepeaterServer) + private readonly repeaterServer: RepeaterServer, + @inject(RepeaterCommands) + private readonly repeaterCommands: RepeaterCommands + ) {} + + public async start(): Promise { + if (this.runningStatus !== RunningStatus.OFF) { + throw new Error('Repeater is already active.'); + } + + this._runningStatus = RunningStatus.STARTING; + + try { + await this.connect(); + + this._runningStatus = RunningStatus.RUNNING; + } catch (e) { + this._runningStatus = RunningStatus.OFF; + throw e; + } + } + + public async stop(): Promise { + if (this.runningStatus !== RunningStatus.RUNNING) { + return; + } + + this._runningStatus = RunningStatus.OFF; + + this.repeaterServer.disconnect(); + + return Promise.resolve(); + } + + private async connect(): Promise { + this.logger.log('Connecting the Bridges'); + + this.subscribeDiagnosticEvents(); + + await this.repeaterServer.connect(); + + this.logger.log('Deploying the repeater'); + + await this.deploy(); -export const Repeater: unique symbol = Symbol('Repeater'); + this.logger.log('The Repeater (%s) started', this.repeaterId); + + this.subscribeConnectedEvent(); + } + + private async deploy() { + await this.repeaterServer.deploy({ + repeaterId: this.repeaterId + }); + } + + private subscribeConnectedEvent() { + this.repeaterServer.on(RepeaterServerEvents.CONNECTED, this.deploy); + } + + private subscribeDiagnosticEvents() { + this.repeaterServer.on(RepeaterServerEvents.ERROR, this.handleError); + + this.repeaterServer.on( + RepeaterServerEvents.RECONNECTION_FAILED, + this.reconnectionFailed + ); + this.repeaterServer.on(RepeaterServerEvents.REQUEST, this.requestReceived); + this.repeaterServer.on( + RepeaterServerEvents.UPDATE_AVAILABLE, + this.upgradeAvailable + ); + this.repeaterServer.on( + RepeaterServerEvents.RECONNECT_ATTEMPT, + this.reconnectAttempt + ); + this.repeaterServer.on(RepeaterServerEvents.RECONNECTION_SUCCEEDED, () => + this.logger.log('The Repeater (%s) connected', this.repeaterId) + ); + } + + private handleError = ({ + code, + message, + remediation + }: RepeaterServerErrorEvent) => { + const normalizedMessage = this.normalizeMessage(message); + const normalizedRemediation = this.normalizeMessage(remediation ?? ''); + + if (this.isCriticalError(code)) { + this.handleCriticalError(normalizedMessage, normalizedRemediation); + } else { + this.logger.error(normalizedMessage); + } + }; + + private normalizeMessage(message: string): string { + return message.replace(/\.$/, ''); + } + + private isCriticalError(code: RepeaterErrorCodes): boolean { + return [ + RepeaterErrorCodes.REPEATER_DEACTIVATED, + RepeaterErrorCodes.REPEATER_NO_LONGER_SUPPORTED, + RepeaterErrorCodes.REPEATER_UNAUTHORIZED, + RepeaterErrorCodes.REPEATER_ALREADY_STARTED, + RepeaterErrorCodes.REPEATER_NOT_PERMITTED, + RepeaterErrorCodes.UNEXPECTED_ERROR + ].includes(code); + } + + private handleCriticalError(message: string, remediation: string): void { + this.logger.error( + '%s: %s. %s', + chalk.red('(!) CRITICAL'), + message, + remediation + ); + this.stop().catch(this.logger.error); + } + + private upgradeAvailable = (event: RepeaterUpgradeAvailableEvent) => { + this.logger.warn( + '%s: A new Repeater version (%s) is available, for update instruction visit https://docs.brightsec.com/docs/installation-options', + chalk.yellow('(!) IMPORTANT'), + event.version + ); + }; + + private reconnectAttempt = ({ + attempt, + maxAttempts + }: RepeaterServerReconnectionAttemptedEvent) => { + this.logger.warn( + 'Failed to connect to Bright cloud (attempt %d/%d)', + attempt, + maxAttempts + ); + }; + + private reconnectionFailed = ({ + error + }: RepeaterServerReconnectionFailedEvent) => { + this.logger.error(error.message); + this.stop().catch(this.logger.error); + }; + + private requestReceived = async (event: RepeaterServerRequestEvent) => { + const response = await this.repeaterCommands.sendRequest( + new Request({ ...event }) + ); + + const { + statusCode, + message, + errorCode, + body, + headers, + protocol, + encoding + } = response; + + return { + protocol, + body, + headers, + statusCode, + errorCode, + message, + encoding + }; + }; +} diff --git a/packages/repeater/src/lib/RepeaterFactory.spec.ts b/packages/repeater/src/lib/RepeaterFactory.spec.ts index 19a51435..626794b7 100644 --- a/packages/repeater/src/lib/RepeaterFactory.spec.ts +++ b/packages/repeater/src/lib/RepeaterFactory.spec.ts @@ -164,4 +164,81 @@ describe('RepeaterFactory', () => { ).once(); }); }); + + describe('createRepeaterFromExisting', () => { + it('should create repeater from existing repeater ID', async () => { + const factory = new RepeaterFactory(configuration); + const existingRepeaterId = '123'; + + await factory.createRepeaterFromExisting(existingRepeaterId); + + verify(mockedChildContainer.resolve(Repeater)).once(); + }); + + it('should register custom request runner options', async () => { + const factory = new RepeaterFactory(configuration); + const existingRepeaterId = '123'; + when( + mockedChildContainer.register(RequestRunnerOptions, anything()) + ).thenReturn(); + + const requestRunnerOptions = { + timeout: 10000, + maxContentLength: 200, + allowedMimes: ['text/html'] + }; + + await factory.createRepeaterFromExisting(existingRepeaterId, { + requestRunnerOptions + }); + + verify( + mockedChildContainer.register( + RequestRunnerOptions, + objectContaining({ + useValue: requestRunnerOptions + }) + ) + ).once(); + }); + + it('should register request runner options', async () => { + const factory = new RepeaterFactory(configuration); + const existingRepeaterId = '123'; + + await factory.createRepeaterFromExisting(existingRepeaterId, { + requestRunnerOptions: defaultOptions + }); + + verify( + mockedChildContainer.register( + RequestRunnerOptions, + deepEqual({ + useValue: defaultOptions + }) + ) + ).once(); + }); + + it('should register request runners', async () => { + const factory = new RepeaterFactory(configuration); + const existingRepeaterId = '123'; + + await factory.createRepeaterFromExisting(existingRepeaterId, { + requestRunners: [HttpRequestRunner] + }); + + verify( + mockedChildContainer.register( + RequestRunner, + deepEqual({ + useClass: HttpRequestRunner + }), + deepEqual({ + lifecycle: Lifecycle.ContainerScoped + }) + ) + ).once(); + }); + }); }); diff --git a/packages/repeater/src/lib/RepeaterFactory.ts b/packages/repeater/src/lib/RepeaterFactory.ts index d081ccf6..06fe7288 100644 --- a/packages/repeater/src/lib/RepeaterFactory.ts +++ b/packages/repeater/src/lib/RepeaterFactory.ts @@ -1,5 +1,6 @@ import { Repeater, RepeaterId } from './Repeater'; import { RequestRunner, RequestRunnerOptions } from '../request-runner'; +import { RepeaterRequestRunnerOptions } from './RepeaterRequestRunnerOptions'; import { RepeatersManager } from '../api'; import { RepeaterOptions } from './RepeaterOptions'; import { Configuration } from '@sectester/core'; @@ -27,8 +28,7 @@ export class RepeaterFactory { description, disableRandomNameGeneration, namePrefix = 'sectester', - requestRunnerOptions, - requestRunners + ...options }: RepeaterOptions = {}): Promise { await this.configuration.loadCredentials(); @@ -38,6 +38,25 @@ export class RepeaterFactory { name: this.generateName(namePrefix, disableRandomNameGeneration) }); + return this.createRepeaterInstance(repeaterId, options); + } + + public async createRepeaterFromExisting( + repeaterId: string, + options?: RepeaterRequestRunnerOptions + ): Promise { + await this.repeatersManager.getRepeater(repeaterId); + + return this.createRepeaterInstance(repeaterId, options); + } + + private createRepeaterInstance( + repeaterId: string, + { + requestRunnerOptions, + requestRunners = [] + }: RepeaterRequestRunnerOptions = {} + ): Repeater { const container = this.configuration.container.createChildContainer(); container.register(RepeaterId, { useValue: repeaterId }); diff --git a/packages/repeater/src/lib/index.ts b/packages/repeater/src/lib/index.ts index 5e98fa4f..5fe0055e 100644 --- a/packages/repeater/src/lib/index.ts +++ b/packages/repeater/src/lib/index.ts @@ -2,7 +2,6 @@ export * from './Repeater'; export * from './RepeaterFactory'; export * from './RepeaterOptions'; export * from './RepeaterRequestRunnerOptions'; -export * from './DefaultRepeater'; export * from './DefaultRepeaterCommands'; export * from './DefaultRepeaterServer'; export * from './RepeaterCommands'; diff --git a/packages/repeater/src/register.ts b/packages/repeater/src/register.ts index 5a4009e5..84752d1f 100644 --- a/packages/repeater/src/register.ts +++ b/packages/repeater/src/register.ts @@ -3,11 +3,9 @@ import { RepeaterId, DefaultRepeaterCommands, DefaultRepeaterServer, - DefaultRepeater, DefaultRepeaterServerOptions, RepeaterCommands, - RepeaterServer, - Repeater + RepeaterServer } from './lib'; import { HttpRequestRunner, @@ -114,7 +112,6 @@ container.register(DefaultRepeaterServerOptions, { } }); -container.register(Repeater, { useClass: DefaultRepeater }); container.register(ProxyFactory, { useClass: DefaultProxyFactory }); container.register(RepeaterServer, { useClass: DefaultRepeaterServer }); container.register(RepeaterCommands, { useClass: DefaultRepeaterCommands }); From f0485c78811043ea02ac4452861dcd4fbab4c742 Mon Sep 17 00:00:00 2001 From: Dmitry Ostrikov Date: Wed, 19 Jun 2024 17:11:36 +0700 Subject: [PATCH 15/17] feat(repeater): address pr comments closes #196 --- package-lock.json | 19 -- package.json | 1 - .../repeater/src/lib/DefaultRepeaterServer.ts | 7 +- packages/repeater/src/lib/Repeater.spec.ts | 217 +++++++++++++++++- 4 files changed, 210 insertions(+), 34 deletions(-) diff --git a/package-lock.json b/package-lock.json index 39cee224..ebac391d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,7 +55,6 @@ "@types/request-promise": "^4.1.48", "@types/semver": "^7.3.9", "@types/uuid": "^8.3.4", - "@types/ws": "^8.5.10", "@typescript-eslint/eslint-plugin": "5.33.1", "@typescript-eslint/parser": "5.33.1", "eslint": "8.15.0", @@ -2673,15 +2672,6 @@ "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==", "dev": true }, - "node_modules/@types/ws": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", - "integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/yargs": { "version": "16.0.4", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", @@ -17428,15 +17418,6 @@ "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==", "dev": true }, - "@types/ws": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", - "integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, "@types/yargs": { "version": "16.0.4", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", diff --git a/package.json b/package.json index 41f0fa94..af166df5 100644 --- a/package.json +++ b/package.json @@ -118,7 +118,6 @@ "@types/request-promise": "^4.1.48", "@types/semver": "^7.3.9", "@types/uuid": "^8.3.4", - "@types/ws": "^8.5.10", "@typescript-eslint/eslint-plugin": "5.33.1", "@typescript-eslint/parser": "5.33.1", "eslint": "8.15.0", diff --git a/packages/repeater/src/lib/DefaultRepeaterServer.ts b/packages/repeater/src/lib/DefaultRepeaterServer.ts index 653a245d..3a62c764 100644 --- a/packages/repeater/src/lib/DefaultRepeaterServer.ts +++ b/packages/repeater/src/lib/DefaultRepeaterServer.ts @@ -20,7 +20,6 @@ import { Logger } from '@sectester/core'; import { inject, injectable, Lifecycle, scoped } from 'tsyringe'; import io, { Socket } from 'socket.io-client'; import parser from 'socket.io-msgpack-parser'; -import { type ErrorEvent } from 'ws'; import { EventEmitter, once } from 'events'; import { hostname } from 'os'; import Timer = NodeJS.Timer; @@ -141,7 +140,7 @@ export class DefaultRepeaterServer implements RepeaterServer { transports: ['websocket'], reconnectionAttempts: this.MAX_RECONNECTION_ATTEMPTS, auth: { - domain: namePrefix, + domain: namePrefix + '123', token: this.options.token } }); @@ -312,7 +311,9 @@ export class DefaultRepeaterServer implements RepeaterServer { ); const { description, cause } = err as { - description?: ErrorEvent; + description?: { + error?: Error; + }; cause?: Error; }; const nestedError = description?.error ?? cause; diff --git a/packages/repeater/src/lib/Repeater.spec.ts b/packages/repeater/src/lib/Repeater.spec.ts index 384dd961..0080b482 100644 --- a/packages/repeater/src/lib/Repeater.spec.ts +++ b/packages/repeater/src/lib/Repeater.spec.ts @@ -1,5 +1,12 @@ import { Repeater, RunningStatus } from './Repeater'; -import { RepeaterServer, RepeaterServerEvents } from './RepeaterServer'; +import { Protocol } from '../models/Protocol'; +import { Request, Response } from '../request-runner'; +import { + RepeaterErrorCodes, + RepeaterServer, + RepeaterServerEvents, + RepeaterServerRequestEvent +} from './RepeaterServer'; import { RepeaterCommands } from './RepeaterCommands'; import { Logger } from '@sectester/core'; import { @@ -116,20 +123,208 @@ describe('Repeater', () => { await expect(sut.start()).resolves.not.toThrow(); }); - it.each([ - RepeaterServerEvents.UPDATE_AVAILABLE, - RepeaterServerEvents.REQUEST, - RepeaterServerEvents.RECONNECT_ATTEMPT, - RepeaterServerEvents.ERROR, - RepeaterServerEvents.RECONNECTION_FAILED, - RepeaterServerEvents.RECONNECTION_SUCCEEDED, - RepeaterServerEvents.CONNECTED - ])(`should subscribe to %s`, async input => { + it(`should subscribe to ${RepeaterServerEvents.UPDATE_AVAILABLE} and proceed on event`, async () => { + // arrange + let eventHandler!: (event: unknown) => void; + + when( + mockedRepeaterServer.on( + RepeaterServerEvents.UPDATE_AVAILABLE, + anything() + ) + ).thenCall((_, handler) => { + eventHandler = handler; + }); + + await sut.start(); + // act + eventHandler({ version: '1.0.0' }); + + // assert + verify( + mockedLogger.warn( + '%s: A new Repeater version (%s) is available, for update instruction visit https://docs.brightsec.com/docs/installation-options', + anything(), + '1.0.0' + ) + ).once(); + }); + + it(`should subscribe to ${RepeaterServerEvents.REQUEST} and proceed on event`, async () => { + // arrange + let eventHandler!: (event: unknown) => Promise; + + when( + mockedRepeaterServer.on(RepeaterServerEvents.REQUEST, anything()) + ).thenCall((_, handler) => { + eventHandler = handler; + }); + + const requestEvent: RepeaterServerRequestEvent = { + protocol: Protocol.HTTP, + url: 'http://foo.bar', + method: 'GET' + }; + + const request = new Request(requestEvent); + + const response = new Response({ + protocol: Protocol.HTTP, + statusCode: 200 + }); + + when(repeaterCommands.sendRequest(objectContaining(request))).thenResolve( + response + ); + await sut.start(); + // act + const result = await eventHandler(requestEvent); + // assert - verify(mockedRepeaterServer.on(input, anything())).once(); + expect(result).toMatchObject(response); + }); + + it(`should subscribe to ${RepeaterServerEvents.RECONNECT_ATTEMPT} and proceed on event`, async () => { + // arrange + + let eventHandler!: (event: unknown) => void; + + when( + mockedRepeaterServer.on( + RepeaterServerEvents.RECONNECT_ATTEMPT, + anything() + ) + ).thenCall((_, handler) => { + eventHandler = handler; + }); + + await sut.start(); + + // act + eventHandler({ + attempt: 1, + maxAttempts: 3 + }); + + // assert + verify( + mockedLogger.warn( + 'Failed to connect to Bright cloud (attempt %d/%d)', + anything(), + anything() + ) + ).once(); + }); + + it(`should subscribe to ${RepeaterServerEvents.ERROR} and proceed on error`, async () => { + // arrange + + let eventHandler!: (event: unknown) => void; + + when( + mockedRepeaterServer.on(RepeaterServerEvents.ERROR, anything()) + ).thenCall((_, handler) => { + eventHandler = handler; + }); + + await sut.start(); + + // act + eventHandler({ + code: RepeaterErrorCodes.UNKNOWN_ERROR, + message: 'error' + }); + + // assert + verify(mockedLogger.error('error')).once(); + }); + + it(`should subscribe to ${RepeaterServerEvents.ERROR} and proceed on critical error`, async () => { + // arrange + + let eventHandler!: (event: unknown) => void; + + when( + mockedRepeaterServer.on(RepeaterServerEvents.ERROR, anything()) + ).thenCall((_, handler) => { + eventHandler = handler; + }); + + await sut.start(); + + // act + eventHandler({ + code: RepeaterErrorCodes.UNEXPECTED_ERROR, + message: 'unexpected error', + remediation: 'remediation' + }); + + // assert + verify( + mockedLogger.error( + '%s: %s. %s', + anything(), + 'unexpected error', + 'remediation' + ) + ).once(); + verify(mockedRepeaterServer.disconnect()).once(); + }); + + it(`should subscribe to ${RepeaterServerEvents.RECONNECTION_FAILED} and proceed on event`, async () => { + // arrange + const error = new Error('test error'); + // arrange + + let eventHandler!: (event: unknown) => void; + + when( + mockedRepeaterServer.on( + RepeaterServerEvents.RECONNECTION_FAILED, + anything() + ) + ).thenCall((_, handler) => { + eventHandler = handler; + }); + + await sut.start(); + + // act + eventHandler({ + error + }); + + // assert + verify(mockedLogger.error(error.message)).once(); + verify(mockedRepeaterServer.disconnect()).once(); + }); + + it(`should subscribe to ${RepeaterServerEvents.RECONNECTION_SUCCEEDED} and proceed on event`, async () => { + // arrange + + let eventHandler!: () => void; + + when( + mockedRepeaterServer.on( + RepeaterServerEvents.RECONNECTION_SUCCEEDED, + anything() + ) + ).thenCall((_, handler) => { + eventHandler = handler; + }); + + await sut.start(); + + // act + eventHandler(); + + // assert + verify( + mockedLogger.log('The Repeater (%s) connected', RepeaterId) + ).once(); }); }); From 317cd43f3e093f35552f87d5730de7b1ec376210 Mon Sep 17 00:00:00 2001 From: Dmitry Ostrikov Date: Wed, 19 Jun 2024 17:58:41 +0700 Subject: [PATCH 16/17] feat(repeater): fix formatting closes #196 --- packages/repeater/src/lib/Repeater.spec.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/repeater/src/lib/Repeater.spec.ts b/packages/repeater/src/lib/Repeater.spec.ts index 0080b482..79d43eb3 100644 --- a/packages/repeater/src/lib/Repeater.spec.ts +++ b/packages/repeater/src/lib/Repeater.spec.ts @@ -189,7 +189,6 @@ describe('Repeater', () => { it(`should subscribe to ${RepeaterServerEvents.RECONNECT_ATTEMPT} and proceed on event`, async () => { // arrange - let eventHandler!: (event: unknown) => void; when( @@ -221,7 +220,6 @@ describe('Repeater', () => { it(`should subscribe to ${RepeaterServerEvents.ERROR} and proceed on error`, async () => { // arrange - let eventHandler!: (event: unknown) => void; when( @@ -244,7 +242,6 @@ describe('Repeater', () => { it(`should subscribe to ${RepeaterServerEvents.ERROR} and proceed on critical error`, async () => { // arrange - let eventHandler!: (event: unknown) => void; when( @@ -277,7 +274,6 @@ describe('Repeater', () => { it(`should subscribe to ${RepeaterServerEvents.RECONNECTION_FAILED} and proceed on event`, async () => { // arrange const error = new Error('test error'); - // arrange let eventHandler!: (event: unknown) => void; @@ -304,7 +300,6 @@ describe('Repeater', () => { it(`should subscribe to ${RepeaterServerEvents.RECONNECTION_SUCCEEDED} and proceed on event`, async () => { // arrange - let eventHandler!: () => void; when( From e2cd7e14b1c14429bea201ff19e790c90323ef4f Mon Sep 17 00:00:00 2001 From: Dmitry Ostrikov Date: Wed, 19 Jun 2024 18:14:25 +0700 Subject: [PATCH 17/17] feat(repeater): simplify tests closes #196 --- packages/repeater/src/lib/Repeater.spec.ts | 112 ++++++++------------- 1 file changed, 41 insertions(+), 71 deletions(-) diff --git a/packages/repeater/src/lib/Repeater.spec.ts b/packages/repeater/src/lib/Repeater.spec.ts index 79d43eb3..0b61b963 100644 --- a/packages/repeater/src/lib/Repeater.spec.ts +++ b/packages/repeater/src/lib/Repeater.spec.ts @@ -8,7 +8,7 @@ import { RepeaterServerRequestEvent } from './RepeaterServer'; import { RepeaterCommands } from './RepeaterCommands'; -import { Logger } from '@sectester/core'; +import { delay, Logger } from '@sectester/core'; import { anything, instance, @@ -125,21 +125,17 @@ describe('Repeater', () => { it(`should subscribe to ${RepeaterServerEvents.UPDATE_AVAILABLE} and proceed on event`, async () => { // arrange - let eventHandler!: (event: unknown) => void; + const event = { version: '1.0.0' }; when( mockedRepeaterServer.on( RepeaterServerEvents.UPDATE_AVAILABLE, anything() ) - ).thenCall((_, handler) => { - eventHandler = handler; - }); - - await sut.start(); + ).thenCall((_, handler) => handler(event)); // act - eventHandler({ version: '1.0.0' }); + await sut.start(); // assert verify( @@ -153,60 +149,51 @@ describe('Repeater', () => { it(`should subscribe to ${RepeaterServerEvents.REQUEST} and proceed on event`, async () => { // arrange - let eventHandler!: (event: unknown) => Promise; - - when( - mockedRepeaterServer.on(RepeaterServerEvents.REQUEST, anything()) - ).thenCall((_, handler) => { - eventHandler = handler; - }); - - const requestEvent: RepeaterServerRequestEvent = { + const event: RepeaterServerRequestEvent = { protocol: Protocol.HTTP, url: 'http://foo.bar', method: 'GET' }; - const request = new Request(requestEvent); + const request = new Request(event); const response = new Response({ protocol: Protocol.HTTP, statusCode: 200 }); + when( + mockedRepeaterServer.on(RepeaterServerEvents.REQUEST, anything()) + ).thenCall((_, handler) => setImmediate(() => handler(event))); + when(repeaterCommands.sendRequest(objectContaining(request))).thenResolve( response ); - await sut.start(); - // act - const result = await eventHandler(requestEvent); + await sut.start(); // assert - expect(result).toMatchObject(response); + await delay(200); + verify(repeaterCommands.sendRequest(objectContaining(request))).once(); }); it(`should subscribe to ${RepeaterServerEvents.RECONNECT_ATTEMPT} and proceed on event`, async () => { // arrange - let eventHandler!: (event: unknown) => void; + const event = { + attempt: 1, + maxAttempts: 3 + }; when( mockedRepeaterServer.on( RepeaterServerEvents.RECONNECT_ATTEMPT, anything() ) - ).thenCall((_, handler) => { - eventHandler = handler; - }); - - await sut.start(); + ).thenCall((_, handler) => handler(event)); // act - eventHandler({ - attempt: 1, - maxAttempts: 3 - }); + await sut.start(); // assert verify( @@ -220,21 +207,17 @@ describe('Repeater', () => { it(`should subscribe to ${RepeaterServerEvents.ERROR} and proceed on error`, async () => { // arrange - let eventHandler!: (event: unknown) => void; + const event = { + code: RepeaterErrorCodes.UNKNOWN_ERROR, + message: 'error' + }; when( mockedRepeaterServer.on(RepeaterServerEvents.ERROR, anything()) - ).thenCall((_, handler) => { - eventHandler = handler; - }); - - await sut.start(); + ).thenCall((_, handler) => handler(event)); // act - eventHandler({ - code: RepeaterErrorCodes.UNKNOWN_ERROR, - message: 'error' - }); + await sut.start(); // assert verify(mockedLogger.error('error')).once(); @@ -242,24 +225,21 @@ describe('Repeater', () => { it(`should subscribe to ${RepeaterServerEvents.ERROR} and proceed on critical error`, async () => { // arrange - let eventHandler!: (event: unknown) => void; + const event = { + code: RepeaterErrorCodes.UNEXPECTED_ERROR, + message: 'unexpected error', + remediation: 'remediation' + }; when( mockedRepeaterServer.on(RepeaterServerEvents.ERROR, anything()) - ).thenCall((_, handler) => { - eventHandler = handler; - }); - - await sut.start(); + ).thenCall((_, handler) => setImmediate(() => handler(event))); // act - eventHandler({ - code: RepeaterErrorCodes.UNEXPECTED_ERROR, - message: 'unexpected error', - remediation: 'remediation' - }); + await sut.start(); // assert + await delay(200); verify( mockedLogger.error( '%s: %s. %s', @@ -274,47 +254,37 @@ describe('Repeater', () => { it(`should subscribe to ${RepeaterServerEvents.RECONNECTION_FAILED} and proceed on event`, async () => { // arrange const error = new Error('test error'); - - let eventHandler!: (event: unknown) => void; + const event = { + error + }; when( mockedRepeaterServer.on( RepeaterServerEvents.RECONNECTION_FAILED, anything() ) - ).thenCall((_, handler) => { - eventHandler = handler; - }); - - await sut.start(); + ).thenCall((_, handler) => setImmediate(() => handler(event))); // act - eventHandler({ - error - }); + await sut.start(); // assert + await delay(200); verify(mockedLogger.error(error.message)).once(); verify(mockedRepeaterServer.disconnect()).once(); }); it(`should subscribe to ${RepeaterServerEvents.RECONNECTION_SUCCEEDED} and proceed on event`, async () => { // arrange - let eventHandler!: () => void; - when( mockedRepeaterServer.on( RepeaterServerEvents.RECONNECTION_SUCCEEDED, anything() ) - ).thenCall((_, handler) => { - eventHandler = handler; - }); - - await sut.start(); + ).thenCall((_, handler) => handler()); // act - eventHandler(); + await sut.start(); // assert verify(