diff --git a/docs/backend-architecture.md b/docs/backend-architecture.md index 32e705fcea..55a9d10276 100644 --- a/docs/backend-architecture.md +++ b/docs/backend-architecture.md @@ -41,8 +41,8 @@ Currently, `usecases` returns `HTTP` oriented objects when an exception occurs, Every request handled by the `HTTP` API (`/api`, not the static files) and every `gRPC` call is logged once handled, by `ApiRequestLogger` (`HTTP`: `accessLogMiddleware`, `gRPC`: `bindGRPCController`). The entry is a single-line `JSON`, logged by the `API` logger **to the local log only** (it is not forwarded to InfoLogger). `HTTP` and `gRPC` entries share the same structure, so that they can be stored and aggregated together in OpenSearch: ```json -{"type":"api_request","protocol":"http","method":"GET","route":"/api/runs","status":200,"durationMs":42,"userId":7,"bytes":18234,"params":"{\"filter\":{\"runNumbers\":\"1,2\"},\"page\":{\"limit\":\"50\"}}"} -{"type":"api_request","protocol":"grpc","method":null,"route":"/o2.bookkeeping.RunService/Get","status":0,"durationMs":12,"userId":null,"bytes":null,"params":"{\"runNumber\":12}"} +{"type":"api_request","protocol":"http","method":"GET","route":"/api/runs","status":200,"durationMs":42,"userId":7,"bytes":18234,"client":"browser","device":"desktop","params":"{\"filter\":{\"runNumbers\":\"1,2\"},\"page\":{\"limit\":\"50\"}}"} +{"type":"api_request","protocol":"grpc","method":null,"route":"/o2.bookkeeping.RunService/Get","status":0,"durationMs":12,"userId":null,"bytes":null,"client":"grpc-node","device":null,"params":"{\"runNumber\":12}"} ``` - `type`: always `api_request` @@ -53,7 +53,9 @@ Every request handled by the `HTTP` API (`/api`, not the static files) and every - `durationMs`: time between the reception of the request by the middleware (`HTTP`) or the call (`gRPC`) and the end of the response, so it includes the authentication - `userId`: the id of the user, `null` if not authenticated - `bytes`: the `content-length` of the `HTTP` response, `null` if not provided and for `gRPC` -- `params`: the `HTTP` query string (the `token` parameter is removed and the body is never logged) or the `gRPC` request message, as a `JSON` **string** (not an object, because the structure varies from one endpoint to another and the types are not the same between protocols, which would lead to conflicts in OpenSearch mappings). It is omitted when empty and truncated (with a trailing `…`) when it exceeds 1000 characters +- `client`: the kind of client, identified from the `User-Agent` it declares (`browser`, `curl`, `wget`, `python`, `node`, `go`, `java`, `postman`, `grpc-node`, `grpc-cxx`, `grpc-python`, `grpc-go`, `grpc-java`, `grpc-other` or `unknown`). It is self-reported, so it is meant for statistics only and must never be used for security purposes. The proxy in front of the application (for example nginx) must forward the `User-Agent` header, which is the default behavior +- `device`: for browsers only (`null` otherwise), `phone`, `tablet` or `desktop`, deduced from the `User-Agent` and, for chromium based browsers, the `Sec-CH-UA-Mobile` header. An iPad requesting the desktop version of websites (default since iPadOS 13) can not be told apart from a Mac +- `params`: the `HTTP` query string (the `token` parameter is removed and the body is never logged) or the `gRPC` request message, as a `JSON` **string** (not an object, because the structure varies from one endpoint to another and the types are not the same between protocols, which would lead to conflicts in OpenSearch mappings). It is omitted when empty and truncated (with a trailing `…`) when it exceeds 500 characters - `aborted`: `true` only if the connection was closed before the response was completely sent (`HTTP` only) Nothing is logged in test mode. diff --git a/lib/domain/enums/ApiClients.js b/lib/domain/enums/ApiClients.js new file mode 100644 index 0000000000..e91a0e8664 --- /dev/null +++ b/lib/domain/enums/ApiClients.js @@ -0,0 +1,33 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +/** + * Types of clients of the API, as identified from the user agent the request contains + */ +exports.ApiClients = Object.freeze({ + BROWSER: 'browser', + CURL: 'curl', + WGET: 'wget', + PYTHON: 'python', + NODE: 'node', + GO: 'go', + JAVA: 'java', + POSTMAN: 'postman', + GRPC_NODE: 'grpc-node', + GRPC_CXX: 'grpc-cxx', + GRPC_PYTHON: 'grpc-python', + GRPC_GO: 'grpc-go', + GRPC_JAVA: 'grpc-java', + GRPC_OTHER: 'grpc-other', + UNKNOWN: 'unknown', +}); diff --git a/lib/domain/enums/ApiDevices.js b/lib/domain/enums/ApiDevices.js new file mode 100644 index 0000000000..52f7b30b28 --- /dev/null +++ b/lib/domain/enums/ApiDevices.js @@ -0,0 +1,21 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +/** + * Types of devices from which the API is used by a browser + */ +exports.ApiDevices = Object.freeze({ + PHONE: 'phone', + TABLET: 'tablet', + DESKTOP: 'desktop', +}); diff --git a/lib/server/gRPC/bindGRPCController.js b/lib/server/gRPC/bindGRPCController.js index 2af76100bb..2ea60d6048 100644 --- a/lib/server/gRPC/bindGRPCController.js +++ b/lib/server/gRPC/bindGRPCController.js @@ -181,6 +181,7 @@ const bindGRPCController = ( status, durationMs: stopTimer(), userId: call.identity?.id ?? null, + userAgent: call.metadata?.get('user-agent')?.[0], // The controller has not been reached (for example, a pre-processor failed): the request is still untouched params: requestSnapshot === undefined ? call.request : requestSnapshot, }); diff --git a/lib/server/middleware/accessLog.middleware.js b/lib/server/middleware/accessLog.middleware.js index 4e60246423..a83540a79a 100644 --- a/lib/server/middleware/accessLog.middleware.js +++ b/lib/server/middleware/accessLog.middleware.js @@ -59,6 +59,8 @@ exports.accessLogMiddleware = (configuration) => { durationMs: stopTimer(), userId: session?.id ?? null, bytes: Number.isNaN(contentLength) ? null : contentLength, + userAgent: request.headers?.['user-agent'], + mobileHint: request.headers?.['sec-ch-ua-mobile'], params, aborted: !response.writableFinished, }); diff --git a/lib/server/utilities/ApiRequestLogger.js b/lib/server/utilities/ApiRequestLogger.js index 090e90cac6..a8a1e20cd7 100644 --- a/lib/server/utilities/ApiRequestLogger.js +++ b/lib/server/utilities/ApiRequestLogger.js @@ -13,6 +13,7 @@ const { LogManager } = require('@aliceo2/web-ui'); const { isInTestMode } = require('../../utilities/env-utils'); +const { identifyClient } = require('./identifyClient.js'); const NANOSECONDS_PER_MILLISECOND = 1000000n; @@ -69,18 +70,21 @@ class ApiRequestLogger { * @param {number} request.durationMs the duration of the request * @param {number|null} [request.userId] the id of the user * @param {number|null} [request.bytes] the size of the response + * @param {string} [request.userAgent] the user agent declared by the client, used to identify its kind and device + * @param {string} [request.mobileHint] the value of the Sec-CH-UA-Mobile header * @param {object|string|null} [request.params] the HTTP query or gRPC request message (or its already stringified snapshot), logged as a * (truncated) JSON string, omitted if empty * @param {boolean} [request.aborted] true if the request has been interrupted before the end of the response * @return {void} */ - log({ protocol, method = null, route, status, durationMs, userId = null, bytes = null, params, aborted }) { + log({ protocol, method = null, route, status, durationMs, userId = null, bytes = null, userAgent, mobileHint, params, aborted }) { if (this._silent) { return; } try { - const entry = { type: 'api_request', protocol, method, route, status, durationMs, userId, bytes }; + const { client, device } = identifyClient({ userAgent, mobileHint }); + const entry = { type: 'api_request', protocol, method, route, status, durationMs, userId, bytes, client, device }; const stringifiedParams = ApiRequestLogger.stringifyParams(params); if (stringifiedParams !== null) { diff --git a/lib/server/utilities/identifyClient.js b/lib/server/utilities/identifyClient.js new file mode 100644 index 0000000000..724334d798 --- /dev/null +++ b/lib/server/utilities/identifyClient.js @@ -0,0 +1,81 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +const { ApiClients } = require('../../domain/enums/ApiClients.js'); +const { ApiDevices } = require('../../domain/enums/ApiDevices.js'); + +/** + * Ordered list of the clients patterns: Postman, the gRPC clients, then the specific tools must come before the generic browser pattern + * + * @type {Array<[RegExp, ApiClients]>} + */ +const CLIENTS_PATTERNS = [ + // Postman must come first: its HTTP user agent is PostmanRuntime/x, its gRPC one is the Node gRPC one suffixed (grpc-node-js/x-postman.y) + [/postman/i, ApiClients.POSTMAN], + // The gRPC clients must come first, as their user agent may contain the name of the underlying library (for example grpc-java-okhttp) + [/grpc-node/i, ApiClients.GRPC_NODE], + [/grpc-c\+\+/i, ApiClients.GRPC_CXX], + [/grpc-python/i, ApiClients.GRPC_PYTHON], + [/grpc-go/i, ApiClients.GRPC_GO], + [/grpc-java/i, ApiClients.GRPC_JAVA], + [/grpc-/i, ApiClients.GRPC_OTHER], + [/^curl\//i, ApiClients.CURL], + [/^wget\//i, ApiClients.WGET], + [/python-requests|python-urllib|python-httpx|aiohttp|^python\//i, ApiClients.PYTHON], + [/axios|node-fetch|undici|^node$/i, ApiClients.NODE], + [/go-http-client/i, ApiClients.GO], + // JVM clients: HttpURLConnection (Java/x), the JDK HttpClient, Apache HttpClient and OkHttp (also used by Android and Kotlin applications) + [/^Java\/|^Java-http-client|Apache-HttpClient|okhttp/i, ApiClients.JAVA], + [/^Mozilla\/5\.0.*(chrome|crios|safari|firefox|fxios|edg|opr)\//i, ApiClients.BROWSER], +]; + +/** + * Identify the device of a browser + * + * @param {string} userAgent the user agent of the browser + * @param {string} [mobileHint] the value of the Sec-CH-UA-Mobile header (sent by chromium based browsers: ?1 for phones, ?0 otherwise) + * @return {ApiDevices} the device + */ +const identifyDevice = (userAgent, mobileHint) => { + if (/iPad|Tablet/i.test(userAgent)) { + return ApiDevices.TABLET; + } + if (/iPhone|iPod|Windows Phone|Opera Mini/i.test(userAgent)) { + return ApiDevices.PHONE; + } + // Android tablets do not declare "Mobile" in their user agent, phones do + if (/Android/i.test(userAgent)) { + return /Mobile/i.test(userAgent) ? ApiDevices.PHONE : ApiDevices.TABLET; + } + return /Mobile/i.test(userAgent) || mobileHint === '?1' ? ApiDevices.PHONE : ApiDevices.DESKTOP; +}; + +/** + * Identify the client of the API from the user agent it declares. This is self-reported, so it is meant for statistics only and must never be + * used for security purposes. Note that an iPad requesting the desktop version of websites (default since iPadOS 13) can not be told apart from + * a Mac + * + * @param {object} [headers] the user agent and optional client hints + * @param {string} [headers.userAgent] the user agent + * @param {string} [headers.mobileHint] the value of the Sec-CH-UA-Mobile header + * @return {{client: ApiClients, device: (ApiDevices|null)}} the client and, for browsers only, the device (null otherwise) + */ +exports.identifyClient = ({ userAgent, mobileHint } = {}) => { + if (typeof userAgent !== 'string') { + return { client: ApiClients.UNKNOWN, device: null }; + } + + const [, client = ApiClients.UNKNOWN] = CLIENTS_PATTERNS.find(([pattern]) => pattern.test(userAgent)) ?? []; + + return { client, device: client === ApiClients.BROWSER ? identifyDevice(userAgent, mobileHint) : null }; +}; diff --git a/test/gRPC/serviceImplementation.test.js b/test/gRPC/serviceImplementation.test.js index 14f8ef172d..b018e5e097 100644 --- a/test/gRPC/serviceImplementation.test.js +++ b/test/gRPC/serviceImplementation.test.js @@ -169,7 +169,14 @@ module.exports = () => { it('should log successful calls with the user, the method and the request', async () => { const { adapter, logger } = getAdapter({ TestEnums: () => ({}), TestBigInts: () => ({ ui: 1n, i: 2n }), TestRepeated: () => ({}) }); - await adapter.TestBigInts({ identity: { id: 9 }, request: { ui: Long.fromString('12', true), i: Long.fromString('-5') } }, sinon.fake()); + await adapter.TestBigInts( + { + identity: { id: 9 }, + metadata: { get: (name) => name === 'user-agent' ? ['grpc-c++/1.60.0 grpc-c/37.0.0'] : [] }, + request: { ui: Long.fromString('12', true), i: Long.fromString('-5') }, + }, + sinon.fake(), + ); expect(logger.infoMessage.calledOnce).to.be.true; const { durationMs, ...entry } = JSON.parse(logger.infoMessage.firstCall.args[0]); @@ -182,6 +189,8 @@ module.exports = () => { status: 0, userId: 9, bytes: null, + client: 'grpc-cxx', + device: null, params: '{"ui":"12","i":"-5"}', }); }); diff --git a/test/lib/server/middleware/accessLog.middleware.test.js b/test/lib/server/middleware/accessLog.middleware.test.js index a34ea0e69c..0339ff7c20 100644 --- a/test/lib/server/middleware/accessLog.middleware.test.js +++ b/test/lib/server/middleware/accessLog.middleware.test.js @@ -41,7 +41,15 @@ module.exports = () => { it('should log a single line JSON entry with route pattern, status, duration, user, size and params once the response is sent', () => { const query = { filter: { runNumbers: '1,2' }, page: { limit: '10' } }; const { logger, next, response } = handle( - { method: 'GET', baseUrl: '/api', originalUrl: '/api/runs/12?a=1', route: { path: '/runs/:runNumber' }, session: { id: 7 }, query }, + { + method: 'GET', + baseUrl: '/api', + originalUrl: '/api/runs/12?a=1', + route: { path: '/runs/:runNumber' }, + session: { id: 7 }, + query, + headers: { 'user-agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Safari/604.1' }, + }, { headers: { 'content-length': '123' }, statusCode: 201 }, ); @@ -65,16 +73,40 @@ module.exports = () => { status: 201, userId: 7, bytes: 123, + client: 'browser', + device: 'phone', params: JSON.stringify(query), }); }); + it('should identify the client from the user agent and the mobile hint', () => { + const { logger, response } = handle({ + method: 'GET', + baseUrl: '/api', + originalUrl: '/api/runs', + route: { path: '/runs' }, + headers: { 'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) Chrome/120.0 Safari/537.36', 'sec-ch-ua-mobile': '?1' }, + }); + response.emit('finish'); + expect(JSON.parse(logger.infoMessage.firstCall.args[0])).to.include({ client: 'browser', device: 'phone' }); + + const { logger: curlLogger, response: curlResponse } = handle({ + method: 'GET', + baseUrl: '/api', + originalUrl: '/api/runs', + route: { path: '/runs' }, + headers: { 'user-agent': 'curl/8.4.0' }, + }); + curlResponse.emit('finish'); + expect(JSON.parse(curlLogger.infoMessage.firstCall.args[0])).to.include({ client: 'curl', device: null }); + }); + it('should log null user and size, no params, and the path when the request is anonymous and no route matched', () => { const { logger, response } = handle({ method: 'GET', baseUrl: '/api', originalUrl: '/api/unknown?x=1', query: {} }); response.emit('finish'); const entry = JSON.parse(logger.infoMessage.firstCall.args[0]); - expect(entry).to.include({ route: '/api/unknown', userId: null, bytes: null }); + expect(entry).to.include({ route: '/api/unknown', userId: null, bytes: null, client: 'unknown', device: null }); expect(entry).to.not.have.property('params'); expect(entry).to.not.have.property('aborted'); }); diff --git a/test/lib/server/utilities/ApiRequestLogger.test.js b/test/lib/server/utilities/ApiRequestLogger.test.js index 1ce64cc05b..fdc59e1077 100644 --- a/test/lib/server/utilities/ApiRequestLogger.test.js +++ b/test/lib/server/utilities/ApiRequestLogger.test.js @@ -22,7 +22,7 @@ module.exports = () => { const logger = { infoMessage: sinon.fake() }; const apiRequestLogger = new ApiRequestLogger({ logger, silent: false }); - apiRequestLogger.log({ ...request, userId: 4, params: { runNumber: 12 } }); + apiRequestLogger.log({ ...request, userId: 4, userAgent: 'grpc-node-js/1.9.0', params: { runNumber: 12 } }); apiRequestLogger.log({ protocol: 'http', method: 'GET', route: '/api/runs', status: 200, durationMs: 5, bytes: 10 }); expect(logger.infoMessage.callCount).to.equal(2); @@ -40,6 +40,8 @@ module.exports = () => { durationMs: 3, userId: 4, bytes: null, + client: 'grpc-node', + device: null, params: '{"runNumber":12}', }); expect(Object.keys(httpEntry)).to.eql(Object.keys(grpcEntry).filter((key) => key !== 'params')); diff --git a/test/lib/server/utilities/identifyClient.test.js b/test/lib/server/utilities/identifyClient.test.js new file mode 100644 index 0000000000..13410fc724 --- /dev/null +++ b/test/lib/server/utilities/identifyClient.test.js @@ -0,0 +1,85 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +const { expect } = require('chai'); +const { identifyClient } = require('../../../../lib/server/utilities/identifyClient.js'); + +const CHROME_DESKTOP = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; +const FIREFOX_DESKTOP = 'Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0'; +const SAFARI_MAC = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15'; +const IPHONE = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'; +const IPAD = 'Mozilla/5.0 (iPad; CPU OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1'; +const ANDROID_PHONE = 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36'; +const ANDROID_TABLET = 'Mozilla/5.0 (Linux; Android 13; SM-X700) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; +const FIREFOX_ANDROID_TABLET = 'Mozilla/5.0 (Android 13; Tablet; rv:121.0) Gecko/121.0 Firefox/121.0'; + +module.exports = () => { + it('should identify the tools and libraries from their default user agent', () => { + const expectations = { + 'curl/8.4.0': 'curl', + 'Wget/1.21.4': 'wget', + 'python-requests/2.31.0': 'python', + 'Python-urllib/3.11': 'python', + 'python-httpx/0.25.0': 'python', + 'Python/3.11 aiohttp/3.9.0': 'python', + 'axios/1.6.0': 'node', + node: 'node', + 'Go-http-client/1.1': 'go', + 'Java/17.0.2': 'java', + 'Java-http-client/11.0.20': 'java', + 'Apache-HttpClient/4.5.13 (Java/11.0.20)': 'java', + 'okhttp/4.12.0': 'java', + 'PostmanRuntime/7.36.0': 'postman', + 'grpc-node-js/1.14.0-postman.4': 'postman', + 'grpc-node-js/1.9.0': 'grpc-node', + 'grpc-c++/1.60.0 grpc-c/37.0.0 (linux; chttp2)': 'grpc-cxx', + 'grpc-python/1.60.0': 'grpc-python', + 'grpc-go/1.60.0': 'grpc-go', + 'grpc-java-netty/1.60.0': 'grpc-java', + 'grpc-java-okhttp/1.60.0': 'grpc-java', + 'grpc-dotnet/2.60.0': 'grpc-other', + 'Mozilla/5.0 (compatible; MyScraper)': 'unknown', + something: 'unknown', + }; + + for (const [userAgent, client] of Object.entries(expectations)) { + expect(identifyClient({ userAgent }), userAgent).to.eql({ client, device: null }); + } + }); + + it('should identify browsers and their device', () => { + const expectations = [ + [CHROME_DESKTOP, undefined, 'desktop'], + [FIREFOX_DESKTOP, undefined, 'desktop'], + [SAFARI_MAC, undefined, 'desktop'], + [IPHONE, undefined, 'phone'], + [IPAD, undefined, 'tablet'], + [ANDROID_PHONE, undefined, 'phone'], + [ANDROID_TABLET, undefined, 'tablet'], + [FIREFOX_ANDROID_TABLET, undefined, 'tablet'], + // Sec-CH-UA-Mobile is used when the user agent does not tell + [CHROME_DESKTOP, '?1', 'phone'], + [CHROME_DESKTOP, '?0', 'desktop'], + ]; + + for (const [userAgent, mobileHint, device] of expectations) { + expect(identifyClient({ userAgent, mobileHint }), `${userAgent} ${mobileHint}`).to.eql({ client: 'browser', device }); + } + }); + + it('should return an unknown client when the user agent is missing', () => { + expect(identifyClient()).to.eql({ client: 'unknown', device: null }); + expect(identifyClient({ userAgent: undefined })).to.eql({ client: 'unknown', device: null }); + expect(identifyClient({ userAgent: ['an', 'array'] })).to.eql({ client: 'unknown', device: null }); + }); +}; diff --git a/test/lib/server/utilities/index.js b/test/lib/server/utilities/index.js index 01e62f0c9f..00d18318a1 100644 --- a/test/lib/server/utilities/index.js +++ b/test/lib/server/utilities/index.js @@ -15,10 +15,12 @@ const appendPathTest = require('./appendPath.test.js'); const isAnEmptyShellTest = require('./isAnEmptyShell.test.js'); const bigIntConverter = require('./bigIntConverter.test.js'); const apiRequestLoggerTest = require('./ApiRequestLogger.test.js'); +const identifyClientTest = require('./identifyClient.test.js'); module.exports = () => { describe('ApiRequestLogger', apiRequestLoggerTest); describe('appendPath', appendPathTest); describe('bigIntConverter', bigIntConverter); + describe('identifyClient', identifyClientTest); describe('isAnEmptyShell', isAnEmptyShellTest); };