From da941d63b9bbafe899678d81eddf6241ebeed00b Mon Sep 17 00:00:00 2001 From: Jordy Cabannes Date: Mon, 12 Feb 2024 13:57:26 +0100 Subject: [PATCH 01/16] clean: application server api code --- packages/tom-server/src/application-server/index.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/tom-server/src/application-server/index.ts b/packages/tom-server/src/application-server/index.ts index 14ef984b..584e9167 100644 --- a/packages/tom-server/src/application-server/index.ts +++ b/packages/tom-server/src/application-server/index.ts @@ -27,11 +27,7 @@ export default class TwakeApplicationServer extendRoutes(this, parent) this.on('ephemeral_type: m.presence', (event: ClientEvent) => { - if ( - event.type === 'm.presence' && - 'presence' in event.content && - event.content.presence === 'online' - ) { + if (event.content.presence === 'online') { const matrixUserId = event.sender let ldapUid: string | null = null if (matrixUserId != null) { @@ -114,11 +110,7 @@ export default class TwakeApplicationServer }) this.on('state event | type: m.room.member', (event: ClientEvent) => { - if ( - event.type === 'm.room.member' && - 'membership' in event.content && - event.content.membership === 'leave' - ) { + if (event.content.membership === 'leave') { const matrixUserId = event.sender const targetUserId = event.state_key if ( From 4db15224e628ff41a85d8a6476af89303401a028 Mon Sep 17 00:00:00 2001 From: Jordy Cabannes Date: Sat, 2 Mar 2024 19:40:50 +0100 Subject: [PATCH 02/16] feat: init tom indexes in opensearch instance --- .../src/matrixDb/index.ts | 2 + packages/tom-server/package.json | 1 + packages/tom-server/src/config.json | 20 +- packages/tom-server/src/index.ts | 130 ++++++------ .../conf/opensearch-configuration.ts | 108 ++++++++++ .../search-engine-api/conf/tchat-mapping.json | 34 +++ .../tom-server/src/search-engine-api/index.ts | 57 +++++ .../matrix-db-rooms-repository.interface.ts | 16 ++ .../opensearch-repository.interface.ts | 108 ++++++++++ .../matrix-db-rooms.repository.ts | 90 ++++++++ .../repositories/opensearch.repository.ts | 200 ++++++++++++++++++ .../opensearch-service.interface.ts | 3 + .../services/opensearch.service.ts | 72 +++++++ .../src/search-engine-api/utils/constantes.ts | 2 + .../src/search-engine-api/utils/error.ts | 21 ++ packages/tom-server/src/types.ts | 12 +- server.mjs | 12 ++ 17 files changed, 823 insertions(+), 65 deletions(-) create mode 100644 packages/tom-server/src/search-engine-api/conf/opensearch-configuration.ts create mode 100644 packages/tom-server/src/search-engine-api/conf/tchat-mapping.json create mode 100644 packages/tom-server/src/search-engine-api/index.ts create mode 100644 packages/tom-server/src/search-engine-api/repositories/interfaces/matrix-db-rooms-repository.interface.ts create mode 100644 packages/tom-server/src/search-engine-api/repositories/interfaces/opensearch-repository.interface.ts create mode 100644 packages/tom-server/src/search-engine-api/repositories/matrix-db-rooms.repository.ts create mode 100644 packages/tom-server/src/search-engine-api/repositories/opensearch.repository.ts create mode 100644 packages/tom-server/src/search-engine-api/services/interfaces/opensearch-service.interface.ts create mode 100644 packages/tom-server/src/search-engine-api/services/opensearch.service.ts create mode 100644 packages/tom-server/src/search-engine-api/utils/constantes.ts create mode 100644 packages/tom-server/src/search-engine-api/utils/error.ts diff --git a/packages/matrix-identity-server/src/matrixDb/index.ts b/packages/matrix-identity-server/src/matrixDb/index.ts index dc8d4766..5122c2ce 100644 --- a/packages/matrix-identity-server/src/matrixDb/index.ts +++ b/packages/matrix-identity-server/src/matrixDb/index.ts @@ -9,6 +9,8 @@ type Collections = | 'room_stats_state' | 'local_media_repository' | 'room_aliases' + | 'room_stats_state' + | 'event_json' type Get = ( table: Collections, diff --git a/packages/tom-server/package.json b/packages/tom-server/package.json index b4bdd36e..25923d91 100644 --- a/packages/tom-server/package.json +++ b/packages/tom-server/package.json @@ -40,6 +40,7 @@ "test": "jest" }, "dependencies": { + "@opensearch-project/opensearch": "^2.5.0", "@twake/matrix-application-server": "*", "@twake/matrix-identity-server": "*", "lodash": "^4.17.21", diff --git a/packages/tom-server/src/config.json b/packages/tom-server/src/config.json index b687fd75..f2074d8c 100644 --- a/packages/tom-server/src/config.json +++ b/packages/tom-server/src/config.json @@ -30,9 +30,11 @@ "ldapjs_opts": {}, "logging": { "log_level": "info", - "log_transports": [{ - "type":"Console" - }], + "log_transports": [ + { + "type": "Console" + } + ], "silent": false, "exit_on_error": false, "default_meta": null, @@ -48,6 +50,16 @@ "matrix_database_ssl": false, "matrix_database_user": null, "oidc_issuer": "", + "opensearch_ca_cert_path": "", + "opensearch_host": "localhost", + "opensearch_is_activated": true, + "opensearch_max_retries": 7, + "opensearch_number_of_shards": 5, + "opensearch_number_of_replicas": 1, + "opensearch_password": "admin", + "opensearch_ssl": false, + "opensearch_user": "admin", + "opensearch_wait_for_active_shards": "1", "pepperCron": "0 0 * * *", "policies": null, "rate_limiting_window": 600000, @@ -78,4 +90,4 @@ "sms_api_login": "", "sms_api_key": "", "trust_x_forwarded_for": false -} \ No newline at end of file +} diff --git a/packages/tom-server/src/index.ts b/packages/tom-server/src/index.ts index ee9dd5b5..ec2e41aa 100644 --- a/packages/tom-server/src/index.ts +++ b/packages/tom-server/src/index.ts @@ -14,6 +14,8 @@ import IdServer from './identity-server' import mutualRoomsAPIRouter from './mutual-rooms-api' import privateNoteApiRouter from './private-note-api' import roomTagsAPIRouter from './room-tags-api' +import TwakeSearchEngine from './search-engine-api' +import { type IOpenSearchRepository } from './search-engine-api/repositories/interfaces/opensearch-repository.interface' import smsApiRouter from './sms-api' import type { Config, ConfigurationFile, TwakeIdentityServer } from './types' import userInfoAPIRouter from './user-info-api' @@ -26,6 +28,7 @@ export default class TwakeServer { endpoints: Router db?: TwakeDB matrixDb: MatrixDB + private _openSearchClient: IOpenSearchRepository | undefined ready!: Promise idServer!: TwakeIdentityServer @@ -72,6 +75,9 @@ export default class TwakeServer { cleanJobs(): void { this.idServer.cleanJobs() this.matrixDb.close() + if (this._openSearchClient != null) { + this._openSearchClient.close() + } } private _getConfigurationFile( @@ -96,71 +102,75 @@ export default class TwakeServer { } private async _initServer(confDesc?: ConfigDescription): Promise { - try { - await this.idServer.ready - await this.matrixDb.ready - await initializeDb(this) + await this.idServer.ready + await this.matrixDb.ready + await initializeDb(this) - const vaultServer = new VaultServer( - this.idServer.db, - this.idServer.authenticate - ) - const wellKnown = new WellKnown(this.conf) - const privateNoteApi = privateNoteApiRouter( - this.idServer.db, - this.conf, - this.idServer.authenticate, - this.logger - ) - const mutualRoolsApi = mutualRoomsAPIRouter( - this.conf, - this.matrixDb.db, - this.idServer.authenticate, - this.logger - ) - const roomTagsApi = roomTagsAPIRouter( - this.idServer.db, - this.matrixDb.db, - this.conf, - this.idServer.authenticate, - this.logger - ) - const userInfoApi = userInfoAPIRouter( - this.idServer, - this.conf, - this.logger - ) + const vaultServer = new VaultServer( + this.idServer.db, + this.idServer.authenticate + ) + const wellKnown = new WellKnown(this.conf) + const privateNoteApi = privateNoteApiRouter( + this.idServer.db, + this.conf, + this.idServer.authenticate, + this.logger + ) + const mutualRoolsApi = mutualRoomsAPIRouter( + this.conf, + this.matrixDb.db, + this.idServer.authenticate, + this.logger + ) + const roomTagsApi = roomTagsAPIRouter( + this.idServer.db, + this.matrixDb.db, + this.conf, + this.idServer.authenticate, + this.logger + ) + const userInfoApi = userInfoAPIRouter(this.idServer, this.conf, this.logger) + + const smsApi = smsApiRouter( + this.conf, + this.idServer.authenticate, + this.logger + ) - const smsApi = smsApiRouter( + this.endpoints.use(privateNoteApi) + this.endpoints.use(mutualRoolsApi) + this.endpoints.use(vaultServer.endpoints) + this.endpoints.use(roomTagsApi) + this.endpoints.use(userInfoApi) + this.endpoints.use(smsApi) + + if ( + this.conf.opensearch_is_activated != null && + this.conf.opensearch_is_activated + ) { + const searchEngineApi = new TwakeSearchEngine( + this.matrixDb, this.conf, - this.idServer.authenticate, - this.logger + this.logger, + confDesc ) + await searchEngineApi.ready + this._openSearchClient = searchEngineApi.openSearchRepository + this.endpoints.use(searchEngineApi.router.routes) + } - this.endpoints.use(privateNoteApi) - this.endpoints.use(mutualRoolsApi) - this.endpoints.use(vaultServer.endpoints) - this.endpoints.use(roomTagsApi) - this.endpoints.use(userInfoApi) - this.endpoints.use(smsApi) - - Object.keys(this.idServer.api.get).forEach((k) => { - this.endpoints.get(k, this.idServer.api.get[k]) - }) - Object.keys(this.idServer.api.post).forEach((k) => { - this.endpoints.post(k, this.idServer.api.post[k]) - }) - this.endpoints.use(vaultServer.endpoints) - Object.keys(wellKnown.api.get).forEach((k) => { - this.endpoints.get(k, wellKnown.api.get[k]) - }) + Object.keys(this.idServer.api.get).forEach((k) => { + this.endpoints.get(k, this.idServer.api.get[k]) + }) + Object.keys(this.idServer.api.post).forEach((k) => { + this.endpoints.post(k, this.idServer.api.post[k]) + }) + this.endpoints.use(vaultServer.endpoints) + Object.keys(wellKnown.api.get).forEach((k) => { + this.endpoints.get(k, wellKnown.api.get[k]) + }) - return true - } catch (error) { - /* istanbul ignore next */ - this.logger.error(`Unable to initialize server`, { error }) - /* istanbul ignore next */ - throw Error('Unable to initialize server', { cause: error }) - } + return true } } diff --git a/packages/tom-server/src/search-engine-api/conf/opensearch-configuration.ts b/packages/tom-server/src/search-engine-api/conf/opensearch-configuration.ts new file mode 100644 index 00000000..df617978 --- /dev/null +++ b/packages/tom-server/src/search-engine-api/conf/opensearch-configuration.ts @@ -0,0 +1,108 @@ +import { type ClientOptions } from '@opensearch-project/opensearch' +import { Utils } from '@twake/matrix-identity-server' +import fs from 'fs' +import { type Config } from '../../types' + +export class OpenSearchConfiguration { + private _host!: string + private _protocol!: string + private _username: string | undefined + private _password: string | undefined + private _caCertPath: string | undefined + private _maxRetries!: number + + constructor(config: Config) { + this._setUsername(config.opensearch_user) + this._setPassword(config.opensearch_password) + if (this._username == null && this._password != null) { + throw new Error('opensearch_user is missing') + } + if (this._username != null && this._password == null) { + throw new Error('opensearch_password is missing') + } + this._setHost(config.opensearch_host) + this._setProtocol(config.opensearch_ssl) + this._setCaCertPath(config.opensearch_ca_cert_path) + this._setMaxRetries(config.opensearch_max_retries) + } + + private _setHost(host: string | null | undefined): void { + if (host == null) { + throw new Error('opensearch_host is required when using OpenSearch') + } + if (typeof host !== 'string') { + throw new Error('opensearch_host must be a string') + } + if (host.match(Utils.hostnameRe) == null) { + throw new Error('opensearch_host is invalid') + } + this._host = host + } + + private _setProtocol(ssl: boolean | undefined): void { + if (ssl != null && typeof ssl !== 'boolean') { + throw new Error('opensearch_ssl must be a boolean') + } + this._protocol = ssl != null && ssl ? 'https' : 'http' + } + + private _setUsername(username: string | null | undefined): void { + if (username != null) { + if (typeof username !== 'string') { + throw new Error('opensearch_user must be a string') + } + this._username = username + } + } + + private _setPassword(password: string | null | undefined): void { + if (password != null) { + if (typeof password !== 'string') { + throw new Error('opensearch_password must be a string') + } + this._password = password + } + } + + private _setCaCertPath(caCertPath: string | null | undefined): void { + if (caCertPath != null) { + if (typeof caCertPath !== 'string') { + throw new Error('opensearch_ca_cert_path must be a string') + } + this._caCertPath = caCertPath + } + } + + private _setMaxRetries(maxRetries: number | undefined | null): void { + if (maxRetries != null && typeof maxRetries !== 'number') { + throw new Error('opensearch_max_retries must be a number') + } + this._maxRetries = maxRetries ?? 3 + } + + getMaxRetries(): number { + return this._maxRetries + } + + getClientOptions(): ClientOptions { + let auth = '' + if (this._username != null && this._password != null) { + auth = `${this._username}:${this._password}@` + } + + let options: ClientOptions = { + node: `${this._protocol}://${auth}${this._host}`, + maxRetries: this._maxRetries + } + + if (this._protocol === 'https' && this._caCertPath != null) { + options = { + ...options, + ssl: { + ca: fs.readFileSync(this._caCertPath, 'utf-8') + } + } + } + return options + } +} diff --git a/packages/tom-server/src/search-engine-api/conf/tchat-mapping.json b/packages/tom-server/src/search-engine-api/conf/tchat-mapping.json new file mode 100644 index 00000000..b2667b4c --- /dev/null +++ b/packages/tom-server/src/search-engine-api/conf/tchat-mapping.json @@ -0,0 +1,34 @@ +{ + "rooms": { + "mappings": { + "dynamic": "strict", + "properties": { + "name": { + "type": "text", + "analyzer": "standard" + } + } + } + }, + "messages": { + "mappings": { + "dynamic": "strict", + "properties": { + "room_id": { + "type": "keyword" + }, + "sender": { + "type": "text" + }, + "content": { + "type": "text", + "analyzer": "standard" + }, + "display_name": { + "type": "text", + "analyzer": "standard" + } + } + } + } +} diff --git a/packages/tom-server/src/search-engine-api/index.ts b/packages/tom-server/src/search-engine-api/index.ts new file mode 100644 index 00000000..a070fb18 --- /dev/null +++ b/packages/tom-server/src/search-engine-api/index.ts @@ -0,0 +1,57 @@ +import { type ConfigDescription } from '@twake/config-parser' +import { type TwakeLogger } from '@twake/logger' +import MatrixApplicationServer, { + type AppService +} from '@twake/matrix-application-server' +import { type MatrixDB } from '@twake/matrix-identity-server' +import { Router } from 'express' +import { type Config } from '../types' +import { type IMatrixDBRoomsRepository } from './repositories/interfaces/matrix-db-rooms-repository.interface' +import { type IOpenSearchRepository } from './repositories/interfaces/opensearch-repository.interface' +import { MatrixDBRoomsRepository } from './repositories/matrix-db-rooms.repository' +import { OpenSearchRepository } from './repositories/opensearch.repository' +import { type IOpenSearchService } from './services/interfaces/opensearch-service.interface' +import { OpenSearchService } from './services/opensearch.service' +import { formatErrorMessageForLog } from './utils/error' + +export default class TwakeSearchEngine + extends MatrixApplicationServer + implements AppService +{ + routes = Router() + declare conf: Config + ready!: Promise + public readonly openSearchService: IOpenSearchService + public readonly openSearchRepository: IOpenSearchRepository + public readonly matrixDBRoomsRepository: IMatrixDBRoomsRepository + + constructor( + matrixDb: MatrixDB, + conf: Config, + logger: TwakeLogger, + confDesc?: ConfigDescription + ) { + super(conf, confDesc, logger) + this.openSearchRepository = new OpenSearchRepository(this.conf, this.logger) + this.matrixDBRoomsRepository = new MatrixDBRoomsRepository(matrixDb) + this.openSearchService = new OpenSearchService( + this.openSearchRepository, + this.matrixDBRoomsRepository + ) + + this.ready = new Promise((resolve, reject) => { + this.openSearchService + .createTomIndexes() + .then(() => { + resolve() + }) + .catch((e) => { + reject(formatErrorMessageForLog(e)) + }) + }) + } + + close(): void { + this.openSearchRepository.close() + } +} diff --git a/packages/tom-server/src/search-engine-api/repositories/interfaces/matrix-db-rooms-repository.interface.ts b/packages/tom-server/src/search-engine-api/repositories/interfaces/matrix-db-rooms-repository.interface.ts new file mode 100644 index 00000000..d7f3fae7 --- /dev/null +++ b/packages/tom-server/src/search-engine-api/repositories/interfaces/matrix-db-rooms-repository.interface.ts @@ -0,0 +1,16 @@ +import { type ClientEvent } from '@twake/matrix-application-server' + +export interface IMatrixDBRoomsRepository { + getAllClearRoomsIds: () => Promise + getAllClearRoomsNames: () => Promise> + getMembersDisplayNames: ( + roomsIds: string[] + ) => Promise> + getAllClearRoomsMessages: () => Promise< + Array<{ + room_id: string + event_id: string + json: ClientEvent & { display_name: string | null } + }> + > +} diff --git a/packages/tom-server/src/search-engine-api/repositories/interfaces/opensearch-repository.interface.ts b/packages/tom-server/src/search-engine-api/repositories/interfaces/opensearch-repository.interface.ts new file mode 100644 index 00000000..8efab25e --- /dev/null +++ b/packages/tom-server/src/search-engine-api/repositories/interfaces/opensearch-repository.interface.ts @@ -0,0 +1,108 @@ +export enum EOpenSearchIndexingAction { + CREATE = 'create', + INDEX = 'index' +} + +export interface Document { + id: string + [key: string]: string +} + +export interface DocumentWithIndexingAction extends Document { + action: EOpenSearchIndexingAction.CREATE | EOpenSearchIndexingAction.INDEX +} + +export interface IOpenSearchClientError { + name: string + meta: { + body: T + statusCode: number + headers: { + server: string + date: string + 'content-type': string + 'content-length': string + connection: string + 'strict-transport-security': string + } + meta: { + context: string | null + request: { + params: { + method: string + path: string + body: string + querystring: string + headers: { + 'user-agent': string + 'content-type': string + 'content-length': string + } + timeout: number + } + options: { + maxRetries: number + } + id: number + } + name: string + connection: { + url: string + id: string + headers: Record + deadCount: number + resurrectTimeout: number + _openRequests: number + status: string + roles: { + data: boolean + ingest: boolean + } + } + attempts: number + aborted: boolean + } + } +} + +export interface IErrorOnMultipleDocuments { + took: number + timed_out: boolean + total: number + updated: number + deleted: number + batches: number + version_conflicts: number + noops: number + retries: { + bulk: number + search: number + } + throttled_millis: number + requests_per_second: number + throttled_until_millis: number + failures: Array<{ + index: string + id: string + cause: { + type: string + reason: string + index: string + shard: string + index_uuid: string + } + status: number + }> +} + +export interface IOpenSearchRepository { + createIndex: (index: string, mappings: Record) => Promise + indexDocuments: ( + documentsByIndex: Record< + string, + DocumentWithIndexingAction | DocumentWithIndexingAction[] + > + ) => Promise + indexExists: (index: string) => Promise + close: () => void +} diff --git a/packages/tom-server/src/search-engine-api/repositories/matrix-db-rooms.repository.ts b/packages/tom-server/src/search-engine-api/repositories/matrix-db-rooms.repository.ts new file mode 100644 index 00000000..a3b06953 --- /dev/null +++ b/packages/tom-server/src/search-engine-api/repositories/matrix-db-rooms.repository.ts @@ -0,0 +1,90 @@ +import { type ClientEvent } from '@twake/matrix-application-server' +import { type MatrixDB } from '@twake/matrix-identity-server' +import lodash from 'lodash' +import { type IMatrixDBRoomsRepository } from './interfaces/matrix-db-rooms-repository.interface' +const { groupBy, mapValues } = lodash + +export class MatrixDBRoomsRepository implements IMatrixDBRoomsRepository { + constructor(private readonly _matrixDb: MatrixDB) {} + + async getAllClearRoomsIds(): Promise { + return ( + (await this._matrixDb.getAll('room_stats_state', [ + 'room_id', + 'encryption' + ])) as Array<{ room_id: string; encryption: string | null }> + ) + .filter((room) => room.encryption == null) + .map((room) => room.room_id) + } + + async getAllClearRoomsNames(): Promise< + Array<{ room_id: string; name: string }> + > { + return ( + (await this._matrixDb.getAll('room_stats_state', [ + 'room_id', + 'encryption', + 'name' + ])) as Array<{ + room_id: string + encryption: string | null + name: string | null + }> + ) + .filter((room) => room.encryption == null && room.name != null) + .map((room) => ({ room_id: room.room_id, name: room.name as string })) + } + + async getMembersDisplayNames( + roomsIds: string[] + ): Promise> { + const results = (await this._matrixDb.get( + 'room_memberships', + ['user_id', 'display_name'], + { + room_id: roomsIds + } + )) as Array<{ user_id: string; display_name: string | null }> + + return mapValues( + groupBy(results, 'user_id'), + (res) => res.map((r) => r.display_name).pop() as string | null + ) + } + + async getAllClearRoomsMessages(): Promise< + Array<{ + room_id: string + event_id: string + json: ClientEvent & { display_name: string | null } + }> + > { + const clearRoomsIds = await this.getAllClearRoomsIds() + const displayNameByUserId = await this.getMembersDisplayNames(clearRoomsIds) + return ( + (await this._matrixDb.get('event_json', [ + 'room_id', + 'event_id', + 'json' + ])) as Array<{ room_id: string; event_id: string; json: string }> + ) + .map((event) => ({ + event_id: event.event_id, + room_id: event.room_id, + json: JSON.parse(event.json) as ClientEvent + })) + .filter( + (event) => + clearRoomsIds.includes(event.room_id) && + event.json.type === 'm.room.message' + ) + .map((event) => ({ + ...event, + json: { + ...event.json, + display_name: displayNameByUserId[event.json.sender] ?? null + } + })) + } +} diff --git a/packages/tom-server/src/search-engine-api/repositories/opensearch.repository.ts b/packages/tom-server/src/search-engine-api/repositories/opensearch.repository.ts new file mode 100644 index 00000000..22c03f7d --- /dev/null +++ b/packages/tom-server/src/search-engine-api/repositories/opensearch.repository.ts @@ -0,0 +1,200 @@ +import { Client, type ApiResponse } from '@opensearch-project/opensearch' +import { type TwakeLogger } from '@twake/logger' +import { type Config } from '../../types' +import { OpenSearchConfiguration } from '../conf/opensearch-configuration' +import { OpenSearchClientException } from '../utils/error' +import { + type DocumentWithIndexingAction, + type IErrorOnMultipleDocuments, + type IOpenSearchClientError, + type IOpenSearchRepository +} from './interfaces/opensearch-repository.interface' + +export class OpenSearchRepository implements IOpenSearchRepository { + private _numberOfShards!: number + private _numberOfReplicas!: number + private _waitForActiveShards!: string + private readonly _openSearchClient: Client + private readonly _requestOptions: Record + + constructor(config: Config, private readonly logger: TwakeLogger) { + this._setNumberOfShards(config.opensearch_number_of_shards) + this._setNumberOfReplicas(config.opensearch_number_of_replicas) + this._setWaitForActiveShards(config.opensearch_wait_for_active_shards) + const openSearchConfiguration = new OpenSearchConfiguration(config) + this._openSearchClient = new Client( + openSearchConfiguration.getClientOptions() + ) + this._requestOptions = { + maxRetries: openSearchConfiguration.getMaxRetries() + } + } + + private _setNumberOfShards(numberOfShards: number | undefined | null): void { + if (numberOfShards != null && typeof numberOfShards !== 'number') { + throw new Error('opensearch_number_of_shards must be a number') + } + this._numberOfShards = numberOfShards ?? 1 + } + + private _setNumberOfReplicas( + numberOfReplicas: number | undefined | null + ): void { + if (numberOfReplicas != null && typeof numberOfReplicas !== 'number') { + throw new Error('opensearch_number_of_replicas must be a number') + } + this._numberOfReplicas = numberOfReplicas ?? 1 + } + + private _setWaitForActiveShards( + waitForActiveShards: string | undefined | null + ): void { + if (waitForActiveShards != null) { + if (typeof waitForActiveShards !== 'string') { + throw new Error('opensearch_wait_for_active_shards must be a string') + } else if (waitForActiveShards?.match(/all|\d+/g) == null) { + throw new Error( + 'opensearch_wait_for_active_shards must be a string equal to a number or "all"' + ) + } + } + this._waitForActiveShards = waitForActiveShards ?? '1' + } + + private _checkOpenSearchApiResponse( + response: ApiResponse>, + additionalsValidStatusCode: number[] = [] + ): void { + if ( + response.statusCode != null && + String(response.statusCode).match(/^20[0-8]$/g) == null && + !additionalsValidStatusCode.includes(response.statusCode) + ) { + throw new OpenSearchClientException( + JSON.stringify(response.body, null, 2), + response.statusCode ?? 500 + ) + } + } + + private _checkException( + e: IOpenSearchClientError | Error + ): never { + if ('meta' in e) { + throw new OpenSearchClientException( + JSON.stringify( + 'failures' in e.meta.body ? e.meta.body.failures : e.meta.body, + null, + 2 + ), + e.meta.statusCode ?? 500 + ) + } + throw e + } + + async createIndex( + index: string, + mappings: Record + ): Promise { + try { + const response = await this._openSearchClient.indices.create( + { + index, + wait_for_active_shards: this._waitForActiveShards, + body: { + mappings, + settings: { + index: { + number_of_shards: this._numberOfShards, + number_of_replicas: this._numberOfReplicas + } + } + } + }, + this._requestOptions + ) + this._checkOpenSearchApiResponse(response) + this.logger.info(`Index ${index} created`) + } catch (e) { + this._checkException(e as Error) + } + } + + async indexDocuments( + documentsByIndex: Record< + string, + DocumentWithIndexingAction | DocumentWithIndexingAction[] + > + ): Promise { + try { + const response = await this._openSearchClient.bulk( + { + wait_for_active_shards: this._waitForActiveShards, + refresh: true, + body: Object.keys(documentsByIndex).reduce< + Array> + >((acc, index) => { + let documentDetails: any + if (Array.isArray(documentsByIndex[index])) { + documentDetails = ( + documentsByIndex[index] as DocumentWithIndexingAction[] + ).reduce>>((acc, doc) => { + const { action, id, ...properties } = doc + return [ + ...acc, + { + [action]: { + _id: id, + _index: index + } + }, + properties + ] + }, []) + } else { + const { action, id, ...properties } = documentsByIndex[ + index + ] as DocumentWithIndexingAction + documentDetails = [ + { + [action]: { + _id: id, + _index: index + } + }, + properties + ] + } + return [...acc, ...documentDetails] + }, []) + }, + this._requestOptions + ) + this._checkOpenSearchApiResponse(response) + } catch (e) { + this._checkException( + e as IOpenSearchClientError + ) + } + } + + async indexExists(index: string): Promise { + try { + const roomsIndexExists = await this._openSearchClient.indices.exists( + { + index + }, + this._requestOptions + ) + this._checkOpenSearchApiResponse(roomsIndexExists, [404]) + return roomsIndexExists?.body + } catch (e) { + this._checkException(e as Error) + } + } + + close(): void { + void this._openSearchClient.close() + } +} diff --git a/packages/tom-server/src/search-engine-api/services/interfaces/opensearch-service.interface.ts b/packages/tom-server/src/search-engine-api/services/interfaces/opensearch-service.interface.ts new file mode 100644 index 00000000..74baf1c4 --- /dev/null +++ b/packages/tom-server/src/search-engine-api/services/interfaces/opensearch-service.interface.ts @@ -0,0 +1,3 @@ +export interface IOpenSearchService { + createTomIndexes: () => Promise +} diff --git a/packages/tom-server/src/search-engine-api/services/opensearch.service.ts b/packages/tom-server/src/search-engine-api/services/opensearch.service.ts new file mode 100644 index 00000000..980a90b2 --- /dev/null +++ b/packages/tom-server/src/search-engine-api/services/opensearch.service.ts @@ -0,0 +1,72 @@ +import tchatMapping from '../conf/tchat-mapping.json' +import { type IMatrixDBRoomsRepository } from '../repositories/interfaces/matrix-db-rooms-repository.interface' +import { + EOpenSearchIndexingAction, + type DocumentWithIndexingAction, + type IOpenSearchRepository +} from '../repositories/interfaces/opensearch-repository.interface' +import { tomMessagesIndex, tomRoomsIndex } from '../utils/constantes' +import { type IOpenSearchService } from './interfaces/opensearch-service.interface' + +export class OpenSearchService implements IOpenSearchService { + constructor( + private readonly _openSearchRepository: IOpenSearchRepository, + private readonly _matrixDBRoomsRepository: IMatrixDBRoomsRepository + ) {} + + async createTomIndexes(): Promise { + const [roomsIndexExists, messagesIndexExists] = await Promise.all([ + this._openSearchRepository.indexExists(tomRoomsIndex), + this._openSearchRepository.indexExists(tomMessagesIndex) + ]) + if (!roomsIndexExists) { + await this._openSearchRepository.createIndex( + tomRoomsIndex, + tchatMapping.rooms.mappings + ) + } + + const clearRoomsNames = + await this._matrixDBRoomsRepository.getAllClearRoomsNames() + if (clearRoomsNames.length > 0 && !roomsIndexExists) { + await this._openSearchRepository.indexDocuments({ + [tomRoomsIndex]: clearRoomsNames.map((room) => ({ + id: room.room_id, + action: EOpenSearchIndexingAction.CREATE, + name: room.name + })) + }) + } + + if (!messagesIndexExists) { + await this._openSearchRepository.createIndex( + tomMessagesIndex, + tchatMapping.messages.mappings + ) + } + + const clearRoomsMessages = + await this._matrixDBRoomsRepository.getAllClearRoomsMessages() + + if (clearRoomsMessages.length > 0 && !messagesIndexExists) { + await this._openSearchRepository.indexDocuments({ + [tomMessagesIndex]: clearRoomsMessages.map((event) => { + let document: DocumentWithIndexingAction = { + id: event.event_id, + action: EOpenSearchIndexingAction.CREATE, + room_id: event.room_id, + content: event.json.content.body as string, + sender: event.json.sender + } + if (event.json.display_name != null) { + document = { + ...document, + display_name: event.json.display_name + } + } + return document + }) + }) + } + } +} diff --git a/packages/tom-server/src/search-engine-api/utils/constantes.ts b/packages/tom-server/src/search-engine-api/utils/constantes.ts new file mode 100644 index 00000000..8e7099eb --- /dev/null +++ b/packages/tom-server/src/search-engine-api/utils/constantes.ts @@ -0,0 +1,2 @@ +export const tomRoomsIndex = 'tom_rooms' +export const tomMessagesIndex = 'tom_messages' diff --git a/packages/tom-server/src/search-engine-api/utils/error.ts b/packages/tom-server/src/search-engine-api/utils/error.ts new file mode 100644 index 00000000..37714868 --- /dev/null +++ b/packages/tom-server/src/search-engine-api/utils/error.ts @@ -0,0 +1,21 @@ +import { AppServerAPIError } from '@twake/matrix-application-server' + +export class OpenSearchClientException extends Error { + constructor( + message?: string, + public readonly statusCode = 500, + options?: ErrorOptions + ) { + super(message, options) + } +} + +export const formatErrorMessageForLog = ( + error: OpenSearchClientException | AppServerAPIError | Error | string +): string => { + return error instanceof OpenSearchClientException || + error instanceof AppServerAPIError || + error instanceof Error + ? error.message + : JSON.stringify(error, null, 2) +} diff --git a/packages/tom-server/src/types.ts b/packages/tom-server/src/types.ts index c6c3cc2e..4942f807 100644 --- a/packages/tom-server/src/types.ts +++ b/packages/tom-server/src/types.ts @@ -6,9 +6,9 @@ import { type IdentityServerDb as MIdentityServerDb, type Utils as MUtils } from '@twake/matrix-identity-server' +import { type Request } from 'express' import type { PathOrFileDescriptor } from 'fs' import type AugmentedIdentityServer from './identity-server' -import { type Request } from 'express' export type expressAppHandler = MUtils.expressAppHandler export type AuthenticationFunction = MUtils.AuthenticationFunction @@ -24,6 +24,16 @@ export type Config = MConfig & matrix_server: string matrix_database_host: string oidc_issuer?: string + opensearch_ca_cert_path?: string + opensearch_host?: string + opensearch_is_activated?: boolean + opensearch_max_retries?: number + opensearch_number_of_shards?: number + opensearch_number_of_replicas?: number + opensearch_password?: string + opensearch_ssl?: boolean + opensearch_user?: string + opensearch_wait_for_active_shards?: string sms_api_key?: string sms_api_login?: string sms_api_url?: string diff --git a/server.mjs b/server.mjs index eb5b8124..4165394c 100644 --- a/server.mjs +++ b/server.mjs @@ -54,6 +54,18 @@ let conf = { ? JSON.parse(process.env.MATRIX_DATABASE_SSL) : false, oidc_issuer: process.env.OIDC_ISSUER, + opensearch_ca_cert_path: process.env.OPENSEARCH_CA_CERT_PATH, + opensearch_host: process.env.OPENSEARCH_HOST, + opensearch_is_activated: process.env.OPENSEARCH_IS_ACTIVATED || false, + opensearch_max_retries: +process.env.OPENSEARCH_MAX_RETRIES || null, + opensearch_number_of_shards: +process.env.OPENSEARCH_NUMBER_OF_SHARDS || null, + opensearch_number_of_replicas: + +process.env.OPENSEARCH_NUMBER_OF_REPLICAS || null, + opensearch_password: process.env.OPENSEARCH_PASSWORD, + opensearch_ssl: process.env.OPENSEARCH_SSL || false, + opensearch_user: process.env.OPENSEARCH_USER, + opensearch_wait_for_active_shards: + process.env.OPENSEARCH_WAIT_FOR_ACTIVE_SHARDS, pepperCron: process.env.PEPPER_CRON || '9 1 * * *', rate_limiting_window: process.env.RATE_LIMITING_WINDOW || 600000, rate_limiting_nb_requests: process.env.RATE_LIMITING_NB_REQUESTS || 100, From 808f5adeae3eef15dfcc352365453a2d9e16fd2f Mon Sep 17 00:00:00 2001 From: Jordy Cabannes Date: Sat, 2 Mar 2024 19:43:37 +0100 Subject: [PATCH 03/16] test: add opensearch config in existing tests --- .../src/application-server/__testData__/config.json | 1 + .../src/identity-server/__testData__/registerConf.json | 1 + .../src/identity-server/__testData__/termsConf.json | 1 + packages/tom-server/src/vault-api/__testData__/config.json | 7 ++++--- tsconfig-test.json | 2 +- 5 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/tom-server/src/application-server/__testData__/config.json b/packages/tom-server/src/application-server/__testData__/config.json index ac6e4d47..3759e3ac 100644 --- a/packages/tom-server/src/application-server/__testData__/config.json +++ b/packages/tom-server/src/application-server/__testData__/config.json @@ -23,5 +23,6 @@ "aliases": [{ "exclusive": false, "regex": "#_twake_.*" }], "users": [{ "exclusive": false, "regex": "@.*" }] }, + "opensearch_is_activated": false, "push_ephemeral": true } diff --git a/packages/tom-server/src/identity-server/__testData__/registerConf.json b/packages/tom-server/src/identity-server/__testData__/registerConf.json index 00b91d79..e48a5c77 100644 --- a/packages/tom-server/src/identity-server/__testData__/registerConf.json +++ b/packages/tom-server/src/identity-server/__testData__/registerConf.json @@ -16,6 +16,7 @@ "keys_depth": 5, "mail_link_delay": 7200, "matrix_server": "localhost", + "opensearch_is_activated": false, "server_name": "example.com", "smtp_sender": "yadd@debian.org", "smtp_server": "localhost", diff --git a/packages/tom-server/src/identity-server/__testData__/termsConf.json b/packages/tom-server/src/identity-server/__testData__/termsConf.json index 04afd6a7..7bab7bb2 100644 --- a/packages/tom-server/src/identity-server/__testData__/termsConf.json +++ b/packages/tom-server/src/identity-server/__testData__/termsConf.json @@ -15,6 +15,7 @@ "key_delay": 3600, "keys_depth": 5, "mail_link_delay": 7200, + "opensearch_is_activated": false, "server_name": "example.com", "smtp_sender": "yadd@debian.org", "smtp_server": "localhost", diff --git a/packages/tom-server/src/vault-api/__testData__/config.json b/packages/tom-server/src/vault-api/__testData__/config.json index 861c1497..8aeedfa2 100644 --- a/packages/tom-server/src/vault-api/__testData__/config.json +++ b/packages/tom-server/src/vault-api/__testData__/config.json @@ -1,8 +1,9 @@ { + "base_url": "http://example.com", "database_engine": "sqlite", "database_host": "./server.db", - "server_name": "matrix.org", - "base_url": "http://example.com", + "opensearch_is_activated": false, "registration_file_path": "registration.yaml", - "sender_localpart": "twake" + "sender_localpart": "twake", + "server_name": "matrix.org" } \ No newline at end of file diff --git a/tsconfig-test.json b/tsconfig-test.json index ba89be65..b548b0bd 100644 --- a/tsconfig-test.json +++ b/tsconfig-test.json @@ -2,7 +2,7 @@ "compilerOptions": { "strict": true, "module": "esnext", - "target": "es2015", + "target": "esnext", "esModuleInterop": true, "moduleResolution": "node", "lib": ["esnext"], From c66fd15fa612f3249f83b77cad9771cabadff48c Mon Sep 17 00:00:00 2001 From: Jordy Cabannes Date: Sat, 2 Mar 2024 19:59:31 +0100 Subject: [PATCH 04/16] feat: handle events related to messages and rooms --- .../src/interfaces.ts | 3 +- .../tom-server/src/search-engine-api/index.ts | 62 +++++++++- .../matrix-db-rooms-repository.interface.ts | 17 +++ .../opensearch-repository.interface.ts | 26 ++++ .../matrix-db-rooms.repository.ts | 63 +++++++++- .../repositories/opensearch.repository.ts | 116 +++++++++++++++++- .../opensearch-service.interface.ts | 8 ++ .../services/opensearch.service.ts | 93 ++++++++++++++ .../src/search-engine-api/utils/error.ts | 18 +++ 9 files changed, 401 insertions(+), 5 deletions(-) diff --git a/packages/matrix-application-server/src/interfaces.ts b/packages/matrix-application-server/src/interfaces.ts index dc9fcea2..49926c3d 100644 --- a/packages/matrix-application-server/src/interfaces.ts +++ b/packages/matrix-application-server/src/interfaces.ts @@ -3,7 +3,7 @@ export interface TransactionRequestBody { } export interface ClientEvent { - content: Record + content: Record> event_id: string origin_server_ts: number room_id: string @@ -11,6 +11,7 @@ export interface ClientEvent { state_key?: string type: string unsigned?: UnsignedData + redacts?: string } interface UnsignedData { diff --git a/packages/tom-server/src/search-engine-api/index.ts b/packages/tom-server/src/search-engine-api/index.ts index a070fb18..b41afbea 100644 --- a/packages/tom-server/src/search-engine-api/index.ts +++ b/packages/tom-server/src/search-engine-api/index.ts @@ -1,7 +1,8 @@ import { type ConfigDescription } from '@twake/config-parser' import { type TwakeLogger } from '@twake/logger' import MatrixApplicationServer, { - type AppService + type AppService, + type ClientEvent } from '@twake/matrix-application-server' import { type MatrixDB } from '@twake/matrix-identity-server' import { Router } from 'express' @@ -12,7 +13,7 @@ import { MatrixDBRoomsRepository } from './repositories/matrix-db-rooms.reposito import { OpenSearchRepository } from './repositories/opensearch.repository' import { type IOpenSearchService } from './services/interfaces/opensearch-service.interface' import { OpenSearchService } from './services/opensearch.service' -import { formatErrorMessageForLog } from './utils/error' +import { formatErrorMessageForLog, logError } from './utils/error' export default class TwakeSearchEngine extends MatrixApplicationServer @@ -43,6 +44,63 @@ export default class TwakeSearchEngine this.openSearchService .createTomIndexes() .then(() => { + this.on('state event | type: m.room.name', (event: ClientEvent) => { + this.openSearchService.updateRoomName(event).catch((e: any) => { + logError(this.logger, e) + }) + }) + + this.on( + 'state event | type: m.room.encryption', + (event: ClientEvent) => { + if (event.content.algorithm != null) { + this.openSearchService.deindexRoom(event).catch((e: any) => { + logError(this.logger, e) + }) + } + } + ) + + this.on('type: m.room.message', (event: ClientEvent) => { + if ( + event.content['m.new_content'] != null && + (event.content['m.relates_to'] as Record) + ?.event_id != null && + (event.content['m.relates_to'] as Record) + ?.rel_type === 'm.replace' + ) { + this.openSearchService.updateMessage(event).catch((e: any) => { + logError(this.logger, e) + }) + } else { + this.openSearchService.indexMessage(event).catch((e: any) => { + logError(this.logger, e) + }) + } + }) + + this.on('type: m.room.redaction', (event: ClientEvent) => { + if (event.redacts?.match(/^\$.{1,255}$/g) != null) { + this.openSearchService.deindexMessage(event).catch((e: any) => { + logError(this.logger, e) + }) + } + }) + + this.on('state event | type: m.room.member', (event: ClientEvent) => { + if ( + event.unsigned?.prev_content?.displayname != null && + event.content.displayname != null && + event.content.displayname !== + event.unsigned?.prev_content?.displayname + ) { + this.openSearchService + .updateDisplayName(event) + .catch((e: any) => { + logError(this.logger, e) + }) + } + }) resolve() }) .catch((e) => { diff --git a/packages/tom-server/src/search-engine-api/repositories/interfaces/matrix-db-rooms-repository.interface.ts b/packages/tom-server/src/search-engine-api/repositories/interfaces/matrix-db-rooms-repository.interface.ts index d7f3fae7..9a456ff3 100644 --- a/packages/tom-server/src/search-engine-api/repositories/interfaces/matrix-db-rooms-repository.interface.ts +++ b/packages/tom-server/src/search-engine-api/repositories/interfaces/matrix-db-rooms-repository.interface.ts @@ -1,7 +1,24 @@ import { type ClientEvent } from '@twake/matrix-application-server' +export interface IRoomDetail { + room_id: string + name: string | null + canonical_alias: string | null + join_rules: string | null + history_visibility: string | null + encryption: string | null + avatar: string | null + guest_access: string | null + is_federatable: boolean | null + topic: string | null + room_type: string | null +} + export interface IMatrixDBRoomsRepository { getAllClearRoomsIds: () => Promise + isEncryptedRoom: (roomId: string) => Promise + getRoomDetail: (roomId: string) => Promise + getUserDisplayName: (roomId: string, userId: string) => Promise getAllClearRoomsNames: () => Promise> getMembersDisplayNames: ( roomsIds: string[] diff --git a/packages/tom-server/src/search-engine-api/repositories/interfaces/opensearch-repository.interface.ts b/packages/tom-server/src/search-engine-api/repositories/interfaces/opensearch-repository.interface.ts index 8efab25e..1bd60743 100644 --- a/packages/tom-server/src/search-engine-api/repositories/interfaces/opensearch-repository.interface.ts +++ b/packages/tom-server/src/search-engine-api/repositories/interfaces/opensearch-repository.interface.ts @@ -95,14 +95,40 @@ export interface IErrorOnMultipleDocuments { }> } +export interface IErrorOnSingleDocument { + _index: string + _id: string + _version: number + result: string + _shards: { + total: number + successful: number + failed: number + } + _seq_no: number + _primary_term: number +} + export interface IOpenSearchRepository { createIndex: (index: string, mappings: Record) => Promise + indexDocument: (index: string, document: Document) => Promise indexDocuments: ( documentsByIndex: Record< string, DocumentWithIndexingAction | DocumentWithIndexingAction[] > ) => Promise + updateDocument: (index: string, document: Document) => Promise + updateDocuments: ( + index: string, + script: string, + query: Record + ) => Promise indexExists: (index: string) => Promise + deleteDocument: (index: string, id: string) => Promise + deleteDocuments: ( + index: string, + query: Record + ) => Promise close: () => void } diff --git a/packages/tom-server/src/search-engine-api/repositories/matrix-db-rooms.repository.ts b/packages/tom-server/src/search-engine-api/repositories/matrix-db-rooms.repository.ts index a3b06953..022606d2 100644 --- a/packages/tom-server/src/search-engine-api/repositories/matrix-db-rooms.repository.ts +++ b/packages/tom-server/src/search-engine-api/repositories/matrix-db-rooms.repository.ts @@ -1,12 +1,27 @@ import { type ClientEvent } from '@twake/matrix-application-server' import { type MatrixDB } from '@twake/matrix-identity-server' import lodash from 'lodash' -import { type IMatrixDBRoomsRepository } from './interfaces/matrix-db-rooms-repository.interface' +import { + type IMatrixDBRoomsRepository, + type IRoomDetail +} from './interfaces/matrix-db-rooms-repository.interface' const { groupBy, mapValues } = lodash export class MatrixDBRoomsRepository implements IMatrixDBRoomsRepository { constructor(private readonly _matrixDb: MatrixDB) {} + private _checkRoomStatsStateResult( + roomId: string, + results: Array> | IRoomDetail[] + ): void { + if (results.length === 0) { + throw new Error(`No room stats state found with id ${roomId}`) + } + if (results.length > 1) { + throw new Error(`More than one room found with id ${roomId}`) + } + } + async getAllClearRoomsIds(): Promise { return ( (await this._matrixDb.getAll('room_stats_state', [ @@ -18,6 +33,52 @@ export class MatrixDBRoomsRepository implements IMatrixDBRoomsRepository { .map((room) => room.room_id) } + async isEncryptedRoom(roomId: string): Promise { + const results = (await this._matrixDb.get( + 'room_stats_state', + ['encryption'], + { + room_id: roomId + } + )) as Array<{ encryption: string | null }> + this._checkRoomStatsStateResult(roomId, results) + return results[0].encryption != null + } + + async getRoomDetail(roomId: string): Promise { + const result = (await this._matrixDb.get('room_stats_state', ['*'], { + room_id: roomId + })) as unknown as IRoomDetail[] + this._checkRoomStatsStateResult(roomId, result) + return result[0] + } + + async getUserDisplayName( + roomId: string, + userId: string + ): Promise { + const memberships = (await this._matrixDb.get( + 'room_memberships', + ['display_name', 'membership'], + { + room_id: roomId, + user_id: userId + } + )) as Array<{ display_name: string | null; membership: string }> + if (memberships.length === 0) { + throw new Error( + `No memberships found for user ${userId} in room ${roomId}` + ) + } + const lastItem = memberships.pop() + if (lastItem?.membership !== 'join') { + throw new Error( + `User ${userId} is not allowed to participate in room ${roomId}` + ) + } + return lastItem.display_name + } + async getAllClearRoomsNames(): Promise< Array<{ room_id: string; name: string }> > { diff --git a/packages/tom-server/src/search-engine-api/repositories/opensearch.repository.ts b/packages/tom-server/src/search-engine-api/repositories/opensearch.repository.ts index 22c03f7d..0237d0a0 100644 --- a/packages/tom-server/src/search-engine-api/repositories/opensearch.repository.ts +++ b/packages/tom-server/src/search-engine-api/repositories/opensearch.repository.ts @@ -4,8 +4,10 @@ import { type Config } from '../../types' import { OpenSearchConfiguration } from '../conf/opensearch-configuration' import { OpenSearchClientException } from '../utils/error' import { + type Document, type DocumentWithIndexingAction, type IErrorOnMultipleDocuments, + type IErrorOnSingleDocument, type IOpenSearchClientError, type IOpenSearchRepository } from './interfaces/opensearch-repository.interface' @@ -78,7 +80,11 @@ export class OpenSearchRepository implements IOpenSearchRepository { } private _checkException( - e: IOpenSearchClientError | Error + e: + | IOpenSearchClientError< + IErrorOnMultipleDocuments | IErrorOnSingleDocument + > + | Error ): never { if ('meta' in e) { throw new OpenSearchClientException( @@ -121,6 +127,25 @@ export class OpenSearchRepository implements IOpenSearchRepository { } } + async indexDocument(index: string, document: Document): Promise { + try { + const { id, ...body } = document + const response = await this._openSearchClient.index( + { + id, + index, + wait_for_active_shards: this._waitForActiveShards, + body, + refresh: true + }, + this._requestOptions + ) + this._checkOpenSearchApiResponse(response) + } catch (e) { + this._checkException(e as IOpenSearchClientError) + } + } + async indexDocuments( documentsByIndex: Record< string, @@ -179,6 +204,95 @@ export class OpenSearchRepository implements IOpenSearchRepository { } } + async updateDocument(index: string, document: Document): Promise { + try { + const { id, ...updatedFields } = document + + const response = await this._openSearchClient.update( + { + id, + index, + wait_for_active_shards: this._waitForActiveShards, + body: { doc: updatedFields }, + refresh: true + }, + this._requestOptions + ) + this._checkOpenSearchApiResponse(response) + } catch (e) { + this._checkException(e as IOpenSearchClientError) + } + } + + async updateDocuments( + index: string, + script: string, + query: Record + ): Promise { + try { + const response = await this._openSearchClient.update_by_query( + { + index, + refresh: true, + conflicts: 'proceed', + wait_for_active_shards: this._waitForActiveShards, + body: { script: { source: script }, query } + }, + this._requestOptions + ) + this._checkOpenSearchApiResponse(response) + } catch (e) { + this._checkException( + e as IOpenSearchClientError + ) + } + } + + async deleteDocument(index: string, id: string): Promise { + try { + const documentExists = ( + await this._openSearchClient.exists({ + index, + id + }) + )?.body + if (documentExists) { + const response = await this._openSearchClient.delete({ + index, + id, + refresh: true, + wait_for_active_shards: this._waitForActiveShards + }) + this._checkOpenSearchApiResponse(response) + } + } catch (e) { + this._checkException(e as IOpenSearchClientError) + } + } + + async deleteDocuments( + index: string, + query: Record + ): Promise { + try { + const response = await this._openSearchClient.delete_by_query( + { + index, + refresh: true, + wait_for_active_shards: this._waitForActiveShards, + conflicts: 'proceed', + body: { + query + } + }, + this._requestOptions + ) + this._checkOpenSearchApiResponse(response) + } catch (e) { + this._checkException(e as IOpenSearchClientError) + } + } + async indexExists(index: string): Promise { try { const roomsIndexExists = await this._openSearchClient.indices.exists( diff --git a/packages/tom-server/src/search-engine-api/services/interfaces/opensearch-service.interface.ts b/packages/tom-server/src/search-engine-api/services/interfaces/opensearch-service.interface.ts index 74baf1c4..3a25c570 100644 --- a/packages/tom-server/src/search-engine-api/services/interfaces/opensearch-service.interface.ts +++ b/packages/tom-server/src/search-engine-api/services/interfaces/opensearch-service.interface.ts @@ -1,3 +1,11 @@ +import { type ClientEvent } from '@twake/matrix-application-server' + export interface IOpenSearchService { + updateRoomName: (event: ClientEvent) => Promise + updateDisplayName: (event: ClientEvent) => Promise + indexMessage: (event: ClientEvent) => Promise + updateMessage: (event: ClientEvent) => Promise + deindexRoom: (event: ClientEvent) => Promise createTomIndexes: () => Promise + deindexMessage: (event: ClientEvent) => Promise } diff --git a/packages/tom-server/src/search-engine-api/services/opensearch.service.ts b/packages/tom-server/src/search-engine-api/services/opensearch.service.ts index 980a90b2..0337705f 100644 --- a/packages/tom-server/src/search-engine-api/services/opensearch.service.ts +++ b/packages/tom-server/src/search-engine-api/services/opensearch.service.ts @@ -1,3 +1,4 @@ +import { type ClientEvent } from '@twake/matrix-application-server' import tchatMapping from '../conf/tchat-mapping.json' import { type IMatrixDBRoomsRepository } from '../repositories/interfaces/matrix-db-rooms-repository.interface' import { @@ -14,6 +15,98 @@ export class OpenSearchService implements IOpenSearchService { private readonly _matrixDBRoomsRepository: IMatrixDBRoomsRepository ) {} + async updateRoomName(event: ClientEvent): Promise { + const isEncryptedRoom = await this._matrixDBRoomsRepository.isEncryptedRoom( + event.room_id + ) + + if (!isEncryptedRoom) { + await this._openSearchRepository.indexDocument(tomRoomsIndex, { + id: event.room_id, + name: event.content.name as string + }) + } + } + + async updateDisplayName(event: ClientEvent): Promise { + await this._openSearchRepository.updateDocuments( + tomMessagesIndex, + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `ctx._source.display_name = "${event.content.displayname as string}"`, + { match: { sender: event.sender } } + ) + } + + async deindexRoom(event: ClientEvent): Promise { + await this._openSearchRepository.deleteDocument( + tomRoomsIndex, + event.room_id + ) + await this._openSearchRepository.deleteDocuments(tomMessagesIndex, { + match: { room_id: event.room_id } + }) + } + + async deindexMessage(event: ClientEvent): Promise { + await this._openSearchRepository.deleteDocument( + tomMessagesIndex, + event.redacts as string + ) + } + + async indexMessage(event: ClientEvent): Promise { + const roomDetail = await this._matrixDBRoomsRepository.getRoomDetail( + event.room_id + ) + if (roomDetail.encryption == null) { + const displayName = + await this._matrixDBRoomsRepository.getUserDisplayName( + event.room_id, + event.sender + ) + + let body: any = { + [tomMessagesIndex]: { + id: event.event_id, + action: EOpenSearchIndexingAction.CREATE, + room_id: event.room_id, + content: event.content.body, + sender: event.sender + } + } + if (displayName != null) { + body[tomMessagesIndex] = { + ...body[tomMessagesIndex], + display_name: displayName + } + } + if (roomDetail.name != null) { + body = { + ...body, + [tomRoomsIndex]: { + id: event.room_id, + action: EOpenSearchIndexingAction.INDEX, + name: roomDetail.name + } + } + } + await this._openSearchRepository.indexDocuments(body) + } + } + + async updateMessage(event: ClientEvent): Promise { + const isEncryptedRoom = await this._matrixDBRoomsRepository.isEncryptedRoom( + event.room_id + ) + + if (!isEncryptedRoom) { + await this._openSearchRepository.updateDocument(tomMessagesIndex, { + id: (event.content['m.relates_to'] as Record).event_id, + content: (event.content['m.new_content'] as Record).body + }) + } + } + async createTomIndexes(): Promise { const [roomsIndexExists, messagesIndexExists] = await Promise.all([ this._openSearchRepository.indexExists(tomRoomsIndex), diff --git a/packages/tom-server/src/search-engine-api/utils/error.ts b/packages/tom-server/src/search-engine-api/utils/error.ts index 37714868..d980bd5d 100644 --- a/packages/tom-server/src/search-engine-api/utils/error.ts +++ b/packages/tom-server/src/search-engine-api/utils/error.ts @@ -1,3 +1,4 @@ +import { type TwakeLogger } from '@twake/logger' import { AppServerAPIError } from '@twake/matrix-application-server' export class OpenSearchClientException extends Error { @@ -19,3 +20,20 @@ export const formatErrorMessageForLog = ( ? error.message : JSON.stringify(error, null, 2) } + +export const logError = ( + logger: TwakeLogger, + error: OpenSearchClientException | AppServerAPIError | Error | string, + additionnalDetails?: Record +): void => { + const errorDetail = additionnalDetails ?? {} + if ( + (error instanceof OpenSearchClientException || + error instanceof AppServerAPIError) && + error.statusCode != null + ) { + errorDetail.status = error.statusCode.toString() + } + + logger.error(formatErrorMessageForLog(error), errorDetail) +} From 33205e585d950ba832dae084aba8d27e6bba7d14 Mon Sep 17 00:00:00 2001 From: Jordy Cabannes Date: Sat, 2 Mar 2024 20:05:18 +0100 Subject: [PATCH 05/16] feat: add endpoint to restore opensearch indexes manually --- .../matrix-application-server/src/index.ts | 5 ++- .../controllers/opensearch.controller.ts | 36 +++++++++++++++ .../tom-server/src/search-engine-api/index.ts | 2 + .../src/search-engine-api/routes/index.ts | 44 +++++++++++++++++++ .../opensearch-service.interface.ts | 2 +- .../services/opensearch.service.ts | 9 ++-- 6 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 packages/tom-server/src/search-engine-api/controllers/opensearch.controller.ts create mode 100644 packages/tom-server/src/search-engine-api/routes/index.ts diff --git a/packages/matrix-application-server/src/index.ts b/packages/matrix-application-server/src/index.ts index abb83ecb..0937011d 100644 --- a/packages/matrix-application-server/src/index.ts +++ b/packages/matrix-application-server/src/index.ts @@ -26,7 +26,10 @@ export { EHttpMethod } from './routes' export { AppServerAPIError, validationErrorHandler, - type expressAppHandler + type expressAppHandler, + errorMiddleware, + allowCors, + methodNotAllowed } from './utils' export declare interface AppService { diff --git a/packages/tom-server/src/search-engine-api/controllers/opensearch.controller.ts b/packages/tom-server/src/search-engine-api/controllers/opensearch.controller.ts new file mode 100644 index 00000000..06a3c40f --- /dev/null +++ b/packages/tom-server/src/search-engine-api/controllers/opensearch.controller.ts @@ -0,0 +1,36 @@ +import { type TwakeLogger } from '@twake/logger' +import { AppServerAPIError } from '@twake/matrix-application-server' +import { type NextFunction, type Request, type Response } from 'express' +import { type IOpenSearchService } from '../services/interfaces/opensearch-service.interface' +import { logError } from '../utils/error' + +export class OpenSearchController { + restoreRoute = '/_twake/app/v1/opensearch/restore' + + constructor( + private readonly _opensearchService: IOpenSearchService, + private readonly _logger: TwakeLogger + ) {} + + postRestore = async ( + req: Request, + res: Response, + next: NextFunction + ): Promise => { + try { + await this._opensearchService.createTomIndexes(true) + res.sendStatus(204) + } catch (e: any) { + logError(this._logger, e, { + httpMethod: 'POST', + endpointPath: this.restoreRoute + }) + + next( + new AppServerAPIError({ + status: 500 + }) + ) + } + } +} diff --git a/packages/tom-server/src/search-engine-api/index.ts b/packages/tom-server/src/search-engine-api/index.ts index b41afbea..e9fdbccf 100644 --- a/packages/tom-server/src/search-engine-api/index.ts +++ b/packages/tom-server/src/search-engine-api/index.ts @@ -11,6 +11,7 @@ import { type IMatrixDBRoomsRepository } from './repositories/interfaces/matrix- import { type IOpenSearchRepository } from './repositories/interfaces/opensearch-repository.interface' import { MatrixDBRoomsRepository } from './repositories/matrix-db-rooms.repository' import { OpenSearchRepository } from './repositories/opensearch.repository' +import { extendRoutes } from './routes' import { type IOpenSearchService } from './services/interfaces/opensearch-service.interface' import { OpenSearchService } from './services/opensearch.service' import { formatErrorMessageForLog, logError } from './utils/error' @@ -44,6 +45,7 @@ export default class TwakeSearchEngine this.openSearchService .createTomIndexes() .then(() => { + extendRoutes(this) this.on('state event | type: m.room.name', (event: ClientEvent) => { this.openSearchService.updateRoomName(event).catch((e: any) => { logError(this.logger, e) diff --git a/packages/tom-server/src/search-engine-api/routes/index.ts b/packages/tom-server/src/search-engine-api/routes/index.ts new file mode 100644 index 00000000..25092268 --- /dev/null +++ b/packages/tom-server/src/search-engine-api/routes/index.ts @@ -0,0 +1,44 @@ +/* eslint-disable @typescript-eslint/no-misused-promises */ +import { + allowCors, + errorMiddleware, + methodNotAllowed +} from '@twake/matrix-application-server' +import type TwakeSearchEngine from '..' +import { OpenSearchController } from '../controllers/opensearch.controller' + +export const extendRoutes = (server: TwakeSearchEngine): void => { + const openSearchController = new OpenSearchController( + server.openSearchService, + server.logger + ) + + /** + * @openapi + * '/_twake/app/v1/opensearch/restore': + * post: + * tags: + * - Search Engine + * description: Restore OpenSearch indexes using Matrix homeserver database + * requestBody: + * content: + * application/json: + * schema: + * type: object + * responses: + * 204: + * description: Success + * content: + * application/json: + * schema: + * type: object + * 405: + * $ref: '#/components/responses/Unrecognized' + * 500: + * $ref: '#/components/responses/InternalServerError' + */ + server.router.routes + .route(openSearchController.restoreRoute) + .post(allowCors, openSearchController.postRestore, errorMiddleware) + .all(allowCors, methodNotAllowed, errorMiddleware) +} diff --git a/packages/tom-server/src/search-engine-api/services/interfaces/opensearch-service.interface.ts b/packages/tom-server/src/search-engine-api/services/interfaces/opensearch-service.interface.ts index 3a25c570..a0d643f7 100644 --- a/packages/tom-server/src/search-engine-api/services/interfaces/opensearch-service.interface.ts +++ b/packages/tom-server/src/search-engine-api/services/interfaces/opensearch-service.interface.ts @@ -6,6 +6,6 @@ export interface IOpenSearchService { indexMessage: (event: ClientEvent) => Promise updateMessage: (event: ClientEvent) => Promise deindexRoom: (event: ClientEvent) => Promise - createTomIndexes: () => Promise + createTomIndexes: (forceRestore?: boolean) => Promise deindexMessage: (event: ClientEvent) => Promise } diff --git a/packages/tom-server/src/search-engine-api/services/opensearch.service.ts b/packages/tom-server/src/search-engine-api/services/opensearch.service.ts index 0337705f..3e17e08a 100644 --- a/packages/tom-server/src/search-engine-api/services/opensearch.service.ts +++ b/packages/tom-server/src/search-engine-api/services/opensearch.service.ts @@ -107,7 +107,7 @@ export class OpenSearchService implements IOpenSearchService { } } - async createTomIndexes(): Promise { + async createTomIndexes(forceRestore = false): Promise { const [roomsIndexExists, messagesIndexExists] = await Promise.all([ this._openSearchRepository.indexExists(tomRoomsIndex), this._openSearchRepository.indexExists(tomMessagesIndex) @@ -121,7 +121,7 @@ export class OpenSearchService implements IOpenSearchService { const clearRoomsNames = await this._matrixDBRoomsRepository.getAllClearRoomsNames() - if (clearRoomsNames.length > 0 && !roomsIndexExists) { + if (clearRoomsNames.length > 0 && (!roomsIndexExists || forceRestore)) { await this._openSearchRepository.indexDocuments({ [tomRoomsIndex]: clearRoomsNames.map((room) => ({ id: room.room_id, @@ -141,7 +141,10 @@ export class OpenSearchService implements IOpenSearchService { const clearRoomsMessages = await this._matrixDBRoomsRepository.getAllClearRoomsMessages() - if (clearRoomsMessages.length > 0 && !messagesIndexExists) { + if ( + clearRoomsMessages.length > 0 && + (!messagesIndexExists || forceRestore) + ) { await this._openSearchRepository.indexDocuments({ [tomMessagesIndex]: clearRoomsMessages.map((event) => { let document: DocumentWithIndexingAction = { From 836973261f49f127fa6e38b0f9708cde7d030c23 Mon Sep 17 00:00:00 2001 From: Jordy Cabannes Date: Sat, 2 Mar 2024 20:07:29 +0100 Subject: [PATCH 06/16] feat: add search endpoint --- packages/tom-server/src/index.ts | 3 + .../controllers/search-engine.controller.ts | 63 +++++ .../tom-server/src/search-engine-api/index.ts | 11 +- .../matrix-db-rooms-repository.interface.ts | 7 + .../opensearch-repository.interface.ts | 12 + .../matrix-db-rooms.repository.ts | 69 ++++++ .../repositories/opensearch.repository.ts | 54 +++- .../src/search-engine-api/routes/index.ts | 230 ++++++++++++++++++ .../search-engine-service.interface.ts | 117 +++++++++ .../services/search-engine.service.ts | 133 ++++++++++ .../src/search-engine-api/utils/constantes.ts | 1 + 11 files changed, 697 insertions(+), 3 deletions(-) create mode 100644 packages/tom-server/src/search-engine-api/controllers/search-engine.controller.ts create mode 100644 packages/tom-server/src/search-engine-api/services/interfaces/search-engine-service.interface.ts create mode 100644 packages/tom-server/src/search-engine-api/services/search-engine.service.ts diff --git a/packages/tom-server/src/index.ts b/packages/tom-server/src/index.ts index ec2e41aa..b4cbe0cf 100644 --- a/packages/tom-server/src/index.ts +++ b/packages/tom-server/src/index.ts @@ -150,6 +150,9 @@ export default class TwakeServer { this.conf.opensearch_is_activated ) { const searchEngineApi = new TwakeSearchEngine( + this.idServer.db, + this.idServer.userDB, + this.idServer.authenticate, this.matrixDb, this.conf, this.logger, diff --git a/packages/tom-server/src/search-engine-api/controllers/search-engine.controller.ts b/packages/tom-server/src/search-engine-api/controllers/search-engine.controller.ts new file mode 100644 index 00000000..ac4ae2b4 --- /dev/null +++ b/packages/tom-server/src/search-engine-api/controllers/search-engine.controller.ts @@ -0,0 +1,63 @@ +import { type TwakeLogger } from '@twake/logger' +import { + AppServerAPIError, + EHttpMethod, + validationErrorHandler +} from '@twake/matrix-application-server' +import { type UserDB } from '@twake/matrix-identity-server' +import { type NextFunction, type Response } from 'express' +import { type AuthRequest } from '../../types' +import { type ISearchEngineService } from '../services/interfaces/search-engine-service.interface' +import { logError } from '../utils/error' + +export class SearchEngineController { + searchRoute = '/_twake/app/v1/search' + + constructor( + private readonly _searchEngineService: ISearchEngineService, + private readonly _userDB: UserDB, + private readonly _logger: TwakeLogger + ) {} + + postSearch = async ( + req: AuthRequest, + res: Response, + next: NextFunction + ): Promise => { + const userId = req.userId as string + try { + validationErrorHandler(req) + const match = userId.match(/^@(.*):/) + if (match == null) { + throw new Error(`Cannot extract user uid from matrix user id ${userId}`) + } + const results = await this._userDB.get('users', ['mail'], { + uid: match[1] + }) + if (results.length === 0) { + throw new Error(`User with user id ${match[1]} not found`) + } + const responseBody = + await this._searchEngineService.getMailsMessagesRoomsContainingSearchValue( + req.body.searchValue as string, + userId, + results[0].mail as string + ) + res.json(responseBody) + } catch (e: any) { + logError(this._logger, e, { + httpMethod: EHttpMethod.POST, + endpointPath: this.searchRoute, + matrixUserId: userId + }) + + next( + e instanceof AppServerAPIError + ? e + : new AppServerAPIError({ + status: 500 + }) + ) + } + } +} diff --git a/packages/tom-server/src/search-engine-api/index.ts b/packages/tom-server/src/search-engine-api/index.ts index e9fdbccf..d96e3821 100644 --- a/packages/tom-server/src/search-engine-api/index.ts +++ b/packages/tom-server/src/search-engine-api/index.ts @@ -4,9 +4,13 @@ import MatrixApplicationServer, { type AppService, type ClientEvent } from '@twake/matrix-application-server' -import { type MatrixDB } from '@twake/matrix-identity-server' +import { type MatrixDB, type UserDB } from '@twake/matrix-identity-server' import { Router } from 'express' -import { type Config } from '../types' +import { + type AuthenticationFunction, + type Config, + type IdentityServerDb +} from '../types' import { type IMatrixDBRoomsRepository } from './repositories/interfaces/matrix-db-rooms-repository.interface' import { type IOpenSearchRepository } from './repositories/interfaces/opensearch-repository.interface' import { MatrixDBRoomsRepository } from './repositories/matrix-db-rooms.repository' @@ -28,6 +32,9 @@ export default class TwakeSearchEngine public readonly matrixDBRoomsRepository: IMatrixDBRoomsRepository constructor( + public readonly idDb: IdentityServerDb, + public readonly userDB: UserDB, + public readonly authenticate: AuthenticationFunction, matrixDb: MatrixDB, conf: Config, logger: TwakeLogger, diff --git a/packages/tom-server/src/search-engine-api/repositories/interfaces/matrix-db-rooms-repository.interface.ts b/packages/tom-server/src/search-engine-api/repositories/interfaces/matrix-db-rooms-repository.interface.ts index 9a456ff3..4530ab1c 100644 --- a/packages/tom-server/src/search-engine-api/repositories/interfaces/matrix-db-rooms-repository.interface.ts +++ b/packages/tom-server/src/search-engine-api/repositories/interfaces/matrix-db-rooms-repository.interface.ts @@ -17,6 +17,7 @@ export interface IRoomDetail { export interface IMatrixDBRoomsRepository { getAllClearRoomsIds: () => Promise isEncryptedRoom: (roomId: string) => Promise + getRoomsDetails: (roomsIds: string[]) => Promise> getRoomDetail: (roomId: string) => Promise getUserDisplayName: (roomId: string, userId: string) => Promise getAllClearRoomsNames: () => Promise> @@ -30,4 +31,10 @@ export interface IMatrixDBRoomsRepository { json: ClientEvent & { display_name: string | null } }> > + getUserRoomsIds: (userId: string) => Promise + getDirectRoomsIds: (roomsIds: string[]) => Promise + getDirectRoomsAvatarUrl: ( + roomsIds: string[], + userId: string + ) => Promise> } diff --git a/packages/tom-server/src/search-engine-api/repositories/interfaces/opensearch-repository.interface.ts b/packages/tom-server/src/search-engine-api/repositories/interfaces/opensearch-repository.interface.ts index 1bd60743..8a850d6e 100644 --- a/packages/tom-server/src/search-engine-api/repositories/interfaces/opensearch-repository.interface.ts +++ b/packages/tom-server/src/search-engine-api/repositories/interfaces/opensearch-repository.interface.ts @@ -1,3 +1,5 @@ +import { type ApiResponse } from '@opensearch-project/opensearch' + export enum EOpenSearchIndexingAction { CREATE = 'create', INDEX = 'index' @@ -94,6 +96,12 @@ export interface IErrorOnMultipleDocuments { status: number }> } +export interface IQuery { + operator: string + field: string +} + +export type searchRequestBody = Record export interface IErrorOnSingleDocument { _index: string @@ -130,5 +138,9 @@ export interface IOpenSearchRepository { index: string, query: Record ) => Promise + searchOnMultipleIndexes: ( + searchValue: string, + elements: searchRequestBody + ) => Promise, unknown>> close: () => void } diff --git a/packages/tom-server/src/search-engine-api/repositories/matrix-db-rooms.repository.ts b/packages/tom-server/src/search-engine-api/repositories/matrix-db-rooms.repository.ts index 022606d2..f01af1dc 100644 --- a/packages/tom-server/src/search-engine-api/repositories/matrix-db-rooms.repository.ts +++ b/packages/tom-server/src/search-engine-api/repositories/matrix-db-rooms.repository.ts @@ -45,6 +45,18 @@ export class MatrixDBRoomsRepository implements IMatrixDBRoomsRepository { return results[0].encryption != null } + async getRoomsDetails( + roomsIds: string[] + ): Promise> { + const results = (await this._matrixDb.get('room_stats_state', ['*'], { + room_id: roomsIds + })) as unknown as IRoomDetail[] + return mapValues( + groupBy(results, 'room_id'), + (res) => res.pop() as IRoomDetail + ) + } + async getRoomDetail(roomId: string): Promise { const result = (await this._matrixDb.get('room_stats_state', ['*'], { room_id: roomId @@ -148,4 +160,61 @@ export class MatrixDBRoomsRepository implements IMatrixDBRoomsRepository { } })) } + + async getUserRoomsIds(userId: string): Promise { + const allUserRooms = (await this._matrixDb.get( + 'room_memberships', + ['room_id', 'membership'], + { + user_id: userId + } + )) as Array<{ room_id: string; membership: string }> + const roomMembershipsById = mapValues( + groupBy(allUserRooms, 'room_id'), + (membershipDetails) => membershipDetails.map((m) => m.membership) + ) + return Object.keys(roomMembershipsById).filter( + (roomId: string) => roomMembershipsById[roomId].pop() === 'join' + ) + } + + async getDirectRoomsIds(roomsIds: string[]): Promise { + return ( + (await this._matrixDb.get('event_json', ['room_id', 'json'], { + room_id: roomsIds + })) as Array<{ room_id: string; json: string }> + ) + .map((event) => ({ + room_id: event.room_id, + json: JSON.parse(event.json) as ClientEvent + })) + .filter( + (event) => + event.json.type === 'm.room.member' && event.json.content.is_direct + ) + .map((event) => event.room_id) + } + + async getDirectRoomsAvatarUrl( + roomsIds: string[], + userId: string + ): Promise> { + const results = ( + (await this._matrixDb.get( + 'room_memberships', + ['room_id', 'user_id', 'avatar_url'], + { + room_id: roomsIds + } + )) as Array<{ + room_id: string + user_id: string + avatar_url: string | null + }> + ).filter((membership) => membership.user_id !== userId) + return mapValues( + groupBy(results, 'room_id'), + (res) => res.map((r) => r.avatar_url).pop() as string | null + ) + } } diff --git a/packages/tom-server/src/search-engine-api/repositories/opensearch.repository.ts b/packages/tom-server/src/search-engine-api/repositories/opensearch.repository.ts index 0237d0a0..156c096f 100644 --- a/packages/tom-server/src/search-engine-api/repositories/opensearch.repository.ts +++ b/packages/tom-server/src/search-engine-api/repositories/opensearch.repository.ts @@ -9,7 +9,9 @@ import { type IErrorOnMultipleDocuments, type IErrorOnSingleDocument, type IOpenSearchClientError, - type IOpenSearchRepository + type IOpenSearchRepository, + type IQuery, + type searchRequestBody } from './interfaces/opensearch-repository.interface' export class OpenSearchRepository implements IOpenSearchRepository { @@ -308,6 +310,56 @@ export class OpenSearchRepository implements IOpenSearchRepository { } } + async searchOnMultipleIndexes( + searchValue: string, + elements: searchRequestBody + ): Promise, unknown>> { + try { + const response = await this._openSearchClient.msearch( + { + body: Object.keys(elements).reduce>>( + (acc, index) => [ + ...acc, + { + index + }, + { + size: 10000, + query: Array.isArray(elements[index]) + ? { + bool: { + should: (elements[index] as IQuery[]).map((elt) => ({ + [elt.operator]: { + [elt.field]: { + value: searchValue, + case_insensitive: true + } + } + })) + } + } + : { + [(elements[index] as IQuery).operator]: { + [(elements[index] as IQuery).field]: { + value: searchValue, + case_insensitive: true + } + } + } + } + ], + [] + ) + }, + this._requestOptions + ) + this._checkOpenSearchApiResponse(response) + return response + } catch (e) { + this._checkException(e as Error) + } + } + close(): void { void this._openSearchClient.close() } diff --git a/packages/tom-server/src/search-engine-api/routes/index.ts b/packages/tom-server/src/search-engine-api/routes/index.ts index 25092268..9f8d61d1 100644 --- a/packages/tom-server/src/search-engine-api/routes/index.ts +++ b/packages/tom-server/src/search-engine-api/routes/index.ts @@ -1,18 +1,248 @@ /* eslint-disable @typescript-eslint/no-misused-promises */ import { + EHttpMethod, allowCors, errorMiddleware, methodNotAllowed } from '@twake/matrix-application-server' +import { body } from 'express-validator' import type TwakeSearchEngine from '..' +import authMiddleware from '../../utils/middlewares/auth.middleware' import { OpenSearchController } from '../controllers/opensearch.controller' +import { SearchEngineController } from '../controllers/search-engine.controller' +import { SearchEngineService } from '../services/search-engine.service' export const extendRoutes = (server: TwakeSearchEngine): void => { + const searchEngineService = new SearchEngineService( + server.openSearchRepository, + server.matrixDBRoomsRepository + ) + const searchEngineController = new SearchEngineController( + searchEngineService, + server.userDB, + server.logger + ) + const openSearchController = new OpenSearchController( server.openSearchService, server.logger ) + /** + * @openapi + * '/_twake/app/v1/search': + * post: + * tags: + * - Search Engine + * description: Search performs with OpenSearch on Tchat messages and rooms + * requestBody: + * description: Object containing search query details + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * searchValue: + * type: string + * description: Value used to perform the search on rooms and messages data + * required: + * - searchValue + * example: + * searchValue: "hello" + * responses: + * 200: + * description: Success + * content: + * application/json: + * schema: + * type: object + * properties: + * rooms: + * type: array + * description: List of rooms whose name contains the search value + * items: + * type: object + * properties: + * room_id: + * type: string + * name: + * type: string + * avatar_url: + * type: string + * description: Url of the room's avatar + * messages: + * type: array + * description: List of messages whose content or/and sender display name contain the search value + * items: + * type: object + * properties: + * room_id: + * type: string + * event_id: + * type: string + * description: Id of the message + * content: + * type: string + * display_name: + * type: string + * description: Sender display name + * avatar_url: + * type: string + * description: Sender's avatar url if it is a direct chat, otherwise it is the room's avatar url + * room_name: + * type: string + * description: Room's name in case of the message is not part of a direct chat + * mails: + * type: array + * description: List of mails from Tmail whose meta or content contain the search value + * items: + * type: object + * properties: + * attachments: + * type: array + * items: + * type: object + * properties: + * contentDisposition: + * type: string + * fileExtension: + * type: string + * fileName: + * type: string + * mediaType: + * type: string + * subtype: + * type: string + * textContent: + * type: string + * bcc: + * type: array + * items: + * type: object + * properties: + * address: + * type: string + * domain: + * type: string + * name: + * type: string + * cc: + * type: array + * items: + * type: object + * properties: + * address: + * type: string + * domain: + * type: string + * name: + * type: string + * date: + * type: string + * from: + * type: array + * items: + * type: object + * properties: + * address: + * type: string + * domain: + * type: string + * name: + * type: string + * hasAttachment: + * type: boolean + * headers: + * type: array + * items: + * type: object + * properties: + * name: + * type: string + * value: + * type: string + * htmlBody: + * type: string + * isAnswered: + * type: boolean + * isDeleted: + * type: boolean + * isDraft: + * type: boolean + * isFlagged: + * type: boolean + * isRecent: + * type: boolean + * isUnread: + * type: boolean + * mailboxId: + * type: string + * mediaType: + * type: string + * messageId: + * type: string + * mimeMessageID: + * type: string + * modSeq: + * type: number + * saveDate: + * type: string + * sentDate: + * type: string + * size: + * type: number + * subject: + * type: array + * items: + * type: string + * subtype: + * type: string + * textBody: + * type: string + * threadId: + * type: string + * to: + * type: array + * items: + * type: object + * properties: + * address: + * type: string + * domain: + * type: string + * name: + * type: string + * uid: + * type: number + * userFlags: + * type: array + * items: + * type: string + * example: + * rooms: [{"room_id": "!dYqMpBXVQgKWETVAtJ:example.com", "name": "Hello world room", "avatar_url": "mxc://linagora.com/IBGFusHnOOzCNfePjaIVHpgR"}, {"room_id": "!dugSgNYwppGGoeJwYB:example.com", "name": "Worldwide room", "avatar_url": null}] + * messages: + * [{"room_id": "!dYqMpBXVQgKWETVAtJ:example.com", "event_id": "$c0hW6db_GUjk0NRBUuO12IyMpi48LE_tQK6sH3dkd1U", "content": "Hello world", "display_name": "Anakin Skywalker", "avatar_url": "mxc://linagora.com/IBGFusHnOOzCNfePjaIVHpgR", "room_name": "Hello world room"}, {"room_id": "!ftGqINYwppGGoeJwYB:example.com", "event_id": "$IUzFofxHCvvoHJ-k2nfx7OlWOO8AuPvlHHqkeJLzxJ8", "content": "Hello world my friends in direct chat", "display_name": "Luke Skywalker", "avatar_url": "mxc://matrix.org/wefh34uihSDRGhw34"}] + * mails: [{"id": "message1","attachments": [{"contentDisposition": "attachment","fileExtension": "jpg","fileName": "image1.jpg","mediaType": "image/jpeg","textContent": "A beautiful galaxy far, far away."}],"bcc": [{"address": "okenobi@example.com","domain": "example.com","name": "Obi-Wan Kenobi"}],"cc": [{"address": "pamidala@example.com","domain": "example.com","name": "Padme Amidala"}],"date": "2024-02-24T10:15:00Z","from": [{"address": "dmaul@example.com","domain": "example.com","name": "Dark Maul"}],"hasAttachment": true,"headers": [{ "name": "Header5", "value": "Value5" },{ "name": "Header6", "value": "Value6" }],"htmlBody": "

A beautiful galaxy far, far away.

","isAnswered": true,"isDeleted": false,"isDraft": false,"isFlagged": true,"isRecent": true,"isUnread": false,"mailboxId": "mailbox3","mediaType": "image/jpeg","messageId": "message3","mimeMessageID": "mimeMessageID3","modSeq": 98765,"saveDate": "2024-02-24T10:15:00Z","sentDate": "2024-02-24T10:15:00Z","size": 4096,"subject": ["Star Wars Message 3"],"subtype": "subtype3","textBody": "A beautiful galaxy far, far away.","threadId": "thread3","to": [{"address": "kren@example.com","domain": "example.com","name": "Kylo Ren"}],"uid": 987654,"userFlags": ["Flag4", "Flag5"]}] + * 400: + * $ref: '#/components/responses/BadRequest' + * 401: + * $ref: '#/components/responses/Unauthorized' + * 404: + * $ref: '#/components/responses/NotFound' + * 405: + * $ref: '#/components/responses/Unrecognized' + * 500: + * $ref: '#/components/responses/InternalServerError' + */ + server.router.addRoute( + searchEngineController.searchRoute, + EHttpMethod.POST, + searchEngineController.postSearch, + [body('searchValue').exists().isString()], + authMiddleware(server.authenticate, server.logger) + ) + /** * @openapi * '/_twake/app/v1/opensearch/restore': diff --git a/packages/tom-server/src/search-engine-api/services/interfaces/search-engine-service.interface.ts b/packages/tom-server/src/search-engine-api/services/interfaces/search-engine-service.interface.ts new file mode 100644 index 00000000..4e0e2576 --- /dev/null +++ b/packages/tom-server/src/search-engine-api/services/interfaces/search-engine-service.interface.ts @@ -0,0 +1,117 @@ +export interface ISearchEngineService { + getMailsMessagesRoomsContainingSearchValue: ( + searchValue: string, + userId: string, + userEmail: string + ) => Promise +} + +export interface IOpenSearchResponse { + took: number + timed_out: boolean + _shards: { + total: number + successful: number + skipped: number + failed: number + } + hits: { + total: { value: number; relation: string } + max_score: 1 + hits: Array> + } + status: number +} + +interface IOpenSearchResult { + _index: string + _id: string + _score: number + _source: T +} + +export interface IOpenSearchRoomResult { + name: string +} + +export interface IOpenSearchMessageResult { + room_id: string + content: string + sender: string + display_name: string | null +} + +export interface IOpenSearchMailResult { + attachments: Array<{ + contentDisposition: string + fileExtension: string + fileName: string + mediaType: string + subtype: string + textContent: string + }> + bcc: Array<{ + address: string + domain: string + name: string + }> + cc: Array<{ + address: string + domain: string + name: string + }> + date: string + from: Array<{ + address: string + domain: string + name: string + }> + hasAttachment: boolean + headers: Array<{ + name: string + value: string + }> + htmlBody: string + isAnswered: boolean + isDeleted: boolean + isDraft: boolean + isFlagged: boolean + isRecent: boolean + isUnread: boolean + mailboxId: string + mediaType: string + messageId: string + mimeMessageID: string + modSeq: number + saveDate: string + sentDate: string + size: number + subject: string[] + subtype: string + textBody: string + threadId: string + to: Array<{ + address: string + domain: string + name: string + }> + uid: number + userFlags: string[] +} + +export interface IResponseBody { + rooms: Array<{ + room_id: string + name: string + avatar_url: string | null + }> + messages: Array<{ + room_id: string + event_id: string + content: string + display_name: string | null + avatar_url: string | null + room_name: string | null + }> + mails: Array<{ id: string } & IOpenSearchMailResult> +} diff --git a/packages/tom-server/src/search-engine-api/services/search-engine.service.ts b/packages/tom-server/src/search-engine-api/services/search-engine.service.ts new file mode 100644 index 00000000..ca6e5683 --- /dev/null +++ b/packages/tom-server/src/search-engine-api/services/search-engine.service.ts @@ -0,0 +1,133 @@ +import { type IMatrixDBRoomsRepository } from '../repositories/interfaces/matrix-db-rooms-repository.interface' +import { type IOpenSearchRepository } from '../repositories/interfaces/opensearch-repository.interface' +import { + tmailMailsIndex, + tomMessagesIndex, + tomRoomsIndex +} from '../utils/constantes' +import { + type IOpenSearchMailResult, + type IOpenSearchMessageResult, + type IOpenSearchResponse, + type IOpenSearchRoomResult, + type IResponseBody, + type ISearchEngineService +} from './interfaces/search-engine-service.interface' + +export class SearchEngineService implements ISearchEngineService { + constructor( + private readonly _openSearchRepository: IOpenSearchRepository, + private readonly _matrixDBRoomsRepository: IMatrixDBRoomsRepository + ) {} + + async getMailsMessagesRoomsContainingSearchValue( + searchValue: string, + userId: string, + userEmail: string + ): Promise { + const regexp = `.*${searchValue}.*` + const operator = 'regexp' + const response = await this._openSearchRepository.searchOnMultipleIndexes( + regexp, + { + [tomRoomsIndex]: { operator, field: 'name' }, + [tomMessagesIndex]: [ + { operator, field: 'display_name' }, + { operator, field: 'content' } + ], + [tmailMailsIndex]: [ + { operator, field: 'attachments.fileName' }, + { operator, field: 'attachments.textContent' }, + { operator, field: 'bcc.address' }, + { operator, field: 'bcc.name' }, + { operator, field: 'cc.address' }, + { operator, field: 'cc.name' }, + { operator, field: 'from.address' }, + { operator, field: 'from.name' }, + { operator, field: 'to.address' }, + { operator, field: 'to.name' }, + { operator, field: 'subject' }, + { operator, field: 'textBody' }, + { operator, field: 'userFlags' } + ] + } + ) + + const userRoomsIds = await this._matrixDBRoomsRepository.getUserRoomsIds( + userId + ) + + const openSearchRoomsResult = ( + response.body.responses as Array< + IOpenSearchResponse + > + )[0].hits.hits.filter((osResult) => userRoomsIds.includes(osResult._id)) + + const openSearchMessagesResult = ( + response.body.responses as Array< + IOpenSearchResponse + > + )[1].hits.hits.filter((osResult) => + userRoomsIds.includes(osResult._source.room_id) + ) + + const openSearchMailsResult = ( + response.body.responses as Array< + IOpenSearchResponse + > + )[2].hits.hits.filter((osResult) => + osResult._source.bcc + .concat(osResult._source.cc, osResult._source.from, osResult._source.to) + .some((userDetail) => userDetail.address === userEmail) + ) + + const responseBody: IResponseBody = { + rooms: [], + messages: [], + mails: openSearchMailsResult.map((result) => ({ + id: result._id ?? result._source.messageId, + ...result._source + })) + } + + const roomsDetails = await this._matrixDBRoomsRepository.getRoomsDetails( + userRoomsIds + ) + + openSearchRoomsResult.forEach((osResult) => { + responseBody.rooms.push({ + room_id: osResult._id, + name: osResult._source.name, + avatar_url: roomsDetails[osResult._id]?.avatar + }) + }) + + const directRoomsIds = + await this._matrixDBRoomsRepository.getDirectRoomsIds(userRoomsIds) + + const directRoomsAvatarsUrls = + await this._matrixDBRoomsRepository.getDirectRoomsAvatarUrl( + directRoomsIds, + userId + ) + + openSearchMessagesResult.forEach((osResult) => { + const roomId = osResult._source.room_id + const message = { + room_id: roomId, + event_id: osResult._id, + content: osResult._source.content, + display_name: osResult._source.display_name + } + const isDirectRoom = directRoomsIds.includes(roomId) + responseBody.messages.push({ + ...message, + avatar_url: isDirectRoom + ? directRoomsAvatarsUrls[roomId] + : roomsDetails[roomId]?.avatar, + room_name: roomsDetails[roomId]?.name + }) + }) + return responseBody + } +} diff --git a/packages/tom-server/src/search-engine-api/utils/constantes.ts b/packages/tom-server/src/search-engine-api/utils/constantes.ts index 8e7099eb..dac9d7bf 100644 --- a/packages/tom-server/src/search-engine-api/utils/constantes.ts +++ b/packages/tom-server/src/search-engine-api/utils/constantes.ts @@ -1,2 +1,3 @@ export const tomRoomsIndex = 'tom_rooms' export const tomMessagesIndex = 'tom_messages' +export const tmailMailsIndex = 'mailbox_v2' From 65a6431d5204cb1527816422d600204f8a00fc53 Mon Sep 17 00:00:00 2001 From: Jordy Cabannes Date: Sat, 2 Mar 2024 20:08:39 +0100 Subject: [PATCH 07/16] chore: clean tsconfig.json --- packages/tom-server/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tom-server/tsconfig.json b/packages/tom-server/tsconfig.json index 304c5bc3..3b8bf1c3 100644 --- a/packages/tom-server/tsconfig.json +++ b/packages/tom-server/tsconfig.json @@ -3,5 +3,5 @@ "compilerOptions": { "outDir": "dist" }, - "include": ["src/**/*", "test/**/*", "singleton.ts"] + "include": ["src/**/*", "singleton.ts"] } From 0888c61bbb01b604a5cc57caea3ef2fb4a14bc81 Mon Sep 17 00:00:00 2001 From: Jordy Cabannes Date: Sat, 2 Mar 2024 20:09:54 +0100 Subject: [PATCH 08/16] test: server instanciation --- .../__testData__/config.json | 26 ++ .../tests/server-instanciation.test.ts | 287 ++++++++++++++++++ .../src/search-engine-api/tests/tsconfig.json | 4 + 3 files changed, 317 insertions(+) create mode 100644 packages/tom-server/src/search-engine-api/__testData__/config.json create mode 100644 packages/tom-server/src/search-engine-api/tests/server-instanciation.test.ts create mode 100644 packages/tom-server/src/search-engine-api/tests/tsconfig.json diff --git a/packages/tom-server/src/search-engine-api/__testData__/config.json b/packages/tom-server/src/search-engine-api/__testData__/config.json new file mode 100644 index 00000000..1fdee6f8 --- /dev/null +++ b/packages/tom-server/src/search-engine-api/__testData__/config.json @@ -0,0 +1,26 @@ +{ + "additional_features": true, + "base_url": "http://localhost:3000/", + "database_engine": "pg", + "database_host": "localhost:5433", + "database_name": "identity", + "database_user": "twake", + "database_password": "twake!1", + "ldap_base": "dc=example,dc=com", + "ldap_filter": "(ObjectClass=inetOrgPerson)", + "ldap_uri": "ldap://localhost:21390/", + "matrix_database_engine": "pg", + "matrix_server": "matrix.example.com:445", + "matrix_database_host": "localhost:5433", + "matrix_database_name": "synapse", + "matrix_database_password": "synapse!1", + "matrix_database_user": "synapse", + "opensearch_ca_cert_path": "./src/search-engine-api/__testData__/nginx/ssl/ca.pem", + "opensearch_host": "opensearch.example.com:445", + "opensearch_password": "admin", + "opensearch_ssl": true, + "opensearch_user": "admin", + "registration_file_path": "./src/search-engine-api/__testData__/synapse-data/registration.yaml", + "server_name": "example.com", + "userdb_engine": "ldap" +} \ No newline at end of file diff --git a/packages/tom-server/src/search-engine-api/tests/server-instanciation.test.ts b/packages/tom-server/src/search-engine-api/tests/server-instanciation.test.ts new file mode 100644 index 00000000..2448926b --- /dev/null +++ b/packages/tom-server/src/search-engine-api/tests/server-instanciation.test.ts @@ -0,0 +1,287 @@ +import { Router } from 'express' +import TwakeServer from '../..' +import defaultConfDesc from '../../config.json' +import { type Config } from '../../types' +import defaultConfig from '../__testData__/config.json' + +const mockExists = jest.fn().mockResolvedValue({ statusCode: 200, body: true }) +let testServer: TwakeServer + +jest.mock('@opensearch-project/opensearch', () => ({ + Client: jest.fn().mockImplementation(() => ({ + indices: { + exists: mockExists + }, + close: jest.fn() + })) +})) + +jest.mock('@twake/matrix-identity-server', () => ({ + default: jest.fn().mockImplementation(() => ({ + ready: Promise.resolve(true), + db: {}, + userDB: {}, + api: { get: {}, post: {} }, + cleanJobs: jest.fn().mockImplementation(() => testServer.logger.close()) + })), + MatrixDB: jest.fn().mockImplementation(() => ({ + ready: Promise.resolve(true), + close: jest.fn(), + get: jest.fn().mockResolvedValue([]), + getAll: jest.fn().mockResolvedValue([]) + })), + Utils: { + hostnameRe: + /^((([a-zA-Z0-9][-a-zA-Z0-9]*)?[a-zA-Z0-9])[.])*([a-zA-Z][-a-zA-Z0-9]*[a-zA-Z0-9]|[a-zA-Z])(:(\d+))?$/ + } +})) + +jest.mock('../../identity-server/index.ts', () => { + return function () { + return { + ready: Promise.resolve(true), + db: {}, + userDB: {}, + api: { get: {}, post: {} }, + cleanJobs: jest.fn().mockImplementation(() => testServer.logger.close()) + } + } +}) + +jest.mock('../../application-server/index.ts', () => { + return function () { + return { + router: { + routes: Router() + } + } + } +}) + +jest.mock('../../db/index.ts', () => jest.fn()) + +describe('Search engine API - Opensearch configuration', () => { + afterEach(() => { + if (testServer != null) testServer.cleanJobs() + }) + + it('should throw error if opensearch_user is defined and is not a string', async () => { + await expect(async () => { + testServer = new TwakeServer({ + ...defaultConfig, + opensearch_user: 123 + } as unknown as Config) + await testServer.ready + }).rejects.toThrow( + Error('Unable to initialize server', { + cause: new Error('opensearch_user must be a string') + }) + ) + }) + + it('should throw error if opensearch_password is defined and is not a string', async () => { + await expect(async () => { + testServer = new TwakeServer({ + ...defaultConfig, + opensearch_password: 123 + } as unknown as Config) + await testServer.ready + }).rejects.toThrow( + Error('Unable to initialize server', { + cause: new Error('opensearch_password must be a string') + }) + ) + }) + + it('should throw error if opensearch_password is defined and opensearch_user is not', async () => { + await expect(async () => { + const { opensearch_user: unusedVar, ...testConfig } = + defaultConfig as Config + const { opensearch_user: unusedVar2, ...testConfDesc } = defaultConfDesc + testServer = new TwakeServer(testConfig, testConfDesc) + await testServer.ready + }).rejects.toThrow( + Error('Unable to initialize server', { + cause: new Error('opensearch_user is missing') + }) + ) + }) + + it('should throw error if opensearch_user is defined and opensearch_password is not', async () => { + await expect(async () => { + const { opensearch_password: unusedVar, ...testConfig } = + defaultConfig as Config + const { opensearch_password: unusedVar2, ...testConfDesc } = + defaultConfDesc + testServer = new TwakeServer(testConfig, testConfDesc) + await testServer.ready + }).rejects.toThrow( + Error('Unable to initialize server', { + cause: new Error('opensearch_password is missing') + }) + ) + }) + + it('should throw error if opensearch_host not is defined', async () => { + await expect(async () => { + const { opensearch_host: unusedVar, ...testConfig } = + defaultConfig as Config + const { opensearch_host: unusedVar2, ...testConfDesc } = defaultConfDesc + testServer = new TwakeServer(testConfig, testConfDesc) + await testServer.ready + }).rejects.toThrow( + Error('Unable to initialize server', { + cause: new Error('opensearch_host is required when using OpenSearch') + }) + ) + }) + + it('should throw error if opensearch_host is not a string', async () => { + await expect(async () => { + testServer = new TwakeServer({ + ...defaultConfig, + opensearch_host: 123 + } as unknown as Config) + await testServer.ready + }).rejects.toThrow( + Error('Unable to initialize server', { + cause: new Error('opensearch_host must be a string') + }) + ) + }) + + it('should throw error if opensearch_host does not match hostname regular expression', async () => { + await expect(async () => { + testServer = new TwakeServer({ + ...(defaultConfig as Config), + opensearch_host: 'falsy_host' + }) + await testServer.ready + }).rejects.toThrow( + Error('Unable to initialize server', { + cause: new Error('opensearch_host is invalid') + }) + ) + }) + + it('should throw error if opensearch_ssl is defined and is not a boolean', async () => { + await expect(async () => { + testServer = new TwakeServer({ + ...defaultConfig, + opensearch_ssl: 123 + } as unknown as Config) + await testServer.ready + }).rejects.toThrow( + Error('Unable to initialize server', { + cause: new Error('opensearch_ssl must be a boolean') + }) + ) + }) + + it('should throw error if opensearch_ca_cert_path is defined and is not a string', async () => { + await expect(async () => { + testServer = new TwakeServer({ + ...defaultConfig, + opensearch_ca_cert_path: 123 + } as unknown as Config) + await testServer.ready + }).rejects.toThrow( + Error('Unable to initialize server', { + cause: new Error('opensearch_ca_cert_path must be a string') + }) + ) + }) + + it('should throw error if opensearch_max_retries is defined and is not a number', async () => { + await expect(async () => { + testServer = new TwakeServer({ + ...defaultConfig, + opensearch_max_retries: 'falsy' + } as unknown as Config) + await testServer.ready + }).rejects.toThrow( + Error('Unable to initialize server', { + cause: new Error('opensearch_max_retries must be a number') + }) + ) + }) + + it('should throw error if opensearch_number_of_shards is defined and is not a number', async () => { + await expect(async () => { + testServer = new TwakeServer({ + ...defaultConfig, + opensearch_number_of_shards: 'falsy' + } as unknown as Config) + await testServer.ready + }).rejects.toThrow( + Error('Unable to initialize server', { + cause: new Error('opensearch_number_of_shards must be a number') + }) + ) + }) + + it('should throw error if opensearch_number_of_replicas is defined and is not a number', async () => { + await expect(async () => { + testServer = new TwakeServer({ + ...defaultConfig, + opensearch_number_of_replicas: 'falsy' + } as unknown as Config) + await testServer.ready + }).rejects.toThrow( + Error('Unable to initialize server', { + cause: new Error('opensearch_number_of_replicas must be a number') + }) + ) + }) + + it('should throw error if opensearch_wait_for_active_shards is defined and is not a string', async () => { + await expect(async () => { + testServer = new TwakeServer({ + ...defaultConfig, + opensearch_wait_for_active_shards: 123 + } as unknown as Config) + await testServer.ready + }).rejects.toThrow( + Error('Unable to initialize server', { + cause: new Error('opensearch_wait_for_active_shards must be a string') + }) + ) + }) + + it('should throw error if opensearch_wait_for_active_shards is defined and is not a string representing a number or equal to "all"', async () => { + await expect(async () => { + testServer = new TwakeServer({ + ...defaultConfig, + opensearch_wait_for_active_shards: 'falsy' + } as unknown as Config) + await testServer.ready + }).rejects.toThrow( + Error('Unable to initialize server', { + cause: new Error( + 'opensearch_wait_for_active_shards must be a string equal to a number or "all"' + ) + }) + ) + }) + + it('should log an error if opensearch API throws an error on create tom indexes', async () => { + const error = new Error('An error occured in opensearch exists API') + mockExists.mockRejectedValue(error) + testServer = new TwakeServer(defaultConfig as Config) + const loggerErrorSpyOn = jest.spyOn(testServer.logger, 'error') + await expect(testServer.ready).rejects.toStrictEqual( + new Error('Unable to initialize server', { cause: error }) + ) + mockExists.mockResolvedValue({ statusCode: 200, body: true }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(1) + expect(loggerErrorSpyOn).toHaveBeenCalledWith( + `Unable to initialize server`, + { error: error.message } + ) + }) + + it('should initialize server if config is correct', async () => { + testServer = new TwakeServer(defaultConfig as Config) + await expect(testServer.ready).resolves.toEqual(true) + }) +}) diff --git a/packages/tom-server/src/search-engine-api/tests/tsconfig.json b/packages/tom-server/src/search-engine-api/tests/tsconfig.json new file mode 100644 index 00000000..1364af34 --- /dev/null +++ b/packages/tom-server/src/search-engine-api/tests/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../../../tsconfig-test.json", + "include": ["**/*.test.ts"] +} \ No newline at end of file From 04e76ad319eab6b00b2e6b6c4af9a93643f672f1 Mon Sep 17 00:00:00 2001 From: Jordy Cabannes Date: Sat, 2 Mar 2024 20:10:34 +0100 Subject: [PATCH 09/16] test: events related to messages and rooms --- .../tests/events-listener.test.ts | 1681 +++++++++++++++++ 1 file changed, 1681 insertions(+) create mode 100644 packages/tom-server/src/search-engine-api/tests/events-listener.test.ts diff --git a/packages/tom-server/src/search-engine-api/tests/events-listener.test.ts b/packages/tom-server/src/search-engine-api/tests/events-listener.test.ts new file mode 100644 index 00000000..fd5f7ec8 --- /dev/null +++ b/packages/tom-server/src/search-engine-api/tests/events-listener.test.ts @@ -0,0 +1,1681 @@ +import { type DbGetResult } from '@twake/matrix-identity-server' +import express, { Router } from 'express' +import supertest from 'supertest' +import TwakeServer from '../..' +import { type Config } from '../../types' +import defaultConfig from '../__testData__/config.json' +import { OpenSearchClientException } from '../utils/error' + +let testServer: TwakeServer +const homeServerToken = + 'hsTokenTestwdakZQunWWNe3DZitAerw9aNqJ2a6HVp0sJtg7qTJWXcHnBjgN0NL' + +const mockIndex = jest.fn().mockResolvedValue({ statusCode: 200 }) +const mockBulk = jest.fn().mockResolvedValue({ statusCode: 200 }) +const mockDelete = jest.fn().mockResolvedValue({ statusCode: 200 }) +const mockDeleteByQuery = jest.fn().mockResolvedValue({ statusCode: 200 }) +const mockUpdateByQuery = jest.fn().mockResolvedValue({ statusCode: 200 }) +const mockExists = jest.fn().mockResolvedValue({ statusCode: 200, body: true }) +const mockUpdate = jest.fn().mockResolvedValue({ statusCode: 200 }) + +jest.mock('@opensearch-project/opensearch', () => ({ + Client: jest.fn().mockImplementation(() => ({ + indices: { + exists: jest.fn().mockResolvedValue({ statusCode: 200, body: true }) + }, + index: mockIndex, + bulk: mockBulk, + delete: mockDelete, + delete_by_query: mockDeleteByQuery, + update_by_query: mockUpdateByQuery, + update: mockUpdate, + exists: mockExists, + close: jest.fn() + })) +})) + +jest.mock('@twake/matrix-identity-server', () => ({ + default: jest.fn().mockImplementation(() => ({ + ready: Promise.resolve(true), + db: {}, + userDB: {}, + api: { get: {}, post: {} }, + cleanJobs: jest.fn().mockImplementation(() => testServer.logger.close()) + })), + MatrixDB: jest.fn().mockImplementation(() => ({ + ready: Promise.resolve(true), + get: jest.fn().mockResolvedValue([]), + close: jest.fn(), + getAll: jest.fn().mockResolvedValue([]) + })), + Utils: { + hostnameRe: + /^((([a-zA-Z0-9][-a-zA-Z0-9]*)?[a-zA-Z0-9])[.])*([a-zA-Z][-a-zA-Z0-9]*[a-zA-Z0-9]|[a-zA-Z])(:(\d+))?$/ + } +})) + +jest.mock('../../identity-server/index.ts', () => { + return function () { + return { + ready: Promise.resolve(true), + db: {}, + userDB: {}, + api: { get: {}, post: {} }, + cleanJobs: jest.fn().mockImplementation(() => testServer.logger.close()) + } + } +}) + +jest.mock('../../application-server/index.ts', () => { + return function () { + return { + router: { + routes: Router() + } + } + } +}) + +jest.mock('../../db/index.ts', () => jest.fn()) + +describe('Search engine API - Opensearch service', () => { + let app: express.Application + let loggerErrorSpyOn: jest.SpyInstance + let transactionId = 1 + + beforeAll((done) => { + testServer = new TwakeServer(defaultConfig as Config) + loggerErrorSpyOn = jest.spyOn(testServer.logger, 'error') + testServer.ready + .then(() => { + app = express() + app.use(testServer.endpoints) + done() + }) + .catch((e) => { + done(e) + }) + }) + + afterAll(() => { + if (testServer != null) testServer.cleanJobs() + }) + + afterEach(() => { + jest.clearAllMocks() + transactionId++ + }) + + describe('Update room name', () => { + afterEach(() => { + mockIndex.mockResolvedValue({ statusCode: 200 }) + }) + + it('should log an error when matrix db client does not find the involved room on update room name event', async () => { + jest.spyOn(testServer.matrixDb, 'get').mockResolvedValue([]) + + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + room_id: 'room1', + type: 'm.room.name', + state_key: '@test:example.com' + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(1) + expect(loggerErrorSpyOn).toHaveBeenCalledWith( + new Error('No room stats state found with id room1').message, + {} + ) + }) + + it('should log an error when matrix db client finds multiple rooms on update room name event', async () => { + jest + .spyOn(testServer.matrixDb, 'get') + .mockResolvedValue([ + { encryption: null }, + { encryption: 'test' } + ] as unknown as DbGetResult) + + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + room_id: 'room1', + type: 'm.room.name', + state_key: '@test:example.com' + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(1) + expect(loggerErrorSpyOn).toHaveBeenCalledWith( + new Error('More than one room found with id room1').message, + {} + ) + }) + + it('should work if matrix db client finds the involved rooms on update room name event', async () => { + jest + .spyOn(testServer.matrixDb, 'get') + .mockResolvedValue([{ encryption: null }] as unknown as DbGetResult) + + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + room_id: 'room1', + type: 'm.room.name', + state_key: '@test:example.com', + content: { + name: 'new_name' + } + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(0) + }) + + it('should call logger when opensearch client throws an error', async () => { + jest + .spyOn(testServer.matrixDb, 'get') + .mockResolvedValue([{ encryption: null }] as unknown as DbGetResult) + + const error = new Error('An error occured in opensearch index API') + mockIndex.mockRejectedValue(error) + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + room_id: 'room1', + type: 'm.room.name', + state_key: '@test:example.com', + content: { + name: 'new_name' + } + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(1) + expect(loggerErrorSpyOn).toHaveBeenCalledWith(error.message, {}) + }) + + it('should call logger when opensearch client response status code is not 20X', async () => { + jest + .spyOn(testServer.matrixDb, 'get') + .mockResolvedValue([{ encryption: null }] as unknown as DbGetResult) + + const error = new Error('An error occured in opensearch index API') + mockIndex.mockResolvedValue({ statusCode: 502, body: error }) + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + room_id: 'room1', + type: 'm.room.name', + state_key: '@test:example.com', + content: { + name: 'new_name' + } + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(1) + expect(loggerErrorSpyOn).toHaveBeenCalledWith( + new OpenSearchClientException(JSON.stringify(error, null, 2), 502) + .message, + { status: '502' } + ) + }) + }) + + describe('Index message', () => { + afterEach(() => { + mockBulk.mockResolvedValue({ statusCode: 200 }) + }) + + it('should log an error when matrix db client does not find the involved room on received message event', async () => { + jest.spyOn(testServer.matrixDb, 'get').mockResolvedValue([]) + + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + content: {}, + room_id: 'room2', + type: 'm.room.message' + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(1) + expect(loggerErrorSpyOn).toHaveBeenCalledWith( + new Error('No room stats state found with id room2').message, + {} + ) + }) + + it('should log an error when matrix db client finds multiple rooms on received message event', async () => { + jest + .spyOn(testServer.matrixDb, 'get') + .mockResolvedValue([ + { encryption: null }, + { encryption: 'test' } + ] as unknown as DbGetResult) + + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + room_id: 'room2', + type: 'm.room.message', + content: {} + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(1) + expect(loggerErrorSpyOn).toHaveBeenCalledWith( + new Error('More than one room found with id room2').message, + {} + ) + }) + + it('should log an error when matrix db client does not found membership for the sender in the room', async () => { + jest + .spyOn(testServer.matrixDb, 'get') + .mockResolvedValueOnce([{ encryption: null }] as unknown as DbGetResult) + .mockResolvedValueOnce([]) + + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + room_id: 'room2', + type: 'm.room.message', + sender: '@toto:example.com', + content: {} + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(1) + expect(loggerErrorSpyOn).toHaveBeenCalledWith( + new Error( + 'No memberships found for user @toto:example.com in room room2' + ).message, + {} + ) + }) + + it('should log an error when last membership in the room for the sender is not "join"', async () => { + jest + .spyOn(testServer.matrixDb, 'get') + .mockResolvedValueOnce([{ encryption: null }] as unknown as DbGetResult) + .mockResolvedValueOnce([ + { display_name: 'Toto', membership: 'invite' }, + { display_name: 'Toto', membership: 'join' }, + { display_name: 'Toto', membership: 'leave' } + ]) + + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + room_id: 'room2', + type: 'm.room.message', + sender: '@toto:example.com', + content: {} + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(1) + expect(loggerErrorSpyOn).toHaveBeenCalledWith( + new Error( + 'User @toto:example.com is not allowed to participate in room room2' + ).message, + {} + ) + }) + + it('should not call logger error method if no problem occurs', async () => { + jest + .spyOn(testServer.matrixDb, 'get') + .mockResolvedValueOnce([ + { encryption: null, name: 'Room 2' } + ] as unknown as DbGetResult) + .mockResolvedValueOnce([ + { display_name: 'Toto', membership: 'invite' }, + { display_name: 'Toto', membership: 'join' } + ]) + + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + room_id: 'room2', + type: 'm.room.message', + sender: '@toto:example.com', + content: { + body: 'Hello world', + type: 'text' + } + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(0) + }) + + it('should call logger when opensearch client throws an error', async () => { + jest + .spyOn(testServer.matrixDb, 'get') + .mockResolvedValueOnce([ + { encryption: null, name: 'Room 2' } + ] as unknown as DbGetResult) + .mockResolvedValueOnce([ + { display_name: 'Toto', membership: 'invite' }, + { display_name: 'Toto', membership: 'join' } + ]) + + const error = new Error('An error occured in opensearch bulk API') + mockBulk.mockRejectedValue(error) + + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + room_id: 'room2', + type: 'm.room.message', + sender: '@toto:example.com', + content: { + body: 'Hello world', + type: 'text' + } + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(1) + expect(loggerErrorSpyOn).toHaveBeenCalledWith(error.message, {}) + }) + + it('should call logger when opensearch client response status code is not 20X', async () => { + jest + .spyOn(testServer.matrixDb, 'get') + .mockResolvedValueOnce([ + { encryption: null, name: 'Room 2' } + ] as unknown as DbGetResult) + .mockResolvedValueOnce([ + { display_name: 'Toto', membership: 'invite' }, + { display_name: 'Toto', membership: 'join' } + ]) + + const error = new Error('An error occured in opensearch bulk API') + mockBulk.mockResolvedValue({ statusCode: 503, body: error }) + + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + room_id: 'room2', + type: 'm.room.message', + sender: '@toto:example.com', + content: { + body: 'Hello world', + type: 'text' + } + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(1) + expect(loggerErrorSpyOn).toHaveBeenCalledWith( + new OpenSearchClientException(JSON.stringify(error, null, 2), 503) + .message, + { status: '503' } + ) + }) + }) + + describe('Deindex room', () => { + afterEach(() => { + mockExists.mockResolvedValue({ statusCode: 200, body: true }) + mockDelete.mockResolvedValue({ statusCode: 200 }) + mockDeleteByQuery.mockResolvedValue({ statusCode: 200 }) + }) + + it('should call logger when opensearch exists throws an error', async () => { + const error = new Error('An error occured in opensearch exists API') + mockExists.mockRejectedValue(error) + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + room_id: 'room3', + state_key: '@test:example.com', + type: 'm.room.encryption', + sender: '@toto:example.com', + content: { algorithm: 'm.megolm.v1.aes-sha2' } + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(1) + expect(loggerErrorSpyOn).toHaveBeenCalledWith(error.message, {}) + expect(mockExists).toHaveBeenCalledTimes(1) + }) + + it('should not call logger when opensearch exists response status code is not 20X', async () => { + const error = new Error('An error occured in opensearch exists API') + mockExists.mockResolvedValue({ statusCode: 404, body: error }) + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + room_id: 'room3', + state_key: '@test:example.com', + type: 'm.room.encryption', + sender: '@toto:example.com', + content: { algorithm: 'm.megolm.v1.aes-sha2' } + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(0) + expect(mockExists).toHaveBeenCalledTimes(1) + }) + + it('should call logger when opensearch delete throws an error', async () => { + const error = new Error('An error occured in opensearch delete API') + mockDelete.mockRejectedValue(error) + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + room_id: 'room3', + state_key: '@test:example.com', + type: 'm.room.encryption', + sender: '@toto:example.com', + content: { algorithm: 'm.megolm.v1.aes-sha2' } + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(1) + expect(loggerErrorSpyOn).toHaveBeenCalledWith(error.message, {}) + expect(mockExists).toHaveBeenCalledTimes(1) + expect(mockDelete).toHaveBeenCalledTimes(1) + }) + + it('should call logger when opensearch delete response status code is not 20X', async () => { + const error = new Error('An error occured in opensearch delete API') + mockDelete.mockResolvedValue({ statusCode: 505, body: error }) + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + room_id: 'room3', + state_key: '@test:example.com', + type: 'm.room.encryption', + sender: '@toto:example.com', + content: { algorithm: 'm.megolm.v1.aes-sha2' } + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(1) + expect(loggerErrorSpyOn).toHaveBeenCalledWith( + new OpenSearchClientException(JSON.stringify(error, null, 2), 505) + .message, + { status: '505' } + ) + expect(mockExists).toHaveBeenCalledTimes(1) + expect(mockDelete).toHaveBeenCalledTimes(1) + }) + + it('should call logger when opensearch delete_by_query throws an error', async () => { + const error = new Error( + 'An error occured in opensearch delete_by_query API' + ) + mockDeleteByQuery.mockRejectedValue(error) + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + room_id: 'room3', + state_key: '@test:example.com', + type: 'm.room.encryption', + sender: '@toto:example.com', + content: { algorithm: 'm.megolm.v1.aes-sha2' } + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(1) + expect(loggerErrorSpyOn).toHaveBeenCalledWith(error.message, {}) + expect(mockExists).toHaveBeenCalledTimes(1) + expect(mockDelete).toHaveBeenCalledTimes(1) + expect(mockDeleteByQuery).toHaveBeenCalledTimes(1) + }) + + it('should call logger when opensearch delete_by_query response status code is not 20X', async () => { + const error = new Error('An error occured in opensearch delete API') + mockDeleteByQuery.mockResolvedValue({ statusCode: 505, body: error }) + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + room_id: 'room3', + state_key: '@test:example.com', + type: 'm.room.encryption', + sender: '@toto:example.com', + content: { algorithm: 'm.megolm.v1.aes-sha2' } + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(1) + expect(loggerErrorSpyOn).toHaveBeenCalledWith( + new OpenSearchClientException(JSON.stringify(error, null, 2), 505) + .message, + { status: '505' } + ) + expect(mockExists).toHaveBeenCalledTimes(1) + expect(mockDelete).toHaveBeenCalledTimes(1) + expect(mockDeleteByQuery).toHaveBeenCalledTimes(1) + }) + + it('should not call opensearch exists method if event.content.algorithm is undefined', async () => { + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + room_id: 'room3', + state_key: '@test:example.com', + type: 'm.room.encryption', + sender: '@toto:example.com', + content: {} + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(0) + expect(mockExists).toHaveBeenCalledTimes(0) + expect(mockDelete).toHaveBeenCalledTimes(0) + expect(mockDeleteByQuery).toHaveBeenCalledTimes(0) + }) + + it('should not call opensearch delete methods if document does not exist', async () => { + mockExists.mockResolvedValue({ statusCode: 404, body: false }) + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + room_id: 'room3', + state_key: '@test:example.com', + type: 'm.room.encryption', + sender: '@toto:example.com', + content: { algorithm: 'm.megolm.v1.aes-sha2' } + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(0) + expect(mockExists).toHaveBeenCalledTimes(1) + expect(mockDelete).toHaveBeenCalledTimes(0) + expect(mockDeleteByQuery).toHaveBeenCalledTimes(1) + }) + + it('should not call logger error method if no problem occurs', async () => { + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + room_id: 'room3', + state_key: '@test:example.com', + type: 'm.room.encryption', + sender: '@toto:example.com', + content: { algorithm: 'm.megolm.v1.aes-sha2' } + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(0) + expect(mockExists).toHaveBeenCalledTimes(1) + expect(mockDelete).toHaveBeenCalledTimes(1) + expect(mockDeleteByQuery).toHaveBeenCalledTimes(1) + }) + }) + + describe('Deindex message', () => { + afterEach(() => { + mockExists.mockResolvedValue({ statusCode: 200, body: true }) + mockDelete.mockResolvedValue({ statusCode: 200 }) + }) + + it('should call logger when opensearch exists throws an error', async () => { + const error = new Error('An error occured in opensearch exists API') + mockExists.mockRejectedValue(error) + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + content: { + reason: 'Message content is invalid' + }, + event_id: '$NTT-aFQYu0MblYL81AOsvC5RB6i9uHk8TuAdH1tOg6w', + redacts: '$N1iUgYSegBr2JWThSAuEEsGznZtnbhRPmCNXKIwe6sE', + room_id: 'room4', + sender: '@lskywalker:example.com', + type: 'm.room.redaction', + user_id: '@lskywalker:example.com' + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(1) + expect(loggerErrorSpyOn).toHaveBeenCalledWith(error.message, {}) + expect(mockExists).toHaveBeenCalledTimes(1) + }) + + it('should not call logger when opensearch exists response status code is not 20X', async () => { + const error = new Error('An error occured in opensearch exists API') + mockExists.mockResolvedValue({ statusCode: 404, body: error }) + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + content: { + reason: 'Message content is invalid' + }, + event_id: '$NTT-aFQYu0MblYL81AOsvC5RB6i9uHk8TuAdH1tOg6w', + redacts: '$N1iUgYSegBr2JWThSAuEEsGznZtnbhRPmCNXKIwe6sE', + room_id: 'room4', + sender: '@lskywalker:example.com', + type: 'm.room.redaction', + user_id: '@lskywalker:example.com' + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(0) + expect(mockExists).toHaveBeenCalledTimes(1) + }) + + it('should call logger when opensearch delete throws an error', async () => { + const error = new Error('An error occured in opensearch delete API') + mockDelete.mockRejectedValue(error) + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + content: { + reason: 'Message content is invalid' + }, + event_id: '$NTT-aFQYu0MblYL81AOsvC5RB6i9uHk8TuAdH1tOg6w', + redacts: '$N1iUgYSegBr2JWThSAuEEsGznZtnbhRPmCNXKIwe6sE', + room_id: 'room4', + sender: '@lskywalker:example.com', + type: 'm.room.redaction', + user_id: '@lskywalker:example.com' + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(1) + expect(loggerErrorSpyOn).toHaveBeenCalledWith(error.message, {}) + expect(mockExists).toHaveBeenCalledTimes(1) + expect(mockDelete).toHaveBeenCalledTimes(1) + }) + + it('should call logger when opensearch delete response status code is not 20X', async () => { + const error = new Error('An error occured in opensearch delete API') + mockDelete.mockResolvedValue({ statusCode: 505, body: error }) + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + content: { + reason: 'Message content is invalid' + }, + event_id: '$NTT-aFQYu0MblYL81AOsvC5RB6i9uHk8TuAdH1tOg6w', + redacts: '$N1iUgYSegBr2JWThSAuEEsGznZtnbhRPmCNXKIwe6sE', + room_id: 'room4', + sender: '@lskywalker:example.com', + type: 'm.room.redaction', + user_id: '@lskywalker:example.com' + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(1) + expect(loggerErrorSpyOn).toHaveBeenCalledWith( + new OpenSearchClientException(JSON.stringify(error, null, 2), 505) + .message, + { status: '505' } + ) + expect(mockExists).toHaveBeenCalledTimes(1) + expect(mockDelete).toHaveBeenCalledTimes(1) + }) + + it('should not call opensearch delete methods if event.redacts is undefined', async () => { + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + content: { + reason: 'Message content is invalid' + }, + event_id: '$NTT-aFQYu0MblYL81AOsvC5RB6i9uHk8TuAdH1tOg6w', + room_id: 'room4', + sender: '@lskywalker:example.com', + type: 'm.room.redaction', + user_id: '@lskywalker:example.com' + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(0) + expect(mockExists).toHaveBeenCalledTimes(0) + expect(mockDelete).toHaveBeenCalledTimes(0) + }) + + it('should not call opensearch delete methods if event.redacts is null', async () => { + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + content: { + reason: 'Message content is invalid' + }, + event_id: '$NTT-aFQYu0MblYL81AOsvC5RB6i9uHk8TuAdH1tOg6w', + room_id: 'room4', + redacts: null, + sender: '@lskywalker:example.com', + type: 'm.room.redaction', + user_id: '@lskywalker:example.com' + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(0) + expect(mockExists).toHaveBeenCalledTimes(0) + expect(mockDelete).toHaveBeenCalledTimes(0) + }) + + it('should not call opensearch delete methods if event.redacts does not match event id regex', async () => { + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + content: { + reason: 'Message content is invalid' + }, + event_id: '$NTT-aFQYu0MblYL81AOsvC5RB6i9uHk8TuAdH1tOg6w', + room_id: 'room4', + redacts: 'falsy_event_id', + sender: '@lskywalker:example.com', + type: 'm.room.redaction', + user_id: '@lskywalker:example.com' + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(0) + expect(mockExists).toHaveBeenCalledTimes(0) + expect(mockDelete).toHaveBeenCalledTimes(0) + }) + + it('should not call opensearch delete methods if document does not exist', async () => { + mockExists.mockResolvedValue({ statusCode: 404, body: false }) + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + content: { + reason: 'Message content is invalid' + }, + event_id: '$NTT-aFQYu0MblYL81AOsvC5RB6i9uHk8TuAdH1tOg6w', + room_id: 'room4', + redacts: '$POT-aFQYu0MblYL81AOsvC5RB6i9uHk8TuAdH1tOg6w', + sender: '@lskywalker:example.com', + type: 'm.room.redaction', + user_id: '@lskywalker:example.com' + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(0) + expect(mockExists).toHaveBeenCalledTimes(1) + expect(mockDelete).toHaveBeenCalledTimes(0) + }) + + it('should not call logger error method if no problem occurs', async () => { + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + content: { + reason: 'Message content is invalid' + }, + event_id: '$NTT-aFQYu0MblYL81AOsvC5RB6i9uHk8TuAdH1tOg6w', + room_id: 'room4', + redacts: '$N1iUgYSegBr2JWThSAuEEsGznZtnbhRPmCNXKIwe6sE', + sender: '@lskywalker:example.com', + type: 'm.room.redaction', + user_id: '@lskywalker:example.com' + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(0) + expect(mockExists).toHaveBeenCalledTimes(1) + expect(mockDelete).toHaveBeenCalledTimes(1) + }) + }) + + describe('Update display name', () => { + afterEach(() => { + mockUpdateByQuery.mockResolvedValue({ statusCode: 200 }) + }) + + it('should call logger when opensearch client throws an error', async () => { + const error = new Error( + 'An error occured in opensearch update_by_query API' + ) + mockUpdateByQuery.mockRejectedValue(error) + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + content: { + avatar_url: 'mxc://matrix.org/wefh34uihSDRGhw34', + displayname: 'new display name', + membership: 'join' + }, + room_id: 'room5', + sender: '@askywalker:example.com', + state_key: '@askywalker:example.com', + type: 'm.room.member', + unsigned: { + prev_content: { + avatar_url: 'mxc://matrix.org/wefh34uihSDRGhw34', + displayname: 'old display name', + membership: 'join' + } + } + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(1) + expect(loggerErrorSpyOn).toHaveBeenCalledWith(error.message, {}) + expect(mockUpdateByQuery).toHaveBeenCalledTimes(1) + }) + + it('should call logger when opensearch client response status code is not 20X', async () => { + const error = new Error( + 'An error occured in opensearch update_by_query API' + ) + mockUpdateByQuery.mockResolvedValue({ statusCode: 506, body: error }) + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + content: { + avatar_url: 'mxc://matrix.org/wefh34uihSDRGhw34', + displayname: 'new display name', + membership: 'join' + }, + room_id: 'room5', + sender: '@askywalker:example.com', + state_key: '@askywalker:example.com', + type: 'm.room.member', + unsigned: { + prev_content: { + avatar_url: 'mxc://matrix.org/wefh34uihSDRGhw34', + displayname: 'old display name', + membership: 'join' + } + } + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(1) + expect(loggerErrorSpyOn).toHaveBeenCalledWith( + new OpenSearchClientException(JSON.stringify(error, null, 2), 506) + .message, + { status: '506' } + ) + expect(mockUpdateByQuery).toHaveBeenCalledTimes(1) + }) + + it('should not call opensearch update_by_query method if event.content.display_name is undefined', async () => { + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + content: {}, + room_id: 'room5', + sender: '@askywalker:example.com', + state_key: '@askywalker:example.com', + type: 'm.room.member', + unsigned: { + prev_content: { + avatar_url: 'mxc://matrix.org/wefh34uihSDRGhw34', + displayname: 'old display name', + membership: 'join' + } + } + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(0) + expect(mockUpdateByQuery).toHaveBeenCalledTimes(0) + }) + + it('should not call opensearch update_by_query method if event.unsigned.prev_content.display_name is undefined', async () => { + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + content: { + avatar_url: 'mxc://matrix.org/wefh34uihSDRGhw34', + displayname: 'new display name', + membership: 'join' + }, + room_id: 'room5', + sender: '@askywalker:example.com', + state_key: '@askywalker:example.com', + type: 'm.room.member', + unsigned: { + prev_content: {} + } + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(0) + expect(mockUpdateByQuery).toHaveBeenCalledTimes(0) + }) + + it('should not call opensearch update_by_query method if event.unsigned.prev_content is undefined', async () => { + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + content: { + avatar_url: 'mxc://matrix.org/wefh34uihSDRGhw34', + displayname: 'new display name', + membership: 'join' + }, + room_id: 'room5', + sender: '@askywalker:example.com', + state_key: '@askywalker:example.com', + type: 'm.room.member', + unsigned: {} + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(0) + expect(mockUpdateByQuery).toHaveBeenCalledTimes(0) + }) + + it('should not call opensearch update_by_query method if event.unsigned is undefined', async () => { + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + content: { + avatar_url: 'mxc://matrix.org/wefh34uihSDRGhw34', + displayname: 'new display name', + membership: 'join' + }, + room_id: 'room5', + sender: '@askywalker:example.com', + state_key: '@askywalker:example.com', + type: 'm.room.member' + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(0) + expect(mockUpdateByQuery).toHaveBeenCalledTimes(0) + }) + + it('should not call opensearch update_by_query method if display name has not changed', async () => { + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + content: { + avatar_url: 'mxc://matrix.org/wefh34uihSDRGhw34', + displayname: 'old display name', + membership: 'join' + }, + room_id: 'room5', + sender: '@askywalker:example.com', + state_key: '@askywalker:example.com', + type: 'm.room.member', + unsigned: { + prev_content: { + avatar_url: 'mxc://matrix.org/wefh34uihSDRGhw34', + displayname: 'old display name', + membership: 'join' + } + } + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(0) + expect(mockUpdateByQuery).toHaveBeenCalledTimes(0) + }) + + it('should not call logger error method if no problem occurs', async () => { + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + content: { + avatar_url: 'mxc://matrix.org/wefh34uihSDRGhw34', + displayname: 'new display name', + membership: 'join' + }, + room_id: 'room5', + sender: '@askywalker:example.com', + state_key: '@askywalker:example.com', + type: 'm.room.member', + unsigned: { + prev_content: { + avatar_url: 'mxc://matrix.org/wefh34uihSDRGhw34', + displayname: 'old display name', + membership: 'join' + } + } + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(0) + expect(mockUpdateByQuery).toHaveBeenCalledTimes(1) + }) + }) + + describe('Update message content', () => { + beforeEach(() => { + jest + .spyOn(testServer.matrixDb, 'get') + .mockResolvedValue([{ encryption: null }] as unknown as DbGetResult) + }) + + afterEach(() => { + mockUpdate.mockResolvedValue({ statusCode: 200 }) + }) + + it('should call logger when opensearch client throws an error', async () => { + const error = new Error('An error occured in opensearch update API') + mockUpdate.mockRejectedValue(error) + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + content: { + body: ' * But this room does not have avatar unless you add one', + 'm.mentions': {}, + 'm.new_content': { + body: 'But this room does not have avatar unless you add one', + 'm.mentions': {}, + msgtype: 'm.text' + }, + 'm.relates_to': { + event_id: '$GGTg4DaUAHGVL_pHMAJtMz6F2cAK4cUXug-vkRG-yZQ', + rel_type: 'm.replace' + }, + msgtype: 'm.text' + }, + event_id: '$lDH6ZUNilncsYuFpkV-6xmQH9QZM3_KZVVBI95dY6XA', + room_id: 'room6', + sender: '@toto:example.com', + type: 'm.room.message', + user_id: '@toto:example.com' + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(1) + expect(loggerErrorSpyOn).toHaveBeenCalledWith(error.message, {}) + expect(mockUpdate).toHaveBeenCalledTimes(1) + }) + + it('should call logger when opensearch client response status code is not 20X', async () => { + const error = new Error('An error occured in opensearch update API') + mockUpdate.mockResolvedValue({ statusCode: 506, body: error }) + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + content: { + body: ' * But this room does not have avatar unless you add one', + 'm.mentions': {}, + 'm.new_content': { + body: 'But this room does not have avatar unless you add one', + 'm.mentions': {}, + msgtype: 'm.text' + }, + 'm.relates_to': { + event_id: '$GGTg4DaUAHGVL_pHMAJtMz6F2cAK4cUXug-vkRG-yZQ', + rel_type: 'm.replace' + }, + msgtype: 'm.text' + }, + event_id: '$lDH6ZUNilncsYuFpkV-6xmQH9QZM3_KZVVBI95dY6XA', + room_id: 'room6', + sender: '@toto:example.com', + type: 'm.room.message', + user_id: '@toto:example.com' + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(1) + expect(loggerErrorSpyOn).toHaveBeenCalledWith( + new OpenSearchClientException(JSON.stringify(error, null, 2), 506) + .message, + { status: '506' } + ) + expect(mockUpdate).toHaveBeenCalledTimes(1) + }) + + it('should not call opensearch update method if event.content["m.new_content"] is undefined', async () => { + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + content: { + body: ' * But this room does not have avatar unless you add one', + 'm.mentions': {}, + 'm.relates_to': { + event_id: '$GGTg4DaUAHGVL_pHMAJtMz6F2cAK4cUXug-vkRG-yZQ', + rel_type: 'm.replace' + }, + msgtype: 'm.text' + }, + event_id: '$lDH6ZUNilncsYuFpkV-6xmQH9QZM3_KZVVBI95dY6XA', + room_id: 'room6', + sender: '@toto:example.com', + type: 'm.room.message', + user_id: '@toto:example.com' + } + ] + }) + expect(mockUpdate).toHaveBeenCalledTimes(0) + }) + + it('should not call opensearch update method if event.content["m.new_content"] is null', async () => { + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + content: { + body: ' * But this room does not have avatar unless you add one', + 'm.mentions': {}, + 'm.new_content': null, + 'm.relates_to': { + event_id: '$GGTg4DaUAHGVL_pHMAJtMz6F2cAK4cUXug-vkRG-yZQ', + rel_type: 'm.replace' + }, + msgtype: 'm.text' + }, + event_id: '$lDH6ZUNilncsYuFpkV-6xmQH9QZM3_KZVVBI95dY6XA', + room_id: 'room6', + sender: '@toto:example.com', + type: 'm.room.message', + user_id: '@toto:example.com' + } + ] + }) + expect(mockUpdate).toHaveBeenCalledTimes(0) + }) + + it('should not call opensearch update method if event.content["m.relates_to"] is undefined', async () => { + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + content: { + body: ' * But this room does not have avatar unless you add one', + 'm.mentions': {}, + 'm.new_content': { + body: 'But this room does not have avatar unless you add one', + 'm.mentions': {}, + msgtype: 'm.text' + }, + msgtype: 'm.text' + }, + event_id: '$lDH6ZUNilncsYuFpkV-6xmQH9QZM3_KZVVBI95dY6XA', + room_id: 'room6', + sender: '@toto:example.com', + type: 'm.room.message', + user_id: '@toto:example.com' + } + ] + }) + expect(mockUpdate).toHaveBeenCalledTimes(0) + }) + + it('should not call opensearch update method if event.content["m.relates_to"] is null', async () => { + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + content: { + body: ' * But this room does not have avatar unless you add one', + 'm.mentions': {}, + 'm.new_content': { + body: 'But this room does not have avatar unless you add one', + 'm.mentions': {}, + msgtype: 'm.text' + }, + 'm.relates_to': null, + msgtype: 'm.text' + }, + event_id: '$lDH6ZUNilncsYuFpkV-6xmQH9QZM3_KZVVBI95dY6XA', + room_id: 'room6', + sender: '@toto:example.com', + type: 'm.room.message', + user_id: '@toto:example.com' + } + ] + }) + expect(mockUpdate).toHaveBeenCalledTimes(0) + }) + + it('should not call opensearch update method if event.content["m.relates_to"].event_id is undefined', async () => { + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + content: { + body: ' * But this room does not have avatar unless you add one', + 'm.mentions': {}, + 'm.new_content': { + body: 'But this room does not have avatar unless you add one', + 'm.mentions': {}, + msgtype: 'm.text' + }, + 'm.relates_to': { + rel_type: 'm.replace' + }, + msgtype: 'm.text' + }, + event_id: '$lDH6ZUNilncsYuFpkV-6xmQH9QZM3_KZVVBI95dY6XA', + room_id: 'room6', + sender: '@toto:example.com', + type: 'm.room.message', + user_id: '@toto:example.com' + } + ] + }) + expect(mockUpdate).toHaveBeenCalledTimes(0) + }) + + it('should not call opensearch update method if event.content["m.relates_to"].event_id is null', async () => { + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + content: { + body: ' * But this room does not have avatar unless you add one', + 'm.mentions': {}, + 'm.new_content': { + body: 'But this room does not have avatar unless you add one', + 'm.mentions': {}, + msgtype: 'm.text' + }, + 'm.relates_to': { + event_id: null, + rel_type: 'm.replace' + }, + msgtype: 'm.text' + }, + event_id: '$lDH6ZUNilncsYuFpkV-6xmQH9QZM3_KZVVBI95dY6XA', + room_id: 'room6', + sender: '@toto:example.com', + type: 'm.room.message', + user_id: '@toto:example.com' + } + ] + }) + expect(mockUpdate).toHaveBeenCalledTimes(0) + }) + + it('should not call opensearch update method if event.content["m.relates_to"].rel_type is undefined', async () => { + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + content: { + body: ' * But this room does not have avatar unless you add one', + 'm.mentions': {}, + 'm.new_content': { + body: 'But this room does not have avatar unless you add one', + 'm.mentions': {}, + msgtype: 'm.text' + }, + 'm.relates_to': { + event_id: '$GGTg4DaUAHGVL_pHMAJtMz6F2cAK4cUXug-vkRG-yZQ' + }, + msgtype: 'm.text' + }, + event_id: '$lDH6ZUNilncsYuFpkV-6xmQH9QZM3_KZVVBI95dY6XA', + room_id: 'room6', + sender: '@toto:example.com', + type: 'm.room.message', + user_id: '@toto:example.com' + } + ] + }) + expect(mockUpdate).toHaveBeenCalledTimes(0) + }) + + it('should not call opensearch update method if event.content["m.relates_to"].rel_type is null', async () => { + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + content: { + body: ' * But this room does not have avatar unless you add one', + 'm.mentions': {}, + 'm.new_content': { + body: 'But this room does not have avatar unless you add one', + 'm.mentions': {}, + msgtype: 'm.text' + }, + 'm.relates_to': { + event_id: '$GGTg4DaUAHGVL_pHMAJtMz6F2cAK4cUXug-vkRG-yZQ', + rel_type: null + }, + msgtype: 'm.text' + }, + event_id: '$lDH6ZUNilncsYuFpkV-6xmQH9QZM3_KZVVBI95dY6XA', + room_id: 'room6', + sender: '@toto:example.com', + type: 'm.room.message', + user_id: '@toto:example.com' + } + ] + }) + expect(mockUpdate).toHaveBeenCalledTimes(0) + }) + + it('should not call opensearch update method if event.content["m.relates_to"].rel_type is not equal to "m.replace"', async () => { + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + content: { + body: ' * But this room does not have avatar unless you add one', + 'm.mentions': {}, + 'm.new_content': { + body: 'But this room does not have avatar unless you add one', + 'm.mentions': {}, + msgtype: 'm.text' + }, + 'm.relates_to': { + event_id: '$GGTg4DaUAHGVL_pHMAJtMz6F2cAK4cUXug-vkRG-yZQ', + rel_type: 'falsy' + }, + msgtype: 'm.text' + }, + event_id: '$lDH6ZUNilncsYuFpkV-6xmQH9QZM3_KZVVBI95dY6XA', + room_id: 'room6', + sender: '@toto:example.com', + type: 'm.room.message', + user_id: '@toto:example.com' + } + ] + }) + expect(mockUpdate).toHaveBeenCalledTimes(0) + }) + + it('should not call opensearch update method if it is an encrypted room', async () => { + jest + .spyOn(testServer.matrixDb, 'get') + .mockResolvedValue([{ encryption: 'test' }] as unknown as DbGetResult) + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + content: { + body: ' * But this room does not have avatar unless you add one', + 'm.mentions': {}, + 'm.new_content': { + body: 'But this room does not have avatar unless you add one', + 'm.mentions': {}, + msgtype: 'm.text' + }, + 'm.relates_to': { + event_id: '$GGTg4DaUAHGVL_pHMAJtMz6F2cAK4cUXug-vkRG-yZQ', + rel_type: 'm.replace' + }, + msgtype: 'm.text' + }, + event_id: '$lDH6ZUNilncsYuFpkV-6xmQH9QZM3_KZVVBI95dY6XA', + room_id: 'room6', + sender: '@toto:example.com', + type: 'm.room.message', + user_id: '@toto:example.com' + } + ] + }) + expect(mockUpdate).toHaveBeenCalledTimes(0) + }) + + it('should not call logger error method if no problem occurs', async () => { + await supertest(app) + .put(`/_matrix/app/v1/transactions/${transactionId}`) + .auth(homeServerToken, { + type: 'bearer' + }) + .send({ + events: [ + { + content: { + body: ' * But this room does not have avatar unless you add one', + 'm.mentions': {}, + 'm.new_content': { + body: 'But this room does not have avatar unless you add one', + 'm.mentions': {}, + msgtype: 'm.text' + }, + 'm.relates_to': { + event_id: '$GGTg4DaUAHGVL_pHMAJtMz6F2cAK4cUXug-vkRG-yZQ', + rel_type: 'm.replace' + }, + msgtype: 'm.text' + }, + event_id: '$lDH6ZUNilncsYuFpkV-6xmQH9QZM3_KZVVBI95dY6XA', + room_id: 'room6', + sender: '@toto:example.com', + type: 'm.room.message', + user_id: '@toto:example.com' + } + ] + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(0) + expect(mockUpdate).toHaveBeenCalledTimes(1) + }) + }) +}) From 516ea1c12f7c442e2c6893f7b2230129e371d00d Mon Sep 17 00:00:00 2001 From: Jordy Cabannes Date: Sat, 2 Mar 2024 20:11:06 +0100 Subject: [PATCH 10/16] test: restore opensearch indexes endpoint --- .../tests/opensearch-controller.test.ts | 243 ++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 packages/tom-server/src/search-engine-api/tests/opensearch-controller.test.ts diff --git a/packages/tom-server/src/search-engine-api/tests/opensearch-controller.test.ts b/packages/tom-server/src/search-engine-api/tests/opensearch-controller.test.ts new file mode 100644 index 00000000..5f52ff60 --- /dev/null +++ b/packages/tom-server/src/search-engine-api/tests/opensearch-controller.test.ts @@ -0,0 +1,243 @@ +import express, { Router } from 'express' +import supertest from 'supertest' +import TwakeServer from '../..' +import { type Config } from '../../types' +import defaultConfig from '../__testData__/config.json' +import { OpenSearchClientException } from '../utils/error' + +let testServer: TwakeServer +const mockOpenSearchExists = jest + .fn() + .mockResolvedValue({ statusCode: 200, body: true }) + +const mockOpenSearchCreate = jest.fn().mockResolvedValue({ statusCode: 200 }) + +const mockOpenSearchBulk = jest.fn().mockResolvedValue({ statusCode: 200 }) + +jest.mock('@opensearch-project/opensearch', () => ({ + Client: jest.fn().mockImplementation(() => ({ + indices: { + exists: mockOpenSearchExists, + create: mockOpenSearchCreate + }, + bulk: mockOpenSearchBulk, + close: jest.fn() + })) +})) + +jest.mock('@twake/matrix-identity-server', () => ({ + default: jest.fn().mockImplementation(() => ({ + ready: Promise.resolve(true), + db: {}, + userDB: {}, + api: { get: {}, post: {} }, + cleanJobs: jest.fn().mockImplementation(() => testServer.logger.close()) + })), + MatrixDB: jest.fn().mockImplementation(() => ({ + ready: Promise.resolve(true), + get: jest + .fn() + .mockResolvedValueOnce([ + { user_id: '@toto:example.com', display_name: 'Toto' } + ]) + .mockResolvedValueOnce([ + { + event_id: 'event1', + room_id: 'room1', + json: '{"type":"m.room.message","content":{"body":"Hello world","type":"text"}}' + } + ]) + .mockResolvedValueOnce([ + { user_id: '@toto:example.com', display_name: 'Toto' } + ]) + .mockResolvedValueOnce([ + { + event_id: 'event1', + room_id: 'room1', + json: '{"type":"m.room.message","content":{"body":"Hello world","type":"text"}}' + } + ]), + getAll: jest.fn().mockResolvedValue([ + { + room_id: 'room1', + encryption: null, + name: 'Room1' + } + ]), + close: jest.fn() + })), + Utils: { + hostnameRe: + /^((([a-zA-Z0-9][-a-zA-Z0-9]*)?[a-zA-Z0-9])[.])*([a-zA-Z][-a-zA-Z0-9]*[a-zA-Z0-9]|[a-zA-Z])(:(\d+))?$/ + } +})) + +jest.mock('../../identity-server/index.ts', () => { + return function () { + return { + ready: Promise.resolve(true), + db: {}, + userDB: {}, + api: { get: {}, post: {} }, + cleanJobs: jest.fn().mockImplementation(() => testServer.logger.close()) + } + } +}) + +jest.mock('../../application-server/index.ts', () => { + return function () { + return { + router: { + routes: Router() + } + } + } +}) + +jest.mock('../../db/index.ts', () => jest.fn()) + +describe('Search engine API - Opensearch controller', () => { + let app: express.Application + let loggerErrorSpyOn: jest.SpyInstance + const restoreRoute = '/_twake/app/v1/opensearch/restore' + + beforeAll((done) => { + testServer = new TwakeServer(defaultConfig as Config) + loggerErrorSpyOn = jest.spyOn(testServer.logger, 'error') + testServer.ready + .then(() => { + app = express() + app.use(testServer.endpoints) + done() + }) + .catch((e) => { + done(e) + }) + }) + + afterAll(() => { + if (testServer != null) testServer.cleanJobs() + }) + + afterEach(() => { + jest.clearAllMocks() + mockOpenSearchExists.mockResolvedValue({ statusCode: 200, body: true }) + mockOpenSearchCreate.mockResolvedValue({ statusCode: 200 }) + mockOpenSearchBulk.mockResolvedValue({ statusCode: 200 }) + }) + + it('should log an error when an error occured with opensearch exists API', async () => { + const error = new Error('An error occured in opensearch exists API') + mockOpenSearchExists.mockRejectedValue(error) + const response = await supertest(app).post(restoreRoute).send({}) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(1) + expect(loggerErrorSpyOn).toHaveBeenCalledWith(error.message, { + httpMethod: 'POST', + endpointPath: restoreRoute + }) + expect(response.statusCode).toBe(500) + expect(response.body).toEqual({ + error: 'Internal server error' + }) + }) + + it('should call logger when opensearch client response status code is not 20X', async () => { + const error = new Error('An error occured in opensearch exists API') + mockOpenSearchExists.mockResolvedValue({ statusCode: 501, body: error }) + const response = await supertest(app).post(restoreRoute).send({}) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(1) + expect(loggerErrorSpyOn).toHaveBeenCalledWith( + new OpenSearchClientException(JSON.stringify(error, null, 2), 501) + .message, + { + httpMethod: 'POST', + endpointPath: restoreRoute, + status: '501' + } + ) + expect(response.statusCode).toBe(500) + expect(response.body).toEqual({ + error: 'Internal server error' + }) + }) + + it('should log an error when an error occured with opensearch create index API', async () => { + const error = new Error('An error occured in opensearch create index API') + mockOpenSearchExists.mockResolvedValue({ statusCode: 404, body: false }) + mockOpenSearchCreate.mockRejectedValue(error) + const response = await supertest(app).post(restoreRoute).send({}) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(1) + expect(loggerErrorSpyOn).toHaveBeenCalledWith(error.message, { + httpMethod: 'POST', + endpointPath: restoreRoute + }) + expect(response.statusCode).toBe(500) + expect(response.body).toEqual({ + error: 'Internal server error' + }) + }) + + it('should call logger when opensearch client response status code is not 20X', async () => { + const error = new Error('An error occured in opensearch create index API') + mockOpenSearchExists.mockResolvedValue({ statusCode: 404, body: false }) + mockOpenSearchCreate.mockResolvedValue({ statusCode: 502, body: error }) + const response = await supertest(app).post(restoreRoute).send({}) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(1) + expect(loggerErrorSpyOn).toHaveBeenCalledWith( + new OpenSearchClientException(JSON.stringify(error, null, 2), 502) + .message, + { + httpMethod: 'POST', + endpointPath: restoreRoute, + status: '502' + } + ) + expect(response.statusCode).toBe(500) + expect(response.body).toEqual({ + error: 'Internal server error' + }) + }) + + it('should log an error when an error occured with opensearch bulk API', async () => { + const error = new Error('An error occured in opensearch bulk API') + mockOpenSearchExists.mockResolvedValue({ statusCode: 404, body: false }) + mockOpenSearchBulk.mockRejectedValue(error) + const response = await supertest(app).post(restoreRoute).send({}) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(1) + expect(loggerErrorSpyOn).toHaveBeenCalledWith(error.message, { + httpMethod: 'POST', + endpointPath: restoreRoute + }) + expect(response.statusCode).toBe(500) + expect(response.body).toEqual({ + error: 'Internal server error' + }) + }) + + it('should call logger when opensearch client response status code is not 20X', async () => { + const error = new Error('An error occured in opensearch bulk API') + mockOpenSearchBulk.mockResolvedValue({ statusCode: 504, body: error }) + const response = await supertest(app).post(restoreRoute).send({}) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(1) + expect(loggerErrorSpyOn).toHaveBeenCalledWith( + new OpenSearchClientException(JSON.stringify(error, null, 2), 504) + .message, + { + httpMethod: 'POST', + endpointPath: restoreRoute, + status: '504' + } + ) + expect(response.statusCode).toBe(500) + expect(response.body).toEqual({ + error: 'Internal server error' + }) + }) + + it('should send a response with status 204 if no error occurs', async () => { + const response = await supertest(app).post(restoreRoute).send({}) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(0) + expect(response.statusCode).toBe(204) + expect(response.body).toEqual({}) + }) +}) From 066a5edfbcc0c58e19c050b055dddc0ee1b9b91e Mon Sep 17 00:00:00 2001 From: Jordy Cabannes Date: Sat, 2 Mar 2024 20:11:40 +0100 Subject: [PATCH 11/16] test: search mails, messages and rooms endpoint --- .../tests/search-engine-controller.test.ts | 394 ++++++++++++++++++ 1 file changed, 394 insertions(+) create mode 100644 packages/tom-server/src/search-engine-api/tests/search-engine-controller.test.ts diff --git a/packages/tom-server/src/search-engine-api/tests/search-engine-controller.test.ts b/packages/tom-server/src/search-engine-api/tests/search-engine-controller.test.ts new file mode 100644 index 00000000..44ed5c42 --- /dev/null +++ b/packages/tom-server/src/search-engine-api/tests/search-engine-controller.test.ts @@ -0,0 +1,394 @@ +import { AppServerAPIError } from '@twake/matrix-application-server' +import { type DbGetResult } from '@twake/matrix-identity-server' +import express, { Router, type NextFunction } from 'express' +import supertest from 'supertest' +import TwakeServer from '../..' +import { type AuthRequest, type Config } from '../../types' +import defaultConfig from '../__testData__/config.json' +import { OpenSearchClientException } from '../utils/error' + +const initialUserId = '@toto:example.com' +let userId = initialUserId +const userMail = 'toto@example.com' +let testServer: TwakeServer + +const mockUserDBGet = jest.fn().mockResolvedValue([{ mail: userMail }]) +const msearchResponseBody = { + responses: [ + { + hits: { + hits: [ + { + _id: 'room1', + _source: { + name: 'Room1 is not member' + } + }, + { + _id: 'room2', + _source: { + name: 'Room2 is member' + } + } + ] + } + }, + { + hits: { + hits: [ + { + _id: 'message1', + _source: { + room_id: 'room1', + sender: '@john:example.com', + display_name: 'John test', + content: 'Hello world' + } + }, + { + _id: 'message2', + _source: { + display_name: 'Rose', + room_id: 'room2', + sender: '@rose:example.com', + content: 'See you tomorrow world' + } + }, + { + _id: 'message3', + _source: { + display_name: 'Toto', + room_id: 'room2', + sender: initialUserId, + content: 'Goodbye world' + } + } + ] + } + }, + { + hits: { + hits: [ + { + _id: 'mail1', + _source: { + bcc: [{ address: userMail }], + cc: [{ address: userMail }], + from: [{ address: userMail }], + to: [{ address: userMail }] + } + }, + { + _id: 'mail2', + _source: { + bcc: [{ address: 'other@example.com' }], + cc: [{ address: 'other@example.com' }], + from: [{ address: 'other@example.com' }, { address: userMail }], + to: [{ address: 'other@example.com' }] + } + }, + { + _id: 'mail3', + _source: { + bcc: [{ address: 'other@example.com' }], + cc: [{ address: 'other@example.com' }], + from: [{ address: 'other@example.com' }], + to: [{ address: 'other@example.com' }] + } + } + ] + } + } + ] +} + +const mockMSearchMock = jest.fn().mockResolvedValue({ + statusCode: 200, + body: msearchResponseBody +}) + +jest.mock('@opensearch-project/opensearch', () => ({ + Client: jest.fn().mockImplementation(() => ({ + indices: { + exists: jest.fn().mockResolvedValue({ statusCode: 200, body: true }) + }, + msearch: mockMSearchMock, + close: jest.fn() + })) +})) + +jest.mock('@twake/matrix-identity-server', () => ({ + default: jest.fn().mockImplementation(() => ({ + ready: Promise.resolve(true), + db: {}, + userDB: { get: mockUserDBGet }, + api: { get: {}, post: {} }, + cleanJobs: jest.fn().mockImplementation(() => testServer.logger.close()) + })), + MatrixDB: jest.fn().mockImplementation(() => ({ + ready: Promise.resolve(true), + get: jest.fn().mockResolvedValue([]), + getAll: jest.fn().mockResolvedValue([]), + close: jest.fn() + })), + Utils: { + hostnameRe: + /^((([a-zA-Z0-9][-a-zA-Z0-9]*)?[a-zA-Z0-9])[.])*([a-zA-Z][-a-zA-Z0-9]*[a-zA-Z0-9]|[a-zA-Z])(:(\d+))?$/ + } +})) + +jest.mock('../../identity-server/index.ts', () => { + return function () { + return { + ready: Promise.resolve(true), + db: {}, + userDB: { get: mockUserDBGet }, + api: { get: {}, post: {} }, + cleanJobs: jest.fn().mockImplementation(() => testServer.logger.close()) + } + } +}) + +jest.mock('../../application-server/index.ts', () => { + return function () { + return { + router: { + routes: Router() + } + } + } +}) + +jest.mock('../../db/index.ts', () => jest.fn()) + +jest.mock('../../utils/middlewares/auth.middleware.ts', () => + jest + .fn() + .mockReturnValue((req: AuthRequest, res: Response, next: NextFunction) => { + req.userId = userId + next() + }) +) + +describe('Search engine API - Search engine controller', () => { + let app: express.Application + let loggerErrorSpyOn: jest.SpyInstance + const searchRoute = '/_twake/app/v1/search' + + beforeAll((done) => { + testServer = new TwakeServer(defaultConfig as Config) + loggerErrorSpyOn = jest.spyOn(testServer.logger, 'error') + testServer.ready + .then(() => { + app = express() + app.use(testServer.endpoints) + done() + }) + .catch((e) => { + done(e) + }) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + afterAll(() => { + if (testServer != null) testServer.cleanJobs() + }) + + it('should log an error when request body does not content searchValue property', async () => { + const response = await supertest(app).post(searchRoute).send({}) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(1) + expect(loggerErrorSpyOn).toHaveBeenCalledWith( + new AppServerAPIError({ + status: 400, + message: 'Error field: Invalid value (property: searchValue)' + }).message, + { + httpMethod: 'POST', + endpointPath: searchRoute, + matrixUserId: userId, + status: '400' + } + ) + expect(response.statusCode).toBe(400) + expect(response.body).toEqual({ + error: 'Error field: Invalid value (property: searchValue)' + }) + }) + + it('should log an error when searchValue property in request body is not a string', async () => { + const response = await supertest(app) + .post(searchRoute) + .send({ searchValue: 123 }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(1) + expect(loggerErrorSpyOn).toHaveBeenCalledWith( + new AppServerAPIError({ + status: 400, + message: 'Error field: Invalid value (property: searchValue)' + }).message, + { + httpMethod: 'POST', + endpointPath: searchRoute, + matrixUserId: userId, + status: '400' + } + ) + expect(response.statusCode).toBe(400) + expect(response.body).toEqual({ + error: 'Error field: Invalid value (property: searchValue)' + }) + }) + + it('should log an error when auth middleware set a wrong userId in request', async () => { + userId = 'falsy_userId' + const response = await supertest(app).post(searchRoute).send({ + searchValue: 'test' + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(1) + expect(loggerErrorSpyOn).toHaveBeenCalledWith( + new Error('Cannot extract user uid from matrix user id falsy_userId') + .message, + { + httpMethod: 'POST', + endpointPath: searchRoute, + matrixUserId: userId + } + ) + expect(response.statusCode).toBe(500) + expect(response.body).toEqual({ error: 'Internal server error' }) + userId = initialUserId + }) + + it('should log an error when userDB client does not find user with uid matching req.userId', async () => { + mockUserDBGet.mockResolvedValue([]) + const response = await supertest(app).post(searchRoute).send({ + searchValue: 'test' + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(1) + expect(loggerErrorSpyOn).toHaveBeenCalledWith( + new Error('User with user id toto not found').message, + { + httpMethod: 'POST', + endpointPath: searchRoute, + matrixUserId: userId + } + ) + expect(response.statusCode).toBe(500) + expect(response.body).toEqual({ error: 'Internal server error' }) + mockUserDBGet.mockResolvedValue([{ mail: userMail }]) + }) + + it('should log an error when opensearch client returns a reponse with status code not equal to 200', async () => { + const errorMessage = 'An error occured in opensearch msearch API' + mockMSearchMock.mockResolvedValue({ + body: { + text: errorMessage + }, + statusCode: 502 + }) + const response = await supertest(app).post(searchRoute).send({ + searchValue: 'test' + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(1) + expect(loggerErrorSpyOn).toHaveBeenCalledWith( + new OpenSearchClientException( + JSON.stringify( + { + text: errorMessage + }, + null, + 2 + ), + response.statusCode + ).message, + { + httpMethod: 'POST', + endpointPath: searchRoute, + matrixUserId: userId, + status: '502' + } + ) + expect(response.statusCode).toBe(500) + expect(response.body).toEqual({ error: 'Internal server error' }) + mockMSearchMock.mockResolvedValue({ + body: msearchResponseBody, + statusCode: 200 + }) + }) + + it('should return rooms, mails and messages matching search', async () => { + jest + .spyOn(testServer.matrixDb, 'get') + .mockResolvedValueOnce([ + { room_id: 'room1', membership: 'invite' }, + { room_id: 'room1', membership: 'join' }, + { room_id: 'room1', membership: 'leave' }, + { room_id: 'room2', membership: 'invite' }, + { room_id: 'room2', membership: 'join' } + ]) + .mockResolvedValueOnce([ + { name: 'Room1 is not member', avatar_url: null }, + { name: 'Room2 is member', avatar_url: 'avatar_room2' } + ] as DbGetResult) + .mockResolvedValueOnce([ + { + room_id: 'room1', + json: '{"type":"m.room.member","content":{"is_direct":false}}' + }, + { + room_id: 'room2', + json: '{"type":"m.room.member","content":{"is_direct":true}}' + } + ]) + .mockResolvedValueOnce([ + { room_id: 'room2', user_id: userId, avatar_url: 'toto_avatar' }, + { + room_id: 'room2', + user_id: '@rose:example.com', + avatar_url: 'rose_avatar' + } + ]) + const response = await supertest(app).post(searchRoute).send({ + searchValue: 'test' + }) + expect(loggerErrorSpyOn).toHaveBeenCalledTimes(0) + expect(response.statusCode).toBe(200) + expect(response.body.rooms).toHaveLength(1) + expect(response.body.rooms[0]).toEqual({ + room_id: 'room2', + name: 'Room2 is member' + }) + expect(response.body.messages).toHaveLength(2) + expect(response.body.messages[0]).toEqual({ + room_id: 'room2', + event_id: 'message2', + content: 'See you tomorrow world', + display_name: 'Rose', + avatar_url: 'rose_avatar' + }) + expect(response.body.messages[1]).toEqual({ + room_id: 'room2', + event_id: 'message3', + content: 'Goodbye world', + display_name: 'Toto', + avatar_url: 'rose_avatar' + }) + expect(response.body.mails).toHaveLength(2) + expect(response.body.mails[0]).toEqual({ + id: 'mail1', + bcc: [{ address: userMail }], + cc: [{ address: userMail }], + from: [{ address: userMail }], + to: [{ address: userMail }] + }) + expect(response.body.mails[1]).toEqual({ + id: 'mail2', + bcc: [{ address: 'other@example.com' }], + cc: [{ address: 'other@example.com' }], + from: [{ address: 'other@example.com' }, { address: userMail }], + to: [{ address: 'other@example.com' }] + }) + }) +}) From c8cc8823d0d58662361f14eaa479fba3e2afcddc Mon Sep 17 00:00:00 2001 From: Jordy Cabannes Date: Sat, 2 Mar 2024 20:12:42 +0100 Subject: [PATCH 12/16] fix: ports allocation for integration tests --- .../src/application-server/__testData__/config.json | 2 +- .../src/application-server/__testData__/docker-compose.yml | 4 +++- .../src/application-server/__testData__/llng/lmConf-1.json | 2 +- .../__testData__/synapse-data/homeserver.yaml | 2 +- packages/tom-server/src/application-server/index.test.ts | 5 ++++- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/tom-server/src/application-server/__testData__/config.json b/packages/tom-server/src/application-server/__testData__/config.json index 3759e3ac..9d78d7f7 100644 --- a/packages/tom-server/src/application-server/__testData__/config.json +++ b/packages/tom-server/src/application-server/__testData__/config.json @@ -15,7 +15,7 @@ "template_dir": "./templates", "ldap_base": "dc=example,dc=com", "ldap_uri": "ldap://localhost:21389/", - "matrix_server": "matrix.example.com", + "matrix_server": "matrix.example.com:444", "registration_file_path": "./src/application-server/__testData__/synapse-data/registration.yaml", "matrix_database_engine": "sqlite", "matrix_database_host": "./src/application-server/__testData__/synapse-data/homeserver.db", diff --git a/packages/tom-server/src/application-server/__testData__/docker-compose.yml b/packages/tom-server/src/application-server/__testData__/docker-compose.yml index 2fc27899..818ed188 100644 --- a/packages/tom-server/src/application-server/__testData__/docker-compose.yml +++ b/packages/tom-server/src/application-server/__testData__/docker-compose.yml @@ -37,7 +37,9 @@ services: nginx-proxy: image: nginxproxy/nginx-proxy ports: - - 443:443 + - 444:444 + environment: + - HTTPS_PORT=444 volumes: - /var/run/docker.sock:/tmp/docker.sock:ro - ./nginx/ssl:/etc/nginx/certs \ No newline at end of file diff --git a/packages/tom-server/src/application-server/__testData__/llng/lmConf-1.json b/packages/tom-server/src/application-server/__testData__/llng/lmConf-1.json index 1aa95886..3248f5ac 100644 --- a/packages/tom-server/src/application-server/__testData__/llng/lmConf-1.json +++ b/packages/tom-server/src/application-server/__testData__/llng/lmConf-1.json @@ -250,7 +250,7 @@ "oidcRPMetaDataOptionsLogoutSessionRequired": 1, "oidcRPMetaDataOptionsLogoutType": "back", "oidcRPMetaDataOptionsPublic": 0, - "oidcRPMetaDataOptionsRedirectUris": "https://matrix.example.com/_synapse/client/oidc/callback", + "oidcRPMetaDataOptionsRedirectUris": "https://matrix.example.com:444/_synapse/client/oidc/callback", "oidcRPMetaDataOptionsRefreshToken": 0, "oidcRPMetaDataOptionsRequirePKCE": 0 } diff --git a/packages/tom-server/src/application-server/__testData__/synapse-data/homeserver.yaml b/packages/tom-server/src/application-server/__testData__/synapse-data/homeserver.yaml index fb19c796..7e180d96 100644 --- a/packages/tom-server/src/application-server/__testData__/synapse-data/homeserver.yaml +++ b/packages/tom-server/src/application-server/__testData__/synapse-data/homeserver.yaml @@ -10,7 +10,7 @@ # each option, go to docs/usage/configuration/config_documentation.md or # https://matrix-org.github.io/synapse/latest/usage/configuration/config_documentation.html server_name: "example.com" -public_baseurl: "https://matrix.example.com/" +public_baseurl: "https://matrix.example.com:444/" pid_file: /data/homeserver.pid listeners: - port: 8008 diff --git a/packages/tom-server/src/application-server/index.test.ts b/packages/tom-server/src/application-server/index.test.ts index 07c204a7..3d138256 100644 --- a/packages/tom-server/src/application-server/index.test.ts +++ b/packages/tom-server/src/application-server/index.test.ts @@ -81,7 +81,10 @@ describe('ApplicationServer', () => { redirect: 'manual' } ) - let location = response.headers.get('location') as string + let location = (response.headers.get('location') as string).replace( + 'auth.example.com', + 'auth.example.com:444' + ) const matrixCookies = response.headers.get('set-cookie') response = await fetch.default(location) body = await response.text() From cdaae699f890b7b9fe52ab515eeeca282d1dda6d Mon Sep 17 00:00:00 2001 From: Jordy Cabannes Date: Sat, 2 Mar 2024 20:14:04 +0100 Subject: [PATCH 13/16] test: integration tests setup --- .github/workflows/build-and-test.yml | 2 +- package.json | 2 +- packages/tom-server/jest.config.js | 2 +- .../ldap/ldif/base_ldap_users.ldif | 2 +- .../__testData__/db/init-id-db.sh | 6 + .../__testData__/db/init-llng-db.sh | 95 ++++ .../__testData__/db/init-synapse-db.sh | 6 + .../__testData__/docker-compose.yml | 97 ++++ .../generate-self-signed-certificate.sh | 52 ++ .../images/anakin-at-the-office.jpg | Bin 0 -> 148683 bytes .../__testData__/ldap/Dockerfile | 44 ++ .../ldap/ldif/base_ldap_users.ldif | 174 +++++++ .../ldap/ldif/config-20230322180123.ldif | 260 ++++++++++ .../__testData__/llng/lmConf-1.json | 481 ++++++++++++++++++ .../__testData__/llng/ssl.conf | 12 + .../__testData__/nginx/ssl/9da13359.0 | 1 + .../nginx/ssl/auth.example.com.crt | 30 ++ .../nginx/ssl/auth.example.com.key | 52 ++ .../__testData__/nginx/ssl/ca.key | 52 ++ .../__testData__/nginx/ssl/ca.pem | 32 ++ .../nginx/ssl/matrix.example.com.crt | 30 ++ .../nginx/ssl/matrix.example.com.key | 52 ++ .../nginx/ssl/opensearch.example.com.crt | 30 ++ .../nginx/ssl/opensearch.example.com.key | 52 ++ .../__testData__/opensearch/tmail-data.json | 301 +++++++++++ .../opensearch/tmail-mapping.json | 210 ++++++++ .../__testData__/synapse-data/homeserver.yaml | 65 +++ .../matrix.example.com.log.config | 77 +++ .../synapse-data/registration.yaml | 10 + 29 files changed, 2225 insertions(+), 4 deletions(-) create mode 100644 packages/tom-server/src/search-engine-api/__testData__/db/init-id-db.sh create mode 100644 packages/tom-server/src/search-engine-api/__testData__/db/init-llng-db.sh create mode 100644 packages/tom-server/src/search-engine-api/__testData__/db/init-synapse-db.sh create mode 100644 packages/tom-server/src/search-engine-api/__testData__/docker-compose.yml create mode 100755 packages/tom-server/src/search-engine-api/__testData__/generate-self-signed-certificate.sh create mode 100644 packages/tom-server/src/search-engine-api/__testData__/images/anakin-at-the-office.jpg create mode 100644 packages/tom-server/src/search-engine-api/__testData__/ldap/Dockerfile create mode 100644 packages/tom-server/src/search-engine-api/__testData__/ldap/ldif/base_ldap_users.ldif create mode 100644 packages/tom-server/src/search-engine-api/__testData__/ldap/ldif/config-20230322180123.ldif create mode 100644 packages/tom-server/src/search-engine-api/__testData__/llng/lmConf-1.json create mode 100644 packages/tom-server/src/search-engine-api/__testData__/llng/ssl.conf create mode 120000 packages/tom-server/src/search-engine-api/__testData__/nginx/ssl/9da13359.0 create mode 100644 packages/tom-server/src/search-engine-api/__testData__/nginx/ssl/auth.example.com.crt create mode 100644 packages/tom-server/src/search-engine-api/__testData__/nginx/ssl/auth.example.com.key create mode 100644 packages/tom-server/src/search-engine-api/__testData__/nginx/ssl/ca.key create mode 100644 packages/tom-server/src/search-engine-api/__testData__/nginx/ssl/ca.pem create mode 100644 packages/tom-server/src/search-engine-api/__testData__/nginx/ssl/matrix.example.com.crt create mode 100644 packages/tom-server/src/search-engine-api/__testData__/nginx/ssl/matrix.example.com.key create mode 100644 packages/tom-server/src/search-engine-api/__testData__/nginx/ssl/opensearch.example.com.crt create mode 100644 packages/tom-server/src/search-engine-api/__testData__/nginx/ssl/opensearch.example.com.key create mode 100644 packages/tom-server/src/search-engine-api/__testData__/opensearch/tmail-data.json create mode 100644 packages/tom-server/src/search-engine-api/__testData__/opensearch/tmail-mapping.json create mode 100644 packages/tom-server/src/search-engine-api/__testData__/synapse-data/homeserver.yaml create mode 100644 packages/tom-server/src/search-engine-api/__testData__/synapse-data/matrix.example.com.log.config create mode 100644 packages/tom-server/src/search-engine-api/__testData__/synapse-data/registration.yaml diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index a0cc4493..02067e1c 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -25,7 +25,7 @@ jobs: needs: build steps: - name: Add hosts for integration tests - run: sudo echo "127.0.0.1 localhost auth.example.com matrix.example.com matrix1.example.com matrix2.example.com matrix3.example.com federation.example.com" | sudo tee -a /etc/hosts + run: sudo echo "127.0.0.1 localhost auth.example.com matrix.example.com matrix1.example.com matrix2.example.com matrix3.example.com federation.example.com opensearch.example.com" | sudo tee -a /etc/hosts - uses: actions/checkout@v3 - name: Set up Node LTS uses: actions/setup-node@v3 diff --git a/package.json b/package.json index 3de4ed81..befb7867 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "supertest": "^6.3.3", "swagger-jsdoc": "^6.2.8", "swagger-ui-dist": "^4.18.3", - "testcontainers": "^9.8.0", + "testcontainers": "^10.6.0", "toad-cache": "^3.3.0", "ts-jest": "^29.1.0", "typescript": "^4.9.5" diff --git a/packages/tom-server/jest.config.js b/packages/tom-server/jest.config.js index 69342229..c0f2dfe7 100644 --- a/packages/tom-server/jest.config.js +++ b/packages/tom-server/jest.config.js @@ -2,7 +2,7 @@ import jestConfigBase from '../../jest-base.config.js' export default { ...jestConfigBase, - testTimeout: 120000, + testTimeout: 420000, setupFilesAfterEnv: ['/jest.setup.ts'], moduleNameMapper: { ...jestConfigBase.moduleNameMapper, diff --git a/packages/tom-server/src/application-server/__testData__/ldap/ldif/base_ldap_users.ldif b/packages/tom-server/src/application-server/__testData__/ldap/ldif/base_ldap_users.ldif index 04025bd6..eb4e7f3f 100644 --- a/packages/tom-server/src/application-server/__testData__/ldap/ldif/base_ldap_users.ldif +++ b/packages/tom-server/src/application-server/__testData__/ldap/ldif/base_ldap_users.ldif @@ -135,7 +135,7 @@ userPassword: jfett dn: uid=lskywalker,ou=users,dc=example,dc=com objectClass: inetOrgPerson uid: lskywalker -cn: Luc Skywalker +cn: Luke Skywalker sn: Lskywalker mail: lskywalker@example.com userPassword: lskywalker diff --git a/packages/tom-server/src/search-engine-api/__testData__/db/init-id-db.sh b/packages/tom-server/src/search-engine-api/__testData__/db/init-id-db.sh new file mode 100644 index 00000000..b3ff0da5 --- /dev/null +++ b/packages/tom-server/src/search-engine-api/__testData__/db/init-id-db.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +psql -U postgres <<-EOSQL + CREATE USER twake PASSWORD 'twake!1'; + CREATE DATABASE identity TEMPLATE='template0' LOCALE='C' ENCODING='UTF8' OWNER='twake'; +EOSQL \ No newline at end of file diff --git a/packages/tom-server/src/search-engine-api/__testData__/db/init-llng-db.sh b/packages/tom-server/src/search-engine-api/__testData__/db/init-llng-db.sh new file mode 100644 index 00000000..80d7a094 --- /dev/null +++ b/packages/tom-server/src/search-engine-api/__testData__/db/init-llng-db.sh @@ -0,0 +1,95 @@ +#!/bin/sh +set -e + +DATABASE=${PG_DATABASE:-lemonldapng} +USER=${PG_USER:-lemonldap} +PASSWORD=${PG_PASSWORD:-lemonldap} +TABLE=${PG_TABLE:-lmConfig} +PTABLE=${PG_PERSISTENT_SESSIONS_TABLE:-psessions} +STABLE=${PG_SESSIONS_TABLE:-sessions} +SAMLTABLE=${PG_SAML_TABLE:-samlsessions} +OIDCTABLE=${PG_OIDC_TABLE:-oidcsessions} +CASTABLE=${PG_CAS_TABLE:-cassessions} + +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL + CREATE USER $USER PASSWORD '$PASSWORD'; + CREATE DATABASE $DATABASE; +EOSQL +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$DATABASE" <<-EOSQL + CREATE TABLE $TABLE ( + cfgNum integer not null primary key, + data text + ); + GRANT ALL PRIVILEGES ON TABLE $TABLE TO $USER; + + CREATE TABLE $PTABLE ( + id varchar(64) not null primary key, + a_session jsonb + ); + CREATE INDEX i_p__session_kind ON psessions ((a_session ->> '_session_kind')); + CREATE INDEX i_p__httpSessionType ON psessions ((a_session ->> '_httpSessionType')); + CREATE INDEX i_p__session_uid ON psessions ((a_session ->> '_session_uid')); + CREATE INDEX i_p_ipAddr ON psessions ((a_session ->> 'ipAddr')); + CREATE INDEX i_p__whatToTrace ON psessions ((a_session ->> '_whatToTrace')); + GRANT ALL PRIVILEGES ON TABLE $PTABLE TO $USER; + + CREATE UNLOGGED TABLE $STABLE ( + id varchar(64) not null primary key, + a_session jsonb + ); + CREATE INDEX i_s__whatToTrace ON sessions ((a_session ->> '_whatToTrace')); + CREATE INDEX i_s__session_kind ON sessions ((a_session ->> '_session_kind')); + CREATE INDEX i_s__utime ON sessions ((cast (a_session ->> '_utime' as bigint))); + CREATE INDEX i_s_ipAddr ON sessions ((a_session ->> 'ipAddr')); + CREATE INDEX i_s__httpSessionType ON sessions ((a_session ->> '_httpSessionType')); + CREATE INDEX i_s_user ON sessions ((a_session ->> 'user')); + GRANT ALL PRIVILEGES ON TABLE $STABLE TO $USER; + + CREATE UNLOGGED TABLE $SAMLTABLE ( + id varchar(64) not null primary key, + a_session jsonb + ); + CREATE INDEX i_a__session_kind ON $SAMLTABLE ((a_session ->> '_session_kind')); + CREATE INDEX i_a__utime ON $SAMLTABLE ((cast(a_session ->> '_utime' as bigint))); + CREATE INDEX i_a_ProxyID ON $SAMLTABLE ((a_session ->> 'ProxyID')); + CREATE INDEX i_a__nameID ON $SAMLTABLE ((a_session ->> '_nameID')); + CREATE INDEX i_a__assert_id ON $SAMLTABLE ((a_session ->> '_assert_id')); + CREATE INDEX i_a__art_id ON $SAMLTABLE ((a_session ->> '_art_id')); + CREATE INDEX i_a__saml_id ON $SAMLTABLE ((a_session ->> '_saml_id')); + GRANT ALL PRIVILEGES ON TABLE $SAMLTABLE TO $USER; + + CREATE UNLOGGED TABLE $OIDCTABLE ( + id varchar(64) not null primary key, + a_session jsonb + ); + CREATE INDEX i_o__session_kind ON $OIDCTABLE ((a_session ->> '_session_kind')); + CREATE INDEX i_o__utime ON $OIDCTABLE ((cast(a_session ->> '_utime' as bigint ))); + GRANT ALL PRIVILEGES ON TABLE $OIDCTABLE TO $USER; + + CREATE UNLOGGED TABLE $CASTABLE ( + id varchar(64) not null primary key, + a_session jsonb + ); + CREATE INDEX i_c__session_kind ON $CASTABLE ((a_session ->> '_session_kind')); + CREATE INDEX i_c__utime ON $CASTABLE ((cast(a_session ->> '_utime' as bigint))); + CREATE INDEX i_c__cas_id ON $CASTABLE ((a_session ->> '_cas_id')); + CREATE INDEX i_c_pgtIou ON $CASTABLE ((a_session ->> 'pgtIou')); + GRANT ALL PRIVILEGES ON TABLE $CASTABLE TO $USER; +EOSQL + +if test -e /llng-conf/conf.json; then + SERIALIZED=`perl -MJSON -e '$/=undef; + open F, "/llng-conf/conf.json" or die $!; + $a=JSON::from_json(); + $a->{cfgNum}=1; + $a=JSON::to_json($a); + $a=~s/'\''/'\'\''/g; + $a =~ s/\\\\/\\\\\\\\/g; + print $a;'` + echo "set val '$SERIALIZED'" >&2 + psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$DATABASE" <<-EOSQL + \\set val '$SERIALIZED' + INSERT INTO $TABLE (cfgNum, data) VALUES (1, :'val'); + \\unset val +EOSQL +fi diff --git a/packages/tom-server/src/search-engine-api/__testData__/db/init-synapse-db.sh b/packages/tom-server/src/search-engine-api/__testData__/db/init-synapse-db.sh new file mode 100644 index 00000000..2a6f1859 --- /dev/null +++ b/packages/tom-server/src/search-engine-api/__testData__/db/init-synapse-db.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +psql -U postgres <<-EOSQL + CREATE USER synapse PASSWORD 'synapse!1'; + CREATE DATABASE synapse TEMPLATE='template0' LOCALE='C' ENCODING='UTF8' OWNER='synapse'; +EOSQL \ No newline at end of file diff --git a/packages/tom-server/src/search-engine-api/__testData__/docker-compose.yml b/packages/tom-server/src/search-engine-api/__testData__/docker-compose.yml new file mode 100644 index 00000000..86361d5d --- /dev/null +++ b/packages/tom-server/src/search-engine-api/__testData__/docker-compose.yml @@ -0,0 +1,97 @@ +version: '3.8' + +networks: + twake_chat: + +services: + postgresql: + image: postgres:13-bullseye + volumes: + - ./synapse-data/matrix.example.com.log.config:/data/matrix.example.com.log.config + - ./db/init-synapse-db.sh:/docker-entrypoint-initdb.d/init-synapse-db.sh + - ./db/init-llng-db.sh:/docker-entrypoint-initdb.d/init-llng-db.sh + - ./db/init-id-db.sh:/docker-entrypoint-initdb.d/init-id-db.sh + - ./llng/lmConf-1.json:/llng-conf/conf.json + environment: + - POSTGRES_PASSWORD=synapse!! + healthcheck: + test: ['CMD-SHELL', 'pg_isready'] + interval: 10s + timeout: 5s + retries: 5 + ports: + - 5433:5432 + networks: + - twake_chat + + synapse: + image: matrixdotorg/synapse:v1.89.0 + volumes: + - ./synapse-data:/data + - ./nginx/ssl/ca.pem:/etc/ssl/certs/ca.pem + - ./nginx/ssl/9da13359.0:/etc/ssl/certs/9da13359.0 + depends_on: + - auth + environment: + - UID=${MYUID} + - VIRTUAL_PORT=8008 + - VIRTUAL_HOST=matrix.example.com + healthcheck: + test: ["CMD", "curl", "-fSs", "http://localhost:8008/health"] + interval: 10s + timeout: 10s + retries: 3 + networks: + - twake_chat + extra_hosts: + - "host.docker.internal:host-gateway" + + auth: + image: yadd/lemonldap-ng-portal:2.16.1-bullseye + volumes: + - ./llng/lmConf-1.json:/var/lib/lemonldap-ng/conf/lmConf-1.json + - ./llng/ssl.conf:/etc/nginx/sites-enabled/0000default.conf + - ./nginx/ssl/auth.example.com.crt:/etc/nginx/ssl/auth.example.com.crt + - ./nginx/ssl/auth.example.com.key:/etc/nginx/ssl/auth.example.com.key + environment: + - PORTAL=https://auth.example.com + - VIRTUAL_HOST=auth.example.com + - PG_SERVER=postgresql + depends_on: + postgresql: + condition: service_healthy + networks: + - twake_chat + + annuaire: + image: ldap + build: ./ldap + ports: + - 21390:389 + networks: + - twake_chat + + # opensearchdashboard: + # image: opensearchproject/opensearch-dashboards + # ports: + # - 5601:5601 + # expose: + # - "5601" + # environment: + # - OPENSEARCH_HOSTS=http://opensearch:9200 + # - DISABLE_SECURITY_DASHBOARDS_PLUGIN=true + # networks: + # - twake_chat + + nginx-proxy: + image: nginxproxy/nginx-proxy + ports: + - 445:443 + volumes: + - /var/run/docker.sock:/tmp/docker.sock:ro + - ./nginx/ssl:/etc/nginx/certs + networks: + twake_chat: + aliases: + - matrix.example.com + - auth.example.com diff --git a/packages/tom-server/src/search-engine-api/__testData__/generate-self-signed-certificate.sh b/packages/tom-server/src/search-engine-api/__testData__/generate-self-signed-certificate.sh new file mode 100755 index 00000000..ea5f1a5c --- /dev/null +++ b/packages/tom-server/src/search-engine-api/__testData__/generate-self-signed-certificate.sh @@ -0,0 +1,52 @@ +#!/bin/sh + +# tu use this cript, execute this file with domain as first argument or "-ip" as first argument and host IP as second parameter if you want to set subjectAltName property +SCRIPT_PARENT_PATH=$( cd "$(dirname "$0")" ; pwd -P ) +ADDITIONAL_PARAMS="" +COMMON_NAME=$1 +if [ "$1" = "-ip" ]; then + COMMON_NAME=$2 + echo "subjectAltName = IP:$COMMON_NAME" > $SCRIPT_PARENT_PATH/openssl-ext.cnf + ADDITIONAL_PARAMS="-extfile $SCRIPT_PARENT_PATH/openssl-ext.cnf" +fi + +CERTIFICATE_KEY=$COMMON_NAME.key +CERTIFICATE_CRT=$COMMON_NAME.crt +CA_CRT_PATH=$SCRIPT_PARENT_PATH/nginx/ssl/ca.pem +CA_KEY_PATH=$SCRIPT_PARENT_PATH/nginx/ssl/ca.key + +cd $SCRIPT_PARENT_PATH +openssl genrsa -out $CERTIFICATE_KEY 4096 +openssl req \ + -new \ + -key $CERTIFICATE_KEY \ + -nodes \ + -out server.csr \ + -subj "/C=FR/ST=Centre/L=Paris/O=Linagora/OU=IT/CN=$COMMON_NAME" +if [ ! -f "$CA_CRT_PATH" ]; then + openssl genrsa -out ca.key 4096 + openssl req \ + -new \ + -x509 \ + -nodes \ + -days 36500 \ + -key ca.key \ + -out ca.pem \ + -subj "/C=AU/ST=Some-State/O=Internet Widgits Pty Ltd" + mv ca.pem ca.key $SCRIPT_PARENT_PATH/nginx/ssl +fi +openssl x509 \ + -req \ + -in server.csr \ + -CAkey $CA_KEY_PATH \ + -CA $CA_CRT_PATH \ + -set_serial -01 \ + -out $CERTIFICATE_CRT \ + -days 36500 \ + -sha256 $ADDITIONAL_PARAMS +openssl verify -CAfile $CA_CRT_PATH $CERTIFICATE_CRT +mv $CERTIFICATE_KEY $CERTIFICATE_CRT $SCRIPT_PARENT_PATH/nginx/ssl +rm server.csr +if [ -f "openssl-ext.cnf" ]; then + rm openssl-ext.cnf +fi \ No newline at end of file diff --git a/packages/tom-server/src/search-engine-api/__testData__/images/anakin-at-the-office.jpg b/packages/tom-server/src/search-engine-api/__testData__/images/anakin-at-the-office.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f3b290417d67e28001ccbf48328b9c022ad89c5d GIT binary patch literal 148683 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 l0{ /usr/share/keyrings/ltb-project-openldap-archive-keyring.gpg && \ + echo "deb [arch=amd64 signed-by=/usr/share/keyrings/ltb-project-openldap-archive-keyring.gpg] https://ltb-project.org/debian/openldap25/bullseye bullseye main" > /etc/apt/sources.list.d/ltb-project.list && \ + apt-get update && \ + apt-get install -y openldap-ltb openldap-ltb-contrib-overlays openldap-ltb-mdb-utils ldap-utils && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +# Copy configuration files +COPY ./ldif/config-20230322180123.ldif /var/backups/openldap/ +COPY ./ldif/base_ldap_users.ldif /tmp + +# Configure LDAP +RUN rm -rf /usr/local/openldap/var/lib/ldap /usr/local/openldap/etc/openldap/slapd.d && \ + mkdir -p /usr/local/openldap/var/lib/ldap && \ + chown -R ldap:ldap /usr/local/openldap/var/lib/ldap && \ + mkdir -p /usr/local/openldap/etc/openldap/slapd.d && \ + chown -R ldap:ldap /usr/local/openldap/etc/openldap/slapd.d && \ + usr/local/openldap/sbin/slapd-cli restoreconfig -b /var/backups/openldap/config-20230322180123.ldif && \ + mkdir -p /usr/local/openldap/var/lib/ldap/data && \ + chown -R ldap:ldap /usr/local/openldap/var/lib/ldap/data && \ + /usr/local/openldap/sbin/slapadd -F /usr/local/openldap/etc/openldap/slapd.d/ -b "dc=example,dc=com" -l /tmp/base_ldap_users.ldif + +# Expose LDAP port +EXPOSE 389 + +# Define LDAP data volume +VOLUME /usr/local/openldap/var/openldap-data + +# Set the entrypoint script +CMD ["/usr/local/openldap/libexec/slapd", "-h", "ldap://*", "-u", "ldap", "-g", "ldap", "-d", "256"] + diff --git a/packages/tom-server/src/search-engine-api/__testData__/ldap/ldif/base_ldap_users.ldif b/packages/tom-server/src/search-engine-api/__testData__/ldap/ldif/base_ldap_users.ldif new file mode 100644 index 00000000..eb4e7f3f --- /dev/null +++ b/packages/tom-server/src/search-engine-api/__testData__/ldap/ldif/base_ldap_users.ldif @@ -0,0 +1,174 @@ +dn: dc=example,dc=com +objectClass: top +objectClass: organization +objectClass: dcObject +dc: example +o: Example + +dn: ou=users,dc=example,dc=com +objectClass: top +objectClass: organizationalUnit +ou: users + +dn: uid=dwho,ou=users,dc=example,dc=com +objectClass: inetOrgPerson +uid: dwho +cn: Dr Who +sn: Dwho +mail: dwho@example.com +mobile: 33671298765 +userPassword: dwho + +dn: uid=rtyler,ou=users,dc=example,dc=com +objectClass: inetOrgPerson +uid: rtyler +cn: Rose Tyler +sn: Rtyler +mail: rtyler@example.com +mobile: 33671298767 +userPassword: rtyler + +dn: uid=msmith,ou=users,dc=example,dc=com +objectClass: inetOrgPerson +uid: msmith +cn: Mr Smith +sn: Msmith +mail: msmith@example.com +userPassword: msmith + +dn: uid=okenobi,ou=users,dc=example,dc=com +objectClass: inetOrgPerson +uid: okenobi +cn: Obi-Wan Kenobi +sn: Okenobi +mail: okenobi@example.com +userPassword: okenobi + +dn: uid=qjinn,ou=users,dc=example,dc=com +objectClass: inetOrgPerson +uid: qjinn +cn: Qui-Gon Jinn +sn: Qgonjinn +mail: qjinn@example.com +userPassword: qjinn + +dn: uid=chewbacca,ou=users,dc=example,dc=com +objectClass: inetOrgPerson +uid: chewbacca +cn: Chewbacca +sn: Chewbacca +mail: chewbacca@example.com +userPassword: chewbacca + +dn: uid=lorgana,ou=users,dc=example,dc=com +objectClass: inetOrgPerson +uid: lorgana +cn: Leia Organa +sn: Lorgana +mail: lorgana@example.com +userPassword: lorgana + +dn: uid=pamidala,ou=users,dc=example,dc=com +objectClass: inetOrgPerson +uid: pamidala +cn: Padme Amidala +sn: Pamidala +mail: pamidala@example.com +userPassword: pamidala + +dn: uid=cdooku,ou=users,dc=example,dc=com +objectClass: inetOrgPerson +uid: cdooku +cn: Comte Dooku +sn: Cdooku +mail: cdooku@example.com +userPassword: cdooku + +dn: uid=kren,ou=users,dc=example,dc=com +objectClass: inetOrgPerson +uid: kren +cn: Kylo Ren +sn: Kren +mail: kren@example.com +userPassword: kren + +dn: uid=dmaul,ou=users,dc=example,dc=com +objectClass: inetOrgPerson +uid: dmaul +cn: Dark Maul +sn: Dmaul +mail: dmaul@example.com +userPassword: dmaul + +dn: uid=askywalker,ou=users,dc=example,dc=com +objectClass: inetOrgPerson +uid: askywalker +cn: Anakin Skywalker +sn: Askywalker +mail: askywalker@example.com +userPassword: askywalker + +dn: uid=jbinks,ou=users,dc=example,dc=com +objectClass: inetOrgPerson +uid: jbinks +cn: Jar Jar Binks +sn: Jbinks +mail: jbinks@example.com +userPassword: jbinks + +dn: uid=bfett,ou=users,dc=example,dc=com +objectClass: inetOrgPerson +uid: bfett +cn: Boba Fett +sn: Bfett +mail: bfett@example.com +userPassword: bfett + +dn: uid=jfett,ou=users,dc=example,dc=com +objectClass: inetOrgPerson +uid: jfett +cn: Jango Ffett +sn: Jfett +mail: jfett@example.com +userPassword: jfett + +dn: uid=lskywalker,ou=users,dc=example,dc=com +objectClass: inetOrgPerson +uid: lskywalker +cn: Luke Skywalker +sn: Lskywalker +mail: lskywalker@example.com +userPassword: lskywalker + +dn: uid=myoda,ou=users,dc=example,dc=com +objectClass: inetOrgPerson +uid: myoda +cn: Master Yoda +sn: Myoda +mail: myoda@example.com +userPassword: myoda + +dn: uid=hsolo,ou=users,dc=example,dc=com +objectClass: inetOrgPerson +uid: hsolo +cn: Han Solo +sn: Hsolo +mail: hsolo@example.com +userPassword: hsolo + +dn: uid=r2d2,ou=users,dc=example,dc=com +objectClass: inetOrgPerson +uid: r2d2 +cn: R2D2 +sn: R2D2 +mail: r2d2@example.com +userPassword: r2d2 + +dn: uid=c3po,ou=users,dc=example,dc=com +objectClass: inetOrgPerson +uid: c3po +cn: C3PO +sn: C3po +mail: c3po@example.com +userPassword: c3po + diff --git a/packages/tom-server/src/search-engine-api/__testData__/ldap/ldif/config-20230322180123.ldif b/packages/tom-server/src/search-engine-api/__testData__/ldap/ldif/config-20230322180123.ldif new file mode 100644 index 00000000..5c566113 --- /dev/null +++ b/packages/tom-server/src/search-engine-api/__testData__/ldap/ldif/config-20230322180123.ldif @@ -0,0 +1,260 @@ +dn: cn=config +objectClass: olcGlobal +cn: config +olcPidFile: /usr/local/openldap/var/run/slapd.pid + +dn: cn=schema,cn=config +objectClass: olcSchemaConfig +cn: schema +structuralObjectClass: olcSchemaConfig +entryUUID: 713c4cc8-df8a-103c-9770-230a0a7123cd +creatorsName: cn=config +createTimestamp: 20221013213337Z +entryCSN: 20221013213337.589745Z#000000#000#000000 +modifiersName: cn=config +modifyTimestamp: 20221013213337Z + +dn: cn={0}core,cn=schema,cn=config +objectClass: olcSchemaConfig +cn: {0}core +olcAttributeTypes: {0}( 2.5.4.2 NAME 'knowledgeInformation' DESC 'RFC2256: knowledge information' EQUALITY caseIgnoreMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{32768} ) +olcAttributeTypes: {1}( 2.5.4.4 NAME ( 'sn' 'surname' ) DESC 'RFC2256: last (family) name(s) for which the entity is known by' SUP name ) +olcAttributeTypes: {2}( 2.5.4.5 NAME 'serialNumber' DESC 'RFC2256: serial number of the entity' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.44{64} ) +olcAttributeTypes: {3}( 2.5.4.6 NAME ( 'c' 'countryName' ) DESC 'RFC4519: two-letter ISO-3166 country code' SUP name SYNTAX 1.3.6.1.4.1.1466.115.121.1.11 SINGLE-VALUE ) +olcAttributeTypes: {4}( 2.5.4.7 NAME ( 'l' 'localityName' ) DESC 'RFC2256: locality which this object resides in' SUP name ) +olcAttributeTypes: {5}( 2.5.4.8 NAME ( 'st' 'stateOrProvinceName' ) DESC 'RFC2256: state or province which this object resides in' SUP name ) +olcAttributeTypes: {6}( 2.5.4.9 NAME ( 'street' 'streetAddress' ) DESC 'RFC2256: street address of this object' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{128} ) +olcAttributeTypes: {7}( 2.5.4.10 NAME ( 'o' 'organizationName' ) DESC 'RFC2256: organization this object belongs to' SUP name ) +olcAttributeTypes: {8}( 2.5.4.11 NAME ( 'ou' 'organizationalUnitName' ) DESC 'RFC2256: organizational unit this object belongs to' SUP name ) +olcAttributeTypes: {9}( 2.5.4.12 NAME 'title' DESC 'RFC2256: title associated with the entity' SUP name ) +olcAttributeTypes: {10}( 2.5.4.14 NAME 'searchGuide' DESC 'RFC2256: search guide, deprecated by enhancedSearchGuide' SYNTAX 1.3.6.1.4.1.1466.115.121.1.25 ) +olcAttributeTypes: {11}( 2.5.4.15 NAME 'businessCategory' DESC 'RFC2256: business category' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{128} ) +olcAttributeTypes: {12}( 2.5.4.16 NAME 'postalAddress' DESC 'RFC2256: postal address' EQUALITY caseIgnoreListMatch SUBSTR caseIgnoreListSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.41 ) +olcAttributeTypes: {13}( 2.5.4.17 NAME 'postalCode' DESC 'RFC2256: postal code' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{40} ) +olcAttributeTypes: {14}( 2.5.4.18 NAME 'postOfficeBox' DESC 'RFC2256: Post Office Box' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{40} ) +olcAttributeTypes: {15}( 2.5.4.19 NAME 'physicalDeliveryOfficeName' DESC 'RFC2256: Physical Delivery Office Name' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{128} ) +olcAttributeTypes: {16}( 2.5.4.20 NAME 'telephoneNumber' DESC 'RFC2256: Telephone Number' EQUALITY telephoneNumberMatch SUBSTR telephoneNumberSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.50{32} ) +olcAttributeTypes: {17}( 2.5.4.21 NAME 'telexNumber' DESC 'RFC2256: Telex Number' SYNTAX 1.3.6.1.4.1.1466.115.121.1.52 ) +olcAttributeTypes: {18}( 2.5.4.22 NAME 'teletexTerminalIdentifier' DESC 'RFC2256: Teletex Terminal Identifier' SYNTAX 1.3.6.1.4.1.1466.115.121.1.51 ) +olcAttributeTypes: {19}( 2.5.4.23 NAME ( 'facsimileTelephoneNumber' 'fax' ) DESC 'RFC2256: Facsimile (Fax) Telephone Number' SYNTAX 1.3.6.1.4.1.1466.115.121.1.22 ) +olcAttributeTypes: {20}( 2.5.4.24 NAME 'x121Address' DESC 'RFC2256: X.121 Address' EQUALITY numericStringMatch SUBSTR numericStringSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.36{15} ) +olcAttributeTypes: {21}( 2.5.4.25 NAME 'internationaliSDNNumber' DESC 'RFC2256: international ISDN number' EQUALITY numericStringMatch SUBSTR numericStringSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.36{16} ) +olcAttributeTypes: {22}( 2.5.4.26 NAME 'registeredAddress' DESC 'RFC2256: registered postal address' SUP postalAddress SYNTAX 1.3.6.1.4.1.1466.115.121.1.41 ) +olcAttributeTypes: {23}( 2.5.4.27 NAME 'destinationIndicator' DESC 'RFC2256: destination indicator' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.44{128} ) +olcAttributeTypes: {24}( 2.5.4.28 NAME 'preferredDeliveryMethod' DESC 'RFC2256: preferred delivery method' SYNTAX 1.3.6.1.4.1.1466.115.121.1.14 SINGLE-VALUE ) +olcAttributeTypes: {25}( 2.5.4.29 NAME 'presentationAddress' DESC 'RFC2256: presentation address' EQUALITY presentationAddressMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.43 SINGLE-VALUE ) +olcAttributeTypes: {26}( 2.5.4.30 NAME 'supportedApplicationContext' DESC 'RFC2256: supported application context' EQUALITY objectIdentifierMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 ) +olcAttributeTypes: {27}( 2.5.4.31 NAME 'member' DESC 'RFC2256: member of a group' SUP distinguishedName ) +olcAttributeTypes: {28}( 2.5.4.32 NAME 'owner' DESC 'RFC2256: owner (of the object)' SUP distinguishedName ) +olcAttributeTypes: {29}( 2.5.4.33 NAME 'roleOccupant' DESC 'RFC2256: occupant of role' SUP distinguishedName ) +olcAttributeTypes: {30}( 2.5.4.36 NAME 'userCertificate' DESC 'RFC2256: X.509 user certificate, use ;binary' EQUALITY certificateExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.8 ) +olcAttributeTypes: {31}( 2.5.4.37 NAME 'cACertificate' DESC 'RFC2256: X.509 CA certificate, use ;binary' EQUALITY certificateExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.8 ) +olcAttributeTypes: {32}( 2.5.4.38 NAME 'authorityRevocationList' DESC 'RFC2256: X.509 authority revocation list, use ;binary' SYNTAX 1.3.6.1.4.1.1466.115.121.1.9 ) +olcAttributeTypes: {33}( 2.5.4.39 NAME 'certificateRevocationList' DESC 'RFC2256: X.509 certificate revocation list, use ;binary' SYNTAX 1.3.6.1.4.1.1466.115.121.1.9 ) +olcAttributeTypes: {34}( 2.5.4.40 NAME 'crossCertificatePair' DESC 'RFC2256: X.509 cross certificate pair, use ;binary' SYNTAX 1.3.6.1.4.1.1466.115.121.1.10 ) +olcAttributeTypes: {35}( 2.5.4.42 NAME ( 'givenName' 'gn' ) DESC 'RFC2256: first name(s) for which the entity is known by' SUP name ) +olcAttributeTypes: {36}( 2.5.4.43 NAME 'initials' DESC 'RFC2256: initials of some or all of names, but not the surname(s).' SUP name ) +olcAttributeTypes: {37}( 2.5.4.44 NAME 'generationQualifier' DESC 'RFC2256: name qualifier indicating a generation' SUP name ) +olcAttributeTypes: {38}( 2.5.4.45 NAME 'x500UniqueIdentifier' DESC 'RFC2256: X.500 unique identifier' EQUALITY bitStringMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.6 ) +olcAttributeTypes: {39}( 2.5.4.46 NAME 'dnQualifier' DESC 'RFC2256: DN qualifier' EQUALITY caseIgnoreMatch ORDERING caseIgnoreOrderingMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.44 ) +olcAttributeTypes: {40}( 2.5.4.47 NAME 'enhancedSearchGuide' DESC 'RFC2256: enhanced search guide' SYNTAX 1.3.6.1.4.1.1466.115.121.1.21 ) +olcAttributeTypes: {41}( 2.5.4.48 NAME 'protocolInformation' DESC 'RFC2256: protocol information' EQUALITY protocolInformationMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.42 ) +olcAttributeTypes: {42}( 2.5.4.50 NAME 'uniqueMember' DESC 'RFC2256: unique member of a group' EQUALITY uniqueMemberMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.34 ) +olcAttributeTypes: {43}( 2.5.4.51 NAME 'houseIdentifier' DESC 'RFC2256: house identifier' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{32768} ) +olcAttributeTypes: {44}( 2.5.4.52 NAME 'supportedAlgorithms' DESC 'RFC2256: supported algorithms' SYNTAX 1.3.6.1.4.1.1466.115.121.1.49 ) +olcAttributeTypes: {45}( 2.5.4.53 NAME 'deltaRevocationList' DESC 'RFC2256: delta revocation list; use ;binary' SYNTAX 1.3.6.1.4.1.1466.115.121.1.9 ) +olcAttributeTypes: {46}( 2.5.4.54 NAME 'dmdName' DESC 'RFC2256: name of DMD' SUP name ) +olcAttributeTypes: {47}( 2.5.4.65 NAME 'pseudonym' DESC 'X.520(4th): pseudonym for the object' SUP name ) +olcAttributeTypes: {48}( 0.9.2342.19200300.100.1.3 NAME ( 'mail' 'rfc822Mailbox' ) DESC 'RFC1274: RFC822 Mailbox' EQUALITY caseIgnoreIA5Match SUBSTR caseIgnoreIA5SubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{256} ) +olcAttributeTypes: {49}( 0.9.2342.19200300.100.1.25 NAME ( 'dc' 'domainComponent' ) DESC 'RFC1274/2247: domain component' EQUALITY caseIgnoreIA5Match SUBSTR caseIgnoreIA5SubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 SINGLE-VALUE ) +olcAttributeTypes: {50}( 0.9.2342.19200300.100.1.37 NAME 'associatedDomain' DESC 'RFC1274: domain associated with object' EQUALITY caseIgnoreIA5Match SUBSTR caseIgnoreIA5SubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 ) +olcAttributeTypes: {51}( 1.2.840.113549.1.9.1 NAME ( 'email' 'emailAddress' 'pkcs9email' ) DESC 'RFC3280: legacy attribute for email addresses in DNs' EQUALITY caseIgnoreIA5Match SUBSTR caseIgnoreIA5SubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{128} ) +olcObjectClasses: {0}( 2.5.6.2 NAME 'country' DESC 'RFC2256: a country' SUP top STRUCTURAL MUST c MAY ( searchGuide $ description ) ) +olcObjectClasses: {1}( 2.5.6.3 NAME 'locality' DESC 'RFC2256: a locality' SUP top STRUCTURAL MAY ( street $ seeAlso $ searchGuide $ st $ l $ description ) ) +olcObjectClasses: {2}( 2.5.6.4 NAME 'organization' DESC 'RFC2256: an organization' SUP top STRUCTURAL MUST o MAY ( userPassword $ searchGuide $ seeAlso $ businessCategory $ x121Address $ registeredAddress $ destinationIndicator $ preferredDeliveryMethod $ telexNumber $ teletexTerminalIdentifier $ telephoneNumber $ internationaliSDNNumber $ facsimileTelephoneNumber $ street $ postOfficeBox $ postalCode $ postalAddress $ physicalDeliveryOfficeName $ st $ l $ description ) ) +olcObjectClasses: {3}( 2.5.6.5 NAME 'organizationalUnit' DESC 'RFC2256: an organizational unit' SUP top STRUCTURAL MUST ou MAY ( userPassword $ searchGuide $ seeAlso $ businessCategory $ x121Address $ registeredAddress $ destinationIndicator $ preferredDeliveryMethod $ telexNumber $ teletexTerminalIdentifier $ telephoneNumber $ internationaliSDNNumber $ facsimileTelephoneNumber $ street $ postOfficeBox $ postalCode $ postalAddress $ physicalDeliveryOfficeName $ st $ l $ description ) ) +olcObjectClasses: {4}( 2.5.6.6 NAME 'person' DESC 'RFC2256: a person' SUP top STRUCTURAL MUST ( sn $ cn ) MAY ( userPassword $ telephoneNumber $ seeAlso $ description ) ) +olcObjectClasses: {5}( 2.5.6.7 NAME 'organizationalPerson' DESC 'RFC2256: an organizational person' SUP person STRUCTURAL MAY ( title $ x121Address $ registeredAddress $ destinationIndicator $ preferredDeliveryMethod $ telexNumber $ teletexTerminalIdentifier $ telephoneNumber $ internationaliSDNNumber $ facsimileTelephoneNumber $ street $ postOfficeBox $ postalCode $ postalAddress $ physicalDeliveryOfficeName $ ou $ st $ l ) ) +olcObjectClasses: {6}( 2.5.6.8 NAME 'organizationalRole' DESC 'RFC2256: an organizational role' SUP top STRUCTURAL MUST cn MAY ( x121Address $ registeredAddress $ destinationIndicator $ preferredDeliveryMethod $ telexNumber $ teletexTerminalIdentifier $ telephoneNumber $ internationaliSDNNumber $ facsimileTelephoneNumber $ seeAlso $ roleOccupant $ preferredDeliveryMethod $ street $ postOfficeBox $ postalCode $ postalAddress $ physicalDeliveryOfficeName $ ou $ st $ l $ description ) ) +olcObjectClasses: {7}( 2.5.6.9 NAME 'groupOfNames' DESC 'RFC2256: a group of names (DNs)' SUP top STRUCTURAL MUST ( member $ cn ) MAY ( businessCategory $ seeAlso $ owner $ ou $ o $ description ) ) +olcObjectClasses: {8}( 2.5.6.10 NAME 'residentialPerson' DESC 'RFC2256: an residential person' SUP person STRUCTURAL MUST l MAY ( businessCategory $ x121Address $ registeredAddress $ destinationIndicator $ preferredDeliveryMethod $ telexNumber $ teletexTerminalIdentifier $ telephoneNumber $ internationaliSDNNumber $ facsimileTelephoneNumber $ preferredDeliveryMethod $ street $ postOfficeBox $ postalCode $ postalAddress $ physicalDeliveryOfficeName $ st $ l ) ) +olcObjectClasses: {9}( 2.5.6.11 NAME 'applicationProcess' DESC 'RFC2256: an application process' SUP top STRUCTURAL MUST cn MAY ( seeAlso $ ou $ l $ description ) ) +olcObjectClasses: {10}( 2.5.6.12 NAME 'applicationEntity' DESC 'RFC2256: an application entity' SUP top STRUCTURAL MUST ( presentationAddress $ cn ) MAY ( supportedApplicationContext $ seeAlso $ ou $ o $ l $ description ) ) +olcObjectClasses: {11}( 2.5.6.13 NAME 'dSA' DESC 'RFC2256: a directory system agent (a server)' SUP applicationEntity STRUCTURAL MAY knowledgeInformation ) +olcObjectClasses: {12}( 2.5.6.14 NAME 'device' DESC 'RFC2256: a device' SUP top STRUCTURAL MUST cn MAY ( serialNumber $ seeAlso $ owner $ ou $ o $ l $ description ) ) +olcObjectClasses: {13}( 2.5.6.15 NAME 'strongAuthenticationUser' DESC 'RFC2256: a strong authentication user' SUP top AUXILIARY MUST userCertificate ) +olcObjectClasses: {14}( 2.5.6.16 NAME 'certificationAuthority' DESC 'RFC2256: a certificate authority' SUP top AUXILIARY MUST ( authorityRevocationList $ certificateRevocationList $ cACertificate ) MAY crossCertificatePair ) +olcObjectClasses: {15}( 2.5.6.17 NAME 'groupOfUniqueNames' DESC 'RFC2256: a group of unique names (DN and Unique Identifier)' SUP top STRUCTURAL MUST ( uniqueMember $ cn ) MAY ( businessCategory $ seeAlso $ owner $ ou $ o $ description ) ) +olcObjectClasses: {16}( 2.5.6.18 NAME 'userSecurityInformation' DESC 'RFC2256: a user security information' SUP top AUXILIARY MAY ( supportedAlgorithms ) ) +olcObjectClasses: {17}( 2.5.6.16.2 NAME 'certificationAuthority-V2' SUP certificationAuthority AUXILIARY MAY ( deltaRevocationList ) ) +olcObjectClasses: {18}( 2.5.6.19 NAME 'cRLDistributionPoint' SUP top STRUCTURAL MUST ( cn ) MAY ( certificateRevocationList $ authorityRevocationList $ deltaRevocationList ) ) +olcObjectClasses: {19}( 2.5.6.20 NAME 'dmd' SUP top STRUCTURAL MUST ( dmdName ) MAY ( userPassword $ searchGuide $ seeAlso $ businessCategory $ x121Address $ registeredAddress $ destinationIndicator $ preferredDeliveryMethod $ telexNumber $ teletexTerminalIdentifier $ telephoneNumber $ internationaliSDNNumber $ facsimileTelephoneNumber $ street $ postOfficeBox $ postalCode $ postalAddress $ physicalDeliveryOfficeName $ st $ l $ description ) ) +olcObjectClasses: {20}( 2.5.6.21 NAME 'pkiUser' DESC 'RFC2587: a PKI user' SUP top AUXILIARY MAY userCertificate ) +olcObjectClasses: {21}( 2.5.6.22 NAME 'pkiCA' DESC 'RFC2587: PKI certificate authority' SUP top AUXILIARY MAY ( authorityRevocationList $ certificateRevocationList $ cACertificate $ crossCertificatePair ) ) +olcObjectClasses: {22}( 2.5.6.23 NAME 'deltaCRL' DESC 'RFC4523: X.509 delta CRL' SUP top AUXILIARY MAY deltaRevocationList ) +olcObjectClasses: {23}( 1.3.6.1.4.1.250.3.15 NAME 'labeledURIObject' DESC 'RFC2079: object that contains the URI attribute type' MAY ( labeledURI ) SUP top AUXILIARY ) +olcObjectClasses: {24}( 0.9.2342.19200300.100.4.19 NAME 'simpleSecurityObject' DESC 'RFC1274: simple security object' SUP top AUXILIARY MUST userPassword ) +olcObjectClasses: {25}( 1.3.6.1.4.1.1466.344 NAME 'dcObject' DESC 'RFC2247: domain component object' SUP top AUXILIARY MUST dc ) +olcObjectClasses: {26}( 1.3.6.1.1.3.1 NAME 'uidObject' DESC 'RFC2377: uid object' SUP top AUXILIARY MUST uid ) +structuralObjectClass: olcSchemaConfig +entryUUID: 713c5a24-df8a-103c-9771-230a0a7123cd +creatorsName: cn=config +createTimestamp: 20221013213337Z +entryCSN: 20221013213337.590086Z#000000#000#000000 +modifiersName: cn=config +modifyTimestamp: 20221013213337Z + +dn: cn={1}cosine,cn=schema,cn=config +objectClass: olcSchemaConfig +cn: {1}cosine +olcAttributeTypes: {0}( 0.9.2342.19200300.100.1.2 NAME 'textEncodedORAddress' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} ) +olcAttributeTypes: {1}( 0.9.2342.19200300.100.1.4 NAME 'info' DESC 'RFC1274: general information' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{2048} ) +olcAttributeTypes: {2}( 0.9.2342.19200300.100.1.5 NAME ( 'drink' 'favouriteDrink' ) DESC 'RFC1274: favorite drink' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} ) +olcAttributeTypes: {3}( 0.9.2342.19200300.100.1.6 NAME 'roomNumber' DESC 'RFC1274: room number' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} ) +olcAttributeTypes: {4}( 0.9.2342.19200300.100.1.7 NAME 'photo' DESC 'RFC1274: photo (G3 fax)' SYNTAX 1.3.6.1.4.1.1466.115.121.1.23{25000} ) +olcAttributeTypes: {5}( 0.9.2342.19200300.100.1.8 NAME 'userClass' DESC 'RFC1274: category of user' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} ) +olcAttributeTypes: {6}( 0.9.2342.19200300.100.1.9 NAME 'host' DESC 'RFC1274: host computer' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} ) +olcAttributeTypes: {7}( 0.9.2342.19200300.100.1.10 NAME 'manager' DESC 'RFC1274: DN of manager' EQUALITY distinguishedNameMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 ) +olcAttributeTypes: {8}( 0.9.2342.19200300.100.1.11 NAME 'documentIdentifier' DESC 'RFC1274: unique identifier of document' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} ) +olcAttributeTypes: {9}( 0.9.2342.19200300.100.1.12 NAME 'documentTitle' DESC 'RFC1274: title of document' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} ) +olcAttributeTypes: {10}( 0.9.2342.19200300.100.1.13 NAME 'documentVersion' DESC 'RFC1274: version of document' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} ) +olcAttributeTypes: {11}( 0.9.2342.19200300.100.1.14 NAME 'documentAuthor' DESC 'RFC1274: DN of author of document' EQUALITY distinguishedNameMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 ) +olcAttributeTypes: {12}( 0.9.2342.19200300.100.1.15 NAME 'documentLocation' DESC 'RFC1274: location of document original' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} ) +olcAttributeTypes: {13}( 0.9.2342.19200300.100.1.20 NAME ( 'homePhone' 'homeTelephoneNumber' ) DESC 'RFC1274: home telephone number' EQUALITY telephoneNumberMatch SUBSTR telephoneNumberSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.50 ) +olcAttributeTypes: {14}( 0.9.2342.19200300.100.1.21 NAME 'secretary' DESC 'RFC1274: DN of secretary' EQUALITY distinguishedNameMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 ) +olcAttributeTypes: {15}( 0.9.2342.19200300.100.1.22 NAME 'otherMailbox' SYNTAX 1.3.6.1.4.1.1466.115.121.1.39 ) +olcAttributeTypes: {16}( 0.9.2342.19200300.100.1.26 NAME 'aRecord' EQUALITY caseIgnoreIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 ) +olcAttributeTypes: {17}( 0.9.2342.19200300.100.1.27 NAME 'mDRecord' EQUALITY caseIgnoreIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 ) +olcAttributeTypes: {18}( 0.9.2342.19200300.100.1.28 NAME 'mXRecord' EQUALITY caseIgnoreIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 ) +olcAttributeTypes: {19}( 0.9.2342.19200300.100.1.29 NAME 'nSRecord' EQUALITY caseIgnoreIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 ) +olcAttributeTypes: {20}( 0.9.2342.19200300.100.1.30 NAME 'sOARecord' EQUALITY caseIgnoreIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 ) +olcAttributeTypes: {21}( 0.9.2342.19200300.100.1.31 NAME 'cNAMERecord' EQUALITY caseIgnoreIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 ) +olcAttributeTypes: {22}( 0.9.2342.19200300.100.1.38 NAME 'associatedName' DESC 'RFC1274: DN of entry associated with domain' EQUALITY distinguishedNameMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 ) +olcAttributeTypes: {23}( 0.9.2342.19200300.100.1.39 NAME 'homePostalAddress' DESC 'RFC1274: home postal address' EQUALITY caseIgnoreListMatch SUBSTR caseIgnoreListSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.41 ) +olcAttributeTypes: {24}( 0.9.2342.19200300.100.1.40 NAME 'personalTitle' DESC 'RFC1274: personal title' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} ) +olcAttributeTypes: {25}( 0.9.2342.19200300.100.1.41 NAME ( 'mobile' 'mobileTelephoneNumber' ) DESC 'RFC1274: mobile telephone number' EQUALITY telephoneNumberMatch SUBSTR telephoneNumberSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.50 ) +olcAttributeTypes: {26}( 0.9.2342.19200300.100.1.42 NAME ( 'pager' 'pagerTelephoneNumber' ) DESC 'RFC1274: pager telephone number' EQUALITY telephoneNumberMatch SUBSTR telephoneNumberSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.50 ) +olcAttributeTypes: {27}( 0.9.2342.19200300.100.1.43 NAME ( 'co' 'friendlyCountryName' ) DESC 'RFC1274: friendly country name' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 ) +olcAttributeTypes: {28}( 0.9.2342.19200300.100.1.44 NAME 'uniqueIdentifier' DESC 'RFC1274: unique identifer' EQUALITY caseIgnoreMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} ) +olcAttributeTypes: {29}( 0.9.2342.19200300.100.1.45 NAME 'organizationalStatus' DESC 'RFC1274: organizational status' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} ) +olcAttributeTypes: {30}( 0.9.2342.19200300.100.1.46 NAME 'janetMailbox' DESC 'RFC1274: Janet mailbox' EQUALITY caseIgnoreIA5Match SUBSTR caseIgnoreIA5SubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{256} ) +olcAttributeTypes: {31}( 0.9.2342.19200300.100.1.47 NAME 'mailPreferenceOption' DESC 'RFC1274: mail preference option' SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 ) +olcAttributeTypes: {32}( 0.9.2342.19200300.100.1.48 NAME 'buildingName' DESC 'RFC1274: name of building' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} ) +olcAttributeTypes: {33}( 0.9.2342.19200300.100.1.49 NAME 'dSAQuality' DESC 'RFC1274: DSA Quality' SYNTAX 1.3.6.1.4.1.1466.115.121.1.19 SINGLE-VALUE ) +olcAttributeTypes: {34}( 0.9.2342.19200300.100.1.50 NAME 'singleLevelQuality' DESC 'RFC1274: Single Level Quality' SYNTAX 1.3.6.1.4.1.1466.115.121.1.13 SINGLE-VALUE ) +olcAttributeTypes: {35}( 0.9.2342.19200300.100.1.51 NAME 'subtreeMinimumQuality' DESC 'RFC1274: Subtree Minimum Quality' SYNTAX 1.3.6.1.4.1.1466.115.121.1.13 SINGLE-VALUE ) +olcAttributeTypes: {36}( 0.9.2342.19200300.100.1.52 NAME 'subtreeMaximumQuality' DESC 'RFC1274: Subtree Maximum Quality' SYNTAX 1.3.6.1.4.1.1466.115.121.1.13 SINGLE-VALUE ) +olcAttributeTypes: {37}( 0.9.2342.19200300.100.1.53 NAME 'personalSignature' DESC 'RFC1274: Personal Signature (G3 fax)' SYNTAX 1.3.6.1.4.1.1466.115.121.1.23 ) +olcAttributeTypes: {38}( 0.9.2342.19200300.100.1.54 NAME 'dITRedirect' DESC 'RFC1274: DIT Redirect' EQUALITY distinguishedNameMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 ) +olcAttributeTypes: {39}( 0.9.2342.19200300.100.1.55 NAME 'audio' DESC 'RFC1274: audio (u-law)' SYNTAX 1.3.6.1.4.1.1466.115.121.1.4{25000} ) +olcAttributeTypes: {40}( 0.9.2342.19200300.100.1.56 NAME 'documentPublisher' DESC 'RFC1274: publisher of document' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 ) +olcObjectClasses: {0}( 0.9.2342.19200300.100.4.4 NAME ( 'pilotPerson' 'newPilotPerson' ) SUP person STRUCTURAL MAY ( userid $ textEncodedORAddress $ rfc822Mailbox $ favouriteDrink $ roomNumber $ userClass $ homeTelephoneNumber $ homePostalAddress $ secretary $ personalTitle $ preferredDeliveryMethod $ businessCategory $ janetMailbox $ otherMailbox $ mobileTelephoneNumber $ pagerTelephoneNumber $ organizationalStatus $ mailPreferenceOption $ personalSignature ) ) +olcObjectClasses: {1}( 0.9.2342.19200300.100.4.5 NAME 'account' SUP top STRUCTURAL MUST userid MAY ( description $ seeAlso $ localityName $ organizationName $ organizationalUnitName $ host ) ) +olcObjectClasses: {2}( 0.9.2342.19200300.100.4.6 NAME 'document' SUP top STRUCTURAL MUST documentIdentifier MAY ( commonName $ description $ seeAlso $ localityName $ organizationName $ organizationalUnitName $ documentTitle $ documentVersion $ documentAuthor $ documentLocation $ documentPublisher ) ) +olcObjectClasses: {3}( 0.9.2342.19200300.100.4.7 NAME 'room' SUP top STRUCTURAL MUST commonName MAY ( roomNumber $ description $ seeAlso $ telephoneNumber ) ) +olcObjectClasses: {4}( 0.9.2342.19200300.100.4.9 NAME 'documentSeries' SUP top STRUCTURAL MUST commonName MAY ( description $ seeAlso $ telephonenumber $ localityName $ organizationName $ organizationalUnitName ) ) +olcObjectClasses: {5}( 0.9.2342.19200300.100.4.13 NAME 'domain' SUP top STRUCTURAL MUST domainComponent MAY ( associatedName $ organizationName $ description $ businessCategory $ seeAlso $ searchGuide $ userPassword $ localityName $ stateOrProvinceName $ streetAddress $ physicalDeliveryOfficeName $ postalAddress $ postalCode $ postOfficeBox $ streetAddress $ facsimileTelephoneNumber $ internationalISDNNumber $ telephoneNumber $ teletexTerminalIdentifier $ telexNumber $ preferredDeliveryMethod $ destinationIndicator $ registeredAddress $ x121Address ) ) +olcObjectClasses: {6}( 0.9.2342.19200300.100.4.14 NAME 'RFC822localPart' SUP domain STRUCTURAL MAY ( commonName $ surname $ description $ seeAlso $ telephoneNumber $ physicalDeliveryOfficeName $ postalAddress $ postalCode $ postOfficeBox $ streetAddress $ facsimileTelephoneNumber $ internationalISDNNumber $ telephoneNumber $ teletexTerminalIdentifier $ telexNumber $ preferredDeliveryMethod $ destinationIndicator $ registeredAddress $ x121Address ) ) +olcObjectClasses: {7}( 0.9.2342.19200300.100.4.15 NAME 'dNSDomain' SUP domain STRUCTURAL MAY ( ARecord $ MDRecord $ MXRecord $ NSRecord $ SOARecord $ CNAMERecord ) ) +olcObjectClasses: {8}( 0.9.2342.19200300.100.4.17 NAME 'domainRelatedObject' DESC 'RFC1274: an object related to an domain' SUP top AUXILIARY MUST associatedDomain ) +olcObjectClasses: {9}( 0.9.2342.19200300.100.4.18 NAME 'friendlyCountry' SUP country STRUCTURAL MUST friendlyCountryName ) +olcObjectClasses: {10}( 0.9.2342.19200300.100.4.20 NAME 'pilotOrganization' SUP ( organization $ organizationalUnit ) STRUCTURAL MAY buildingName ) +olcObjectClasses: {11}( 0.9.2342.19200300.100.4.21 NAME 'pilotDSA' SUP dsa STRUCTURAL MAY dSAQuality ) +olcObjectClasses: {12}( 0.9.2342.19200300.100.4.22 NAME 'qualityLabelledData' SUP top AUXILIARY MUST dsaQuality MAY ( subtreeMinimumQuality $ subtreeMaximumQuality ) ) +structuralObjectClass: olcSchemaConfig +entryUUID: 713ca97a-df8a-103c-9772-230a0a7123cd +creatorsName: cn=config +createTimestamp: 20221013213337Z +entryCSN: 20221013213337.592117Z#000000#000#000000 +modifiersName: cn=config +modifyTimestamp: 20221013213337Z + +dn: cn={2}inetorgperson,cn=schema,cn=config +objectClass: olcSchemaConfig +cn: {2}inetorgperson +olcAttributeTypes: {0}( 2.16.840.1.113730.3.1.1 NAME 'carLicense' DESC 'RFC2798: vehicle license or registration plate' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 ) +olcAttributeTypes: {1}( 2.16.840.1.113730.3.1.2 NAME 'departmentNumber' DESC 'RFC2798: identifies a department within an organization' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 ) +olcAttributeTypes: {2}( 2.16.840.1.113730.3.1.241 NAME 'displayName' DESC 'RFC2798: preferred name to be used when displaying entries' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE ) +olcAttributeTypes: {3}( 2.16.840.1.113730.3.1.3 NAME 'employeeNumber' DESC 'RFC2798: numerically identifies an employee within an organization' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE ) +olcAttributeTypes: {4}( 2.16.840.1.113730.3.1.4 NAME 'employeeType' DESC 'RFC2798: type of employment for a person' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 ) +olcAttributeTypes: {5}( 0.9.2342.19200300.100.1.60 NAME 'jpegPhoto' DESC 'RFC2798: a JPEG image' SYNTAX 1.3.6.1.4.1.1466.115.121.1.28 ) +olcAttributeTypes: {6}( 2.16.840.1.113730.3.1.39 NAME 'preferredLanguage' DESC 'RFC2798: preferred written or spoken language for a person' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE ) +olcAttributeTypes: {7}( 2.16.840.1.113730.3.1.40 NAME 'userSMIMECertificate' DESC 'RFC2798: PKCS#7 SignedData used to support S/MIME' SYNTAX 1.3.6.1.4.1.1466.115.121.1.5 ) +olcAttributeTypes: {8}( 2.16.840.1.113730.3.1.216 NAME 'userPKCS12' DESC 'RFC2798: personal identity information, a PKCS #12 PFX' SYNTAX 1.3.6.1.4.1.1466.115.121.1.5 ) +olcObjectClasses: {0}( 2.16.840.1.113730.3.2.2 NAME 'inetOrgPerson' DESC 'RFC2798: Internet Organizational Person' SUP organizationalPerson STRUCTURAL MAY ( audio $ businessCategory $ carLicense $ departmentNumber $ displayName $ employeeNumber $ employeeType $ givenName $ homePhone $ homePostalAddress $ initials $ jpegPhoto $ labeledURI $ mail $ manager $ mobile $ o $ pager $ photo $ roomNumber $ secretary $ uid $ userCertificate $ x500uniqueIdentifier $ preferredLanguage $ userSMIMECertificate $ userPKCS12 ) ) +structuralObjectClass: olcSchemaConfig +entryUUID: 713cc81a-df8a-103c-9773-230a0a7123cd +creatorsName: cn=config +createTimestamp: 20221013213337Z +entryCSN: 20221013213337.592900Z#000000#000#000000 +modifiersName: cn=config +modifyTimestamp: 20221013213337Z + +dn: cn={3}nis,cn=schema,cn=config +objectClass: olcSchemaConfig +cn: {3}nis +olcAttributeTypes: {0}( 1.3.6.1.1.1.1.2 NAME 'gecos' DESC 'The GECOS field; the common name' EQUALITY caseIgnoreIA5Match SUBSTR caseIgnoreIA5SubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 SINGLE-VALUE ) +olcAttributeTypes: {1}( 1.3.6.1.1.1.1.3 NAME 'homeDirectory' DESC 'The absolute path to the home directory' EQUALITY caseExactIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 SINGLE-VALUE ) +olcAttributeTypes: {2}( 1.3.6.1.1.1.1.4 NAME 'loginShell' DESC 'The path to the login shell' EQUALITY caseExactIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 SINGLE-VALUE ) +olcAttributeTypes: {3}( 1.3.6.1.1.1.1.5 NAME 'shadowLastChange' EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE ) +olcAttributeTypes: {4}( 1.3.6.1.1.1.1.6 NAME 'shadowMin' EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE ) +olcAttributeTypes: {5}( 1.3.6.1.1.1.1.7 NAME 'shadowMax' EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE ) +olcAttributeTypes: {6}( 1.3.6.1.1.1.1.8 NAME 'shadowWarning' EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE ) +olcAttributeTypes: {7}( 1.3.6.1.1.1.1.9 NAME 'shadowInactive' EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE ) +olcAttributeTypes: {8}( 1.3.6.1.1.1.1.10 NAME 'shadowExpire' EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE ) +olcAttributeTypes: {9}( 1.3.6.1.1.1.1.11 NAME 'shadowFlag' EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE ) +olcAttributeTypes: {10}( 1.3.6.1.1.1.1.12 NAME 'memberUid' EQUALITY caseExactIA5Match SUBSTR caseExactIA5SubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 ) +olcAttributeTypes: {11}( 1.3.6.1.1.1.1.13 NAME 'memberNisNetgroup' EQUALITY caseExactIA5Match SUBSTR caseExactIA5SubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 ) +olcAttributeTypes: {12}( 1.3.6.1.1.1.1.14 NAME 'nisNetgroupTriple' DESC 'Netgroup triple' SYNTAX 1.3.6.1.1.1.0.0 ) +olcAttributeTypes: {13}( 1.3.6.1.1.1.1.15 NAME 'ipServicePort' EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE ) +olcAttributeTypes: {14}( 1.3.6.1.1.1.1.16 NAME 'ipServiceProtocol' SUP name ) +olcAttributeTypes: {15}( 1.3.6.1.1.1.1.17 NAME 'ipProtocolNumber' EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE ) +olcAttributeTypes: {16}( 1.3.6.1.1.1.1.18 NAME 'oncRpcNumber' EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE ) +olcAttributeTypes: {17}( 1.3.6.1.1.1.1.19 NAME 'ipHostNumber' DESC 'IP address' EQUALITY caseIgnoreIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{128} ) +olcAttributeTypes: {18}( 1.3.6.1.1.1.1.20 NAME 'ipNetworkNumber' DESC 'IP network' EQUALITY caseIgnoreIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{128} SINGLE-VALUE ) +olcAttributeTypes: {19}( 1.3.6.1.1.1.1.21 NAME 'ipNetmaskNumber' DESC 'IP netmask' EQUALITY caseIgnoreIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{128} SINGLE-VALUE ) +olcAttributeTypes: {20}( 1.3.6.1.1.1.1.22 NAME 'macAddress' DESC 'MAC address' EQUALITY caseIgnoreIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{128} ) +olcAttributeTypes: {21}( 1.3.6.1.1.1.1.23 NAME 'bootParameter' DESC 'rpc.bootparamd parameter' SYNTAX 1.3.6.1.1.1.0.1 ) +olcAttributeTypes: {22}( 1.3.6.1.1.1.1.24 NAME 'bootFile' DESC 'Boot image name' EQUALITY caseExactIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 ) +olcAttributeTypes: {23}( 1.3.6.1.1.1.1.26 NAME 'nisMapName' SUP name ) +olcAttributeTypes: {24}( 1.3.6.1.1.1.1.27 NAME 'nisMapEntry' EQUALITY caseExactIA5Match SUBSTR caseExactIA5SubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{1024} SINGLE-VALUE ) +olcObjectClasses: {0}( 1.3.6.1.1.1.2.0 NAME 'posixAccount' DESC 'Abstraction of an account with POSIX attributes' SUP top AUXILIARY MUST ( cn $ uid $ uidNumber $ gidNumber $ homeDirectory ) MAY ( userPassword $ loginShell $ gecos $ description ) ) +olcObjectClasses: {1}( 1.3.6.1.1.1.2.1 NAME 'shadowAccount' DESC 'Additional attributes for shadow passwords' SUP top AUXILIARY MUST uid MAY ( userPassword $ shadowLastChange $ shadowMin $ shadowMax $ shadowWarning $ shadowInactive $ shadowExpire $ shadowFlag $ description ) ) +olcObjectClasses: {2}( 1.3.6.1.1.1.2.2 NAME 'posixGroup' DESC 'Abstraction of a group of accounts' SUP top STRUCTURAL MUST ( cn $ gidNumber ) MAY ( userPassword $ memberUid $ description ) ) +olcObjectClasses: {3}( 1.3.6.1.1.1.2.3 NAME 'ipService' DESC 'Abstraction an Internet Protocol service' SUP top STRUCTURAL MUST ( cn $ ipServicePort $ ipServiceProtocol ) MAY description ) +olcObjectClasses: {4}( 1.3.6.1.1.1.2.4 NAME 'ipProtocol' DESC 'Abstraction of an IP protocol' SUP top STRUCTURAL MUST ( cn $ ipProtocolNumber $ description ) MAY description ) +olcObjectClasses: {5}( 1.3.6.1.1.1.2.5 NAME 'oncRpc' DESC 'Abstraction of an ONC/RPC binding' SUP top STRUCTURAL MUST ( cn $ oncRpcNumber $ description ) MAY description ) +olcObjectClasses: {6}( 1.3.6.1.1.1.2.6 NAME 'ipHost' DESC 'Abstraction of a host, an IP device' SUP top AUXILIARY MUST ( cn $ ipHostNumber ) MAY ( l $ description $ manager ) ) +olcObjectClasses: {7}( 1.3.6.1.1.1.2.7 NAME 'ipNetwork' DESC 'Abstraction of an IP network' SUP top STRUCTURAL MUST ( cn $ ipNetworkNumber ) MAY ( ipNetmaskNumber $ l $ description $ manager ) ) +olcObjectClasses: {8}( 1.3.6.1.1.1.2.8 NAME 'nisNetgroup' DESC 'Abstraction of a netgroup' SUP top STRUCTURAL MUST cn MAY ( nisNetgroupTriple $ memberNisNetgroup $ description ) ) +olcObjectClasses: {9}( 1.3.6.1.1.1.2.9 NAME 'nisMap' DESC 'A generic abstraction of a NIS map' SUP top STRUCTURAL MUST nisMapName MAY description ) +olcObjectClasses: {10}( 1.3.6.1.1.1.2.10 NAME 'nisObject' DESC 'An entry in a NIS map' SUP top STRUCTURAL MUST ( cn $ nisMapEntry $ nisMapName ) MAY description ) +olcObjectClasses: {11}( 1.3.6.1.1.1.2.11 NAME 'ieee802Device' DESC 'A device with a MAC address' SUP top AUXILIARY MAY macAddress ) +olcObjectClasses: {12}( 1.3.6.1.1.1.2.12 NAME 'bootableDevice' DESC 'A device with boot parameters' SUP top AUXILIARY MAY ( bootFile $ bootParameter ) ) +structuralObjectClass: olcSchemaConfig +entryUUID: 713cd51c-df8a-103c-9774-230a0a7123cd +creatorsName: cn=config +createTimestamp: 20221013213337Z +entryCSN: 20221013213337.593234Z#000000#000#000000 +modifiersName: cn=config +modifyTimestamp: 20221013213337Z + +dn: olcDatabase=config,cn=config +objectClass: olcDatabaseConfig +olcDatabase: config + +dn: cn=module,cn=config +objectClass: olcModuleList +cn: module +olcModuleLoad: back_mdb + +dn: olcDatabase={1}mdb,cn=config +objectClass: olcDatabaseConfig +objectClass: olcMdbConfig +olcDatabase: {1}mdb +olcSuffix: dc=example,dc=com +olcDbDirectory: /usr/local/openldap/var/openldap-data +olcRootDN: cn=admin,dc=example,dc=com +olcRootPW: admin +olcAccess: to * by * write + diff --git a/packages/tom-server/src/search-engine-api/__testData__/llng/lmConf-1.json b/packages/tom-server/src/search-engine-api/__testData__/llng/lmConf-1.json new file mode 100644 index 00000000..1b56b4b3 --- /dev/null +++ b/packages/tom-server/src/search-engine-api/__testData__/llng/lmConf-1.json @@ -0,0 +1,481 @@ +{ + "ADPwdExpireWarning": 0, + "ADPwdMaxAge": 0, + "SMTPServer": "", + "SMTPTLS": "", + "SSLAuthnLevel": 5, + "SSLIssuerVar": "SSL_CLIENT_I_DN", + "SSLVar": "SSL_CLIENT_S_DN_Email", + "SSLVarIf": {}, + "activeTimer": 1, + "apacheAuthnLevel": 3, + "applicationList": {}, + "authChoiceParam": "lmAuth", + "authentication": "LDAP", + "available2F": "UTOTP,TOTP,U2F,REST,Mail2F,Ext2F,WebAuthn,Yubikey,Radius,Password", + "available2FSelfRegistration": "Password,TOTP,U2F,WebAuthn,Yubikey", + "bruteForceProtectionLockTimes": "15, 30, 60, 300, 600", + "bruteForceProtectionMaxAge": 300, + "bruteForceProtectionMaxFailed": 3, + "bruteForceProtectionMaxLockTime": 900, + "bruteForceProtectionTempo": 30, + "captcha_mail_enabled": 1, + "captcha_register_enabled": 1, + "captcha_size": 6, + "casAccessControlPolicy": "none", + "casAuthnLevel": 1, + "casTicketExpiration": 0, + "certificateResetByMailCeaAttribute": "description", + "certificateResetByMailCertificateAttribute": "userCertificate;binary", + "certificateResetByMailURL": "https://auth.example.com/certificateReset", + "certificateResetByMailValidityDelay": 0, + "cfgAuthor": "The LemonLDAP::NG team", + "cfgDate": "1627287638", + "cfgNum": "1", + "cfgVersion": "2.0.16", + "checkDevOpsCheckSessionAttributes": 1, + "checkDevOpsDisplayNormalizedHeaders": 1, + "checkDevOpsDownload": 1, + "checkHIBPRequired": 1, + "checkHIBPURL": "https://api.pwnedpasswords.com/range/", + "checkTime": 600, + "checkUserDisplayComputedSession": 1, + "checkUserDisplayEmptyHeaders": 0, + "checkUserDisplayEmptyValues": 0, + "checkUserDisplayHiddenAttributes": 0, + "checkUserDisplayHistory": 0, + "checkUserDisplayNormalizedHeaders": 0, + "checkUserDisplayPersistentInfo": 0, + "checkUserHiddenAttributes": "_loginHistory, _session_id, hGroups", + "checkUserIdRule": 1, + "checkXSS": 1, + "confirmFormMethod": "post", + "contextSwitchingIdRule": 1, + "contextSwitchingPrefix": "switching", + "contextSwitchingRule": 0, + "contextSwitchingStopWithLogout": 1, + "cookieName": "lemonldap", + "corsAllow_Credentials": "true", + "corsAllow_Headers": "*", + "corsAllow_Methods": "POST,GET", + "corsAllow_Origin": "*", + "corsEnabled": 1, + "corsExpose_Headers": "*", + "corsMax_Age": "86400", + "crowdsecAction": "reject", + "cspConnect": "'self'", + "cspDefault": "'self'", + "cspFont": "'self'", + "cspFormAction": "*", + "cspFrameAncestors": "", + "cspImg": "'self' data:", + "cspScript": "'self'", + "cspStyle": "'self'", + "dbiAuthnLevel": 2, + "dbiExportedVars": {}, + "decryptValueRule": 0, + "demoExportedVars": { + "cn": "cn", + "mail": "mail", + "uid": "uid" + }, + "displaySessionId": 1, + "domain": "example.com", + "exportedHeaders": {}, + "exportedVars": {}, + "ext2fActivation": 0, + "ext2fCodeActivation": "\\d{6}", + "facebookAuthnLevel": 1, + "facebookExportedVars": {}, + "facebookUserField": "id", + "failedLoginNumber": 5, + "findUserControl": "^[*\\w]+$", + "findUserWildcard": "*", + "formTimeout": 120, + "githubAuthnLevel": 1, + "githubScope": "user:email", + "githubUserField": "login", + "globalLogoutRule": 0, + "globalLogoutTimer": 1, + "globalStorage": "Apache::Session::File", + "globalStorageOptions": { + "Directory": "/var/lib/lemonldap-ng/sessions", + "LockDirectory": "/var/lib/lemonldap-ng/sessions/lock", + "generateModule": "Lemonldap::NG::Common::Apache::Session::Generate::SHA256" + }, + "gpgAuthnLevel": 5, + "gpgDb": "", + "grantSessionRules": {}, + "groups": {}, + "handlerInternalCache": 15, + "handlerServiceTokenTTL": 30, + "hiddenAttributes": "_password, _2fDevices", + "httpOnly": 1, + "https": -1, + "impersonationHiddenAttributes": "_2fDevices, _loginHistory", + "impersonationIdRule": 1, + "impersonationMergeSSOgroups": 0, + "impersonationPrefix": "real_", + "impersonationRule": 0, + "impersonationSkipEmptyValues": 1, + "infoFormMethod": "get", + "issuerDBCASPath": "^/cas/", + "issuerDBCASRule": 1, + "issuerDBGetParameters": {}, + "issuerDBGetPath": "^/get/", + "issuerDBGetRule": 1, + "issuerDBOpenIDConnectActivation": 1, + "issuerDBOpenIDConnectPath": "^/oauth2/", + "issuerDBOpenIDConnectRule": 1, + "issuerDBOpenIDPath": "^/openidserver/", + "issuerDBOpenIDRule": 1, + "issuerDBSAMLPath": "^/saml/", + "issuerDBSAMLRule": 1, + "issuersTimeout": 120, + "jsRedirect": 0, + "key": "^vmTGvh{+]5!ToB?", + "krbAuthnLevel": 3, + "krbRemoveDomain": 1, + "ldapServer": "annuaire", + "ldapAuthnLevel": 2, + "ldapBase": "dc=example,dc=com", + "ldapExportedVars": { + "cn": "cn", + "mail": "mail", + "uid": "uid" + }, + "ldapGroupAttributeName": "member", + "ldapGroupAttributeNameGroup": "dn", + "ldapGroupAttributeNameSearch": "cn", + "ldapGroupAttributeNameUser": "dn", + "ldapGroupObjectClass": "groupOfNames", + "ldapIOTimeout": 10, + "ldapPasswordResetAttribute": "pwdReset", + "ldapPasswordResetAttributeValue": "TRUE", + "ldapPwdEnc": "utf-8", + "ldapSearchDeref": "find", + "ldapTimeout": 10, + "ldapUsePasswordResetAttribute": 1, + "ldapVerify": "require", + "ldapVersion": 3, + "linkedInAuthnLevel": 1, + "linkedInFields": "id,first-name,last-name,email-address", + "linkedInScope": "r_liteprofile r_emailaddress", + "linkedInUserField": "emailAddress", + "localSessionStorage": "Cache::FileCache", + "localSessionStorageOptions": { + "cache_depth": 3, + "cache_root": "/var/lib/lemonldap-ng/cache", + "default_expires_in": 600, + "directory_umask": "007", + "namespace": "lemonldap-ng-sessions" + }, + "locationDetectGeoIpLanguages": "en, fr", + "locationRules": { + "auth.example.com": { + "(?#checkUser)^/checkuser": "inGroup(\"timelords\")", + "(?#errors)^/lmerror/": "accept", + "default": "accept" + } + }, + "loginHistoryEnabled": 1, + "logoutServices": {}, + "macros": { + "UA": "$ENV{HTTP_USER_AGENT}", + "_whatToTrace": "$_auth eq 'SAML' ? lc($_user.'@'.$_idpConfKey) : $_auth eq 'OpenIDConnect' ? lc($_user.'@'.$_oidc_OP) : lc($_user)" + }, + "mail2fActivation": 0, + "mail2fCodeRegex": "\\d{6}", + "mailCharset": "utf-8", + "mailFrom": "noreply@example.com", + "mailSessionKey": "mail", + "mailTimeout": 0, + "mailUrl": "https://auth.example.com/resetpwd", + "managerDn": "", + "managerPassword": "", + "max2FDevices": 10, + "max2FDevicesNameLength": 20, + "multiValuesSeparator": "; ", + "mySessionAuthorizedRWKeys": [ + "_appsListOrder", + "_oidcConnectedRP", + "_oidcConsents" + ], + "newLocationWarningLocationAttribute": "ipAddr", + "newLocationWarningLocationDisplayAttribute": "", + "newLocationWarningMaxValues": "0", + "notification": 0, + "notificationDefaultCond": "", + "notificationServerPOST": 1, + "notificationServerSentAttributes": "uid reference date title subtitle text check", + "notificationStorage": "File", + "notificationStorageOptions": { + "dirName": "/var/lib/lemonldap-ng/notifications" + }, + "notificationWildcard": "allusers", + "notificationsMaxRetrieve": 3, + "notifyDeleted": 1, + "nullAuthnLevel": 0, + "oidcAuthnLevel": 1, + "oidcOPMetaDataExportedVars": {}, + "oidcOPMetaDataJSON": {}, + "oidcOPMetaDataJWKS": {}, + "oidcOPMetaDataOptions": {}, + "oidcRPCallbackGetParam": "openidconnectcallback", + "oidcRPMetaDataExportedVars": { + "matrix0": { + "email": "mail", + "family_name": "cn", + "given_name": "cn", + "name": "cn", + "nickname": "uid", + "preferred_username": "uid" + }, + "matrix1": { + "email": "mail", + "family_name": "cn", + "given_name": "cn", + "name": "cn", + "nickname": "uid", + "preferred_username": "uid" + }, + "matrix2": { + "email": "mail", + "family_name": "cn", + "given_name": "cn", + "name": "cn", + "nickname": "uid", + "preferred_username": "uid" + }, + "matrix3": { + "email": "mail", + "family_name": "cn", + "given_name": "cn", + "name": "cn", + "nickname": "uid", + "preferred_username": "uid" + } + }, + "oidcRPMetaDataMacros": null, + "oidcRPMetaDataOptions": { + "matrix1": { + "oidcRPMetaDataOptionsAccessTokenClaims": 0, + "oidcRPMetaDataOptionsAccessTokenJWT": 0, + "oidcRPMetaDataOptionsAccessTokenSignAlg": "RS256", + "oidcRPMetaDataOptionsAllowClientCredentialsGrant": 0, + "oidcRPMetaDataOptionsAllowOffline": 0, + "oidcRPMetaDataOptionsAllowPasswordGrant": 0, + "oidcRPMetaDataOptionsBypassConsent": 1, + "oidcRPMetaDataOptionsClientID": "matrix1", + "oidcRPMetaDataOptionsClientSecret": "matrix1*", + "oidcRPMetaDataOptionsIDTokenForceClaims": 0, + "oidcRPMetaDataOptionsIDTokenSignAlg": "RS256", + "oidcRPMetaDataOptionsLogoutBypassConfirm": 0, + "oidcRPMetaDataOptionsLogoutSessionRequired": 1, + "oidcRPMetaDataOptionsLogoutType": "back", + "oidcRPMetaDataOptionsPublic": 0, + "oidcRPMetaDataOptionsRedirectUris": "https://matrix.example.com/_synapse/client/oidc/callback", + "oidcRPMetaDataOptionsRefreshToken": 0, + "oidcRPMetaDataOptionsRequirePKCE": 0 + } + }, + "oidcRPMetaDataOptionsExtraClaims": null, + "oidcRPMetaDataScopeRules": null, + "oidcRPStateTimeout": 600, + "oidcServiceAccessTokenExpiration": 3600, + "oidcServiceAllowAuthorizationCodeFlow": 1, + "oidcServiceAllowImplicitFlow": 0, + "oidcServiceAuthorizationCodeExpiration": 60, + "oidcServiceDynamicRegistrationExportedVars": {}, + "oidcServiceDynamicRegistrationExtraClaims": {}, + "oidcServiceIDTokenExpiration": 3600, + "oidcServiceIgnoreScopeForClaims": 1, + "oidcServiceKeyIdSig": "oMGHInscAW3Nsa0FcnCnDA", + "oidcServiceMetaDataAuthnContext": { + "loa-1": 1, + "loa-2": 2, + "loa-3": 3, + "loa-4": 4, + "loa-5": 5 + }, + "oidcServiceMetaDataAuthorizeURI": "authorize", + "oidcServiceMetaDataBackChannelURI": "blogout", + "oidcServiceMetaDataCheckSessionURI": "checksession.html", + "oidcServiceMetaDataEndSessionURI": "logout", + "oidcServiceMetaDataFrontChannelURI": "flogout", + "oidcServiceMetaDataIntrospectionURI": "introspect", + "oidcServiceMetaDataJWKSURI": "jwks", + "oidcServiceMetaDataRegistrationURI": "register", + "oidcServiceMetaDataTokenURI": "token", + "oidcServiceMetaDataUserInfoURI": "userinfo", + "oidcServiceOfflineSessionExpiration": 2592000, + "oidcServicePrivateKeySig": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDywteBzIOlhKc4\nO+vhMStDYOpPYrWDOodkUZ7OsxlWVNZ/b/lqIFS56+MHPkKNQuT4zZCyO8bEKmmR\nZ6kPFJoGbO1zJCPQ/RKjimX4J/5gDb1BAlo+6agJi55e3Bw0zKNJDU0mRyedcIzW\n7ywTgyj6B35pl/Sfloi4Q1XEizHar+26h66SOEtnppMxGvwsxO8gFWz26CPmalvY\n5GNYR0txbXUZn7I4kDa4mMWgNfeocWc78Qbt4RV5EuQdbRh1sou4tL9Nn4EuGhg0\nmfsSI0xVAj7f82Wn3kW6qEbhuejrY7aqmZjN7yrMKtCBuV7o4hVrjYLuM2j0mInY\nMy5nRNOVAgMBAAECggEAJ145nK8R2lG83H27LvXOUkrxNJaJYRKoyjgCTPr2bO2t\nK1V5WSCNHOmIE7ChEk962m5bvMu83CsUm6P34p4wrEIV78o4lLe1whe7mZbCxcj0\nnApJoFI8EfA2aqO/X0CgakRh8ocvgXSzIlf/CdsHViTI907ROOAso9Unn4wDNbdp\nMrhi3H2SnA+ewzj85WygBVTNQmVBjJSSLXTQRkfHye0ztvQm59gqqaJaM2rkBjvA\nlPWAVsgakOk4pgClKElCsIjWPJwdYtcd8VJrwnro5J9KhMwB//AArGgqOaXUHnLH\nv5aZZp6FjV/M3BxbSp4cG6hXmK1hrDFLecRddYP1gQKBgQD+Y4/ee57Z0E2V8833\nYfrK3F23sfxmZ7zUwEbgFXUfRy3RVW7Hbc7PAJzxzrk+LYk/zaZrrfEJguqG2O6m\nVNYkqxKu69Nn964CMdV15JGxVzpzsN5adKlcvKVVv9gx2rF3SMUOHiRutj2BlUtO\niCq0G3jFsXWIRzePig9PbWP6CQKBgQD0TG2DeDDUgKbeJYIzXfmCvGxlm5MZqCc/\nK7d8P9U0svG//jJRTsa9hcLjk7N24CzhLNHyJmT7dh1Xy1oLyHNPZ4nQRmCe+HUf\nu0SK10WZ2K55ekUmqS+xSuDFWJtWa5SE46cKg0fKu7YkiDKI1s6I3qrF4lew2aDE\n2p8GJRrgLQKBgCh2PZPtpb6PW0fWl5QZiYJqup1VOggvx+EvFBbgUti+wZLiO9SM\nqrBSMKRldSFmrMXxN984s3YH1LXOG2dpZwY+D6Ky79VBl/PRaVpvGJ1Uen+cSkGo\n/Kc7ejDBaunDFycZ8/3i3Xiek/ngfTHohqJPHE6Vg1RBv5ydIQJJK/XBAoGAU1XO\n9c4GOjc4tQbuhz9DYgmMoIyVfWcTHEV5bfUIcdWpCelYmMval8QNWzyDN8X5CUcU\nxxm50N3V3KENsn9KdofHRzj6tL/klFJ5azNMFtMHkYDYHfwQvNXiHu++7Zf9LefK\nj5eA4fNuir+7HVrJUX9DmgVADJ/wa7Z4EMyPgnECgYA/NLUs4920h10ie5lFffpM\nqq6CRcBjsQ7eGK9UI1Z2KZUh94eqIENSJ7whBjXKvJJvhAlH4//lVFMMRs7oJePY\nThg+8In7PB64yMOIJZLc5Fekn9aGG6YtErPzePQkXSYCKZxWl5EpjQZGgPRVkNtD\n2nflyJLjiCbTjeNgWIOZlw==\n-----END PRIVATE KEY-----\n", + "oidcServicePublicKeySig": "-----BEGIN CERTIFICATE-----\nMIICuDCCAaCgAwIBAgIEFU77HjANBgkqhkiG9w0BAQsFADAeMRwwGgYDVQQDDBNt\nYXRyaXgubGluYWdvcmEuY29tMB4XDTIzMDIxNTAzMTk0NloXDTQzMDIxMDAzMTk0\nNlowHjEcMBoGA1UEAwwTbWF0cml4LmxpbmFnb3JhLmNvbTCCASIwDQYJKoZIhvcN\nAQEBBQADggEPADCCAQoCggEBAPLC14HMg6WEpzg76+ExK0Ng6k9itYM6h2RRns6z\nGVZU1n9v+WogVLnr4wc+Qo1C5PjNkLI7xsQqaZFnqQ8UmgZs7XMkI9D9EqOKZfgn\n/mANvUECWj7pqAmLnl7cHDTMo0kNTSZHJ51wjNbvLBODKPoHfmmX9J+WiLhDVcSL\nMdqv7bqHrpI4S2emkzEa/CzE7yAVbPboI+ZqW9jkY1hHS3FtdRmfsjiQNriYxaA1\n96hxZzvxBu3hFXkS5B1tGHWyi7i0v02fgS4aGDSZ+xIjTFUCPt/zZafeRbqoRuG5\n6OtjtqqZmM3vKswq0IG5XujiFWuNgu4zaPSYidgzLmdE05UCAwEAATANBgkqhkiG\n9w0BAQsFAAOCAQEArNmGxZVvmvdOLctv+zQ+npzQtOTaJcf+r/1xYuM4FZVe4yLc\ny9ElDskoDWjvQU7jKeJeaDOYgMJQNrek8Doj8uHPWNe6jYFa62Csg9aPz6e8qbtq\nWI+sXds5GJd6xZ8mi2L4MdT/tf8dBgcgybuoRyhBtJwG1rLNAYkeXMxkBzOFcU7K\nR/SZ0q9ToLAWFDhn42MTjPN3t6GwKDzGNsM/SI/3WvUwpQbtK91hjPnNDwKiAtGG\nfUteuigfXY+0hEcQwJdR0St/FQ8UYYcAB5YT9IkT1wCcU5LfPHCBf3OXNpbnQsHh\netQMKLibM6wWdXNwmsd1szO66ft3QZ4h4EG3Vw==\n-----END CERTIFICATE-----\n", + "oidcStorageOptions": {}, + "openIdAuthnLevel": 1, + "openIdExportedVars": {}, + "openIdIDPList": "0;", + "openIdSPList": "0;", + "openIdSreg_email": "mail", + "openIdSreg_fullname": "cn", + "openIdSreg_nickname": "uid", + "openIdSreg_timezone": "_timezone", + "pamAuthnLevel": 2, + "pamService": "login", + "password2fActivation": 0, + "password2fSelfRegistration": 0, + "password2fUserCanRemoveKey": 1, + "passwordDB": "Demo", + "passwordPolicyActivation": 1, + "passwordPolicyMinDigit": 0, + "passwordPolicyMinLower": 0, + "passwordPolicyMinSize": 0, + "passwordPolicyMinSpeChar": 0, + "passwordPolicyMinUpper": 0, + "passwordPolicySpecialChar": "__ALL__", + "passwordResetAllowedRetries": 3, + "persistentSessionAttributes": "_loginHistory _2fDevices notification_", + "persistentStorage": "Apache::Session::File", + "persistentStorageOptions": { + "Directory": "/var/lib/lemonldap-ng/psessions", + "LockDirectory": "/var/lib/lemonldap-ng/psessions/lock" + }, + "port": -1, + "portal": "https://auth.example.com", + "portalAntiFrame": 1, + "portalCheckLogins": 1, + "portalDisplayAppslist": 1, + "portalDisplayChangePassword": "$_auth =~ /^(LDAP|DBI|Demo)$/", + "portalDisplayGeneratePassword": 1, + "portalDisplayLoginHistory": 1, + "portalDisplayLogout": 1, + "portalDisplayOidcConsents": "$_oidcConsents && $_oidcConsents =~ /\\w+/", + "portalDisplayOrder": "Appslist ChangePassword LoginHistory OidcConsents Logout", + "portalDisplayRefreshMyRights": 1, + "portalDisplayRegister": 1, + "portalErrorOnExpiredSession": 1, + "portalFavicon": "common/favicon.ico", + "portalForceAuthnInterval": 5, + "portalMainLogo": "common/logos/logo_llng_400px.png", + "portalPingInterval": 60000, + "portalRequireOldPassword": 1, + "portalSkin": "bootstrap", + "portalSkinBackground": "1280px-Cedar_Breaks_National_Monument_partially.jpg", + "portalUserAttr": "_user", + "proxyAuthServiceChoiceParam": "lmAuth", + "proxyAuthnLevel": 2, + "radius2fActivation": 0, + "radius2fTimeout": 20, + "radiusAuthnLevel": 3, + "radiusExportedVars": {}, + "randomPasswordRegexp": "[A-Z]{3}[a-z]{5}.\\d{2}", + "redirectFormMethod": "get", + "registerDB": "Null", + "registerTimeout": 0, + "registerUrl": "https://auth.example.com/register", + "reloadTimeout": 5, + "reloadUrls": { + "localhost": "https://reload.example.com/reload" + }, + "rememberAuthChoiceRule": 0, + "rememberCookieName": "llngrememberauthchoice", + "rememberCookieTimeout": 31536000, + "rememberTimer": 5, + "remoteGlobalStorage": "Lemonldap::NG::Common::Apache::Session::SOAP", + "remoteGlobalStorageOptions": { + "ns": "https://auth.example.com/Lemonldap/NG/Common/PSGI/SOAPService", + "proxy": "https://auth.example.com/sessions" + }, + "requireToken": 1, + "rest2fActivation": 0, + "restAuthnLevel": 2, + "restClockTolerance": 15, + "sameSite": "", + "samlAttributeAuthorityDescriptorAttributeServiceSOAP": "urn:oasis:names:tc:SAML:2.0:bindings:SOAP;#PORTAL#/saml/AA/SOAP;", + "samlAuthnContextMapKerberos": 4, + "samlAuthnContextMapPassword": 2, + "samlAuthnContextMapPasswordProtectedTransport": 3, + "samlAuthnContextMapTLSClient": 5, + "samlEntityID": "#PORTAL#/saml/metadata", + "samlIDPSSODescriptorArtifactResolutionServiceArtifact": "1;0;urn:oasis:names:tc:SAML:2.0:bindings:SOAP;#PORTAL#/saml/artifact", + "samlIDPSSODescriptorSingleLogoutServiceHTTPPost": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST;#PORTAL#/saml/singleLogout;#PORTAL#/saml/singleLogoutReturn", + "samlIDPSSODescriptorSingleLogoutServiceHTTPRedirect": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect;#PORTAL#/saml/singleLogout;#PORTAL#/saml/singleLogoutReturn", + "samlIDPSSODescriptorSingleLogoutServiceSOAP": "urn:oasis:names:tc:SAML:2.0:bindings:SOAP;#PORTAL#/saml/singleLogoutSOAP;", + "samlIDPSSODescriptorSingleSignOnServiceHTTPArtifact": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact;#PORTAL#/saml/singleSignOnArtifact;", + "samlIDPSSODescriptorSingleSignOnServiceHTTPPost": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST;#PORTAL#/saml/singleSignOn;", + "samlIDPSSODescriptorSingleSignOnServiceHTTPRedirect": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect;#PORTAL#/saml/singleSignOn;", + "samlIDPSSODescriptorWantAuthnRequestsSigned": 1, + "samlMetadataForceUTF8": 1, + "samlNameIDFormatMapEmail": "mail", + "samlNameIDFormatMapKerberos": "uid", + "samlNameIDFormatMapWindows": "uid", + "samlNameIDFormatMapX509": "mail", + "samlOrganizationDisplayName": "Example", + "samlOrganizationName": "Example", + "samlOrganizationURL": "https://www.example.com", + "samlOverrideIDPEntityID": "", + "samlRelayStateTimeout": 600, + "samlSPSSODescriptorArtifactResolutionServiceArtifact": "1;0;urn:oasis:names:tc:SAML:2.0:bindings:SOAP;#PORTAL#/saml/artifact", + "samlSPSSODescriptorAssertionConsumerServiceHTTPArtifact": "0;1;urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact;#PORTAL#/saml/proxySingleSignOnArtifact", + "samlSPSSODescriptorAssertionConsumerServiceHTTPPost": "1;0;urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST;#PORTAL#/saml/proxySingleSignOnPost", + "samlSPSSODescriptorAuthnRequestsSigned": 1, + "samlSPSSODescriptorSingleLogoutServiceHTTPPost": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST;#PORTAL#/saml/proxySingleLogout;#PORTAL#/saml/proxySingleLogoutReturn", + "samlSPSSODescriptorSingleLogoutServiceHTTPRedirect": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect;#PORTAL#/saml/proxySingleLogout;#PORTAL#/saml/proxySingleLogoutReturn", + "samlSPSSODescriptorSingleLogoutServiceSOAP": "urn:oasis:names:tc:SAML:2.0:bindings:SOAP;#PORTAL#/saml/proxySingleLogoutSOAP;", + "samlSPSSODescriptorWantAssertionsSigned": 1, + "samlServiceSignatureMethod": "RSA_SHA256", + "scrollTop": 400, + "securedCookie": 0, + "sessionDataToRemember": {}, + "sfEngine": "::2F::Engines::Default", + "sfManagerRule": 1, + "sfRemovedMsgRule": 0, + "sfRemovedNotifMsg": "_removedSF_ expired second factor(s) has/have been removed (_nameSF_)!", + "sfRemovedNotifRef": "RemoveSF", + "sfRemovedNotifTitle": "Second factor notification", + "sfRequired": 0, + "showLanguages": 1, + "singleIP": 0, + "singleSession": 0, + "singleUserByIP": 0, + "slaveAuthnLevel": 2, + "slaveExportedVars": {}, + "soapProxyUrn": "urn:Lemonldap/NG/Common/PSGI/SOAPService", + "stayConnected": 0, + "stayConnectedCookieName": "llngconnection", + "stayConnectedTimeout": 2592000, + "successLoginNumber": 5, + "timeout": 72000, + "timeoutActivity": 0, + "timeoutActivityInterval": 60, + "totp2fActivation": 0, + "totp2fDigits": 6, + "totp2fInterval": 30, + "totp2fRange": 1, + "totp2fSelfRegistration": 0, + "totp2fUserCanRemoveKey": 1, + "twitterAuthnLevel": 1, + "twitterUserField": "screen_name", + "u2fActivation": 0, + "u2fSelfRegistration": 0, + "u2fUserCanRemoveKey": 1, + "upgradeSession": 1, + "useRedirectOnError": 1, + "useSafeJail": 1, + "userControl": "^[\\w\\.\\-@]+$", + "userDB": "Same", + "utotp2fActivation": 0, + "viewerHiddenKeys": "samlIDPMetaDataNodes, samlSPMetaDataNodes", + "webIDAuthnLevel": 1, + "webIDExportedVars": {}, + "webauthn2fActivation": 0, + "webauthn2fSelfRegistration": 0, + "webauthn2fUserCanRemoveKey": 1, + "webauthn2fUserVerification": "preferred", + "whatToTrace": "_whatToTrace", + "yubikey2fActivation": 0, + "yubikey2fPublicIDSize": 12, + "yubikey2fSelfRegistration": 0, + "yubikey2fUserCanRemoveKey": 1 + } + \ No newline at end of file diff --git a/packages/tom-server/src/search-engine-api/__testData__/llng/ssl.conf b/packages/tom-server/src/search-engine-api/__testData__/llng/ssl.conf new file mode 100644 index 00000000..85afa7cd --- /dev/null +++ b/packages/tom-server/src/search-engine-api/__testData__/llng/ssl.conf @@ -0,0 +1,12 @@ +server { + listen 443 ssl default_server; + listen [::]:443 ssl default_server; + ssl_certificate /etc/nginx/ssl/auth.example.com.crt; + ssl_certificate_key /etc/nginx/ssl/auth.example.com.key; + server_name _; + location / { + proxy_pass http://auth.example.com:80/; + proxy_redirect off; + proxy_set_header Host $host; + } +} \ No newline at end of file diff --git a/packages/tom-server/src/search-engine-api/__testData__/nginx/ssl/9da13359.0 b/packages/tom-server/src/search-engine-api/__testData__/nginx/ssl/9da13359.0 new file mode 120000 index 00000000..e375f5ab --- /dev/null +++ b/packages/tom-server/src/search-engine-api/__testData__/nginx/ssl/9da13359.0 @@ -0,0 +1 @@ +ca.pem \ No newline at end of file diff --git a/packages/tom-server/src/search-engine-api/__testData__/nginx/ssl/auth.example.com.crt b/packages/tom-server/src/search-engine-api/__testData__/nginx/ssl/auth.example.com.crt new file mode 100644 index 00000000..66306eae --- /dev/null +++ b/packages/tom-server/src/search-engine-api/__testData__/nginx/ssl/auth.example.com.crt @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIIFJDCCAwwCAf8wDQYJKoZIhvcNAQELBQAwRTELMAkGA1UEBhMCQVUxEzARBgNV +BAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0 +ZDAgFw0yNDAxMjUxMDU1NTdaGA8yMTI0MDEwMTEwNTU1N1owaTELMAkGA1UEBhMC +RlIxDzANBgNVBAgMBkNlbnRyZTEOMAwGA1UEBwwFUGFyaXMxETAPBgNVBAoMCExp +bmFnb3JhMQswCQYDVQQLDAJJVDEZMBcGA1UEAwwQYXV0aC5leGFtcGxlLmNvbTCC +AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAM8xy/VQsmnrzLGPqvf69XLf +HO3BwnIvwhegN+rXEF8bl3ahL0dusJ1E+i38X3RZtRgC5/59RmfgwRSAAr8BhfcF +UJoReKJ5t5pJMf8ef9V1PVmJRxpwN6RsgDO0Aq/8NIvfrclGXsV1FeyFxjBezVYV +pKp+b+JZ4DFjNtZvL7Z1MRQZYr4nQ0Sk/JP5A1XbgyKBnOP60aZwqKp6vdNfrwrF +6EoF1jVJfZvGCO6rkF4P+1/CkHSOElrtpou+zFvEZHX4uJInW5ArckHfhSQLXGqO +sJGOuyvL2ZI0Lgk0JRWrUbVuS3LWKalDgHwn1AdMfPQc3pz+YEHgd6vNNrMBpLZI +6Hjcblq0Z2bVbn7RO44iwYr92Pfy9JYsk0O9Ks8UWfuHIrH85Pgtfq5leL+05jDv +fiZpLdS1DMy7g1DKt0YvjBW8C6lkkhYt6bsoK/+1kCMgZ7WpWJI1Jgmjjgymh/0B +HNAKlhcz3lIrEBixrPTvsiac7Yp8TNbj3IdbUUEsG82A0zCwAJ2frTdj/dCZWr9N +e2Xk6J8heTAbfJLeAk76Q/TNVzhACcdw5Iy2SJYGxyOUWQ11R/Kjtd9SVwz2iU58 +mJNBQYjLcJcAYBi4IpxTkZyGZcnCEZ2gmxKvTKR9ulQEm8AKjP8/F2AKor8vSUKD +u3WywWl4fmH8NOpto4E3AgMBAAEwDQYJKoZIhvcNAQELBQADggIBAJdSoTQLTfOB +nUS5QpBlBQMvNfUi6Y2dqiQjb5kmNxurBe7tyIcHB3CvTw0Iof0xVEpls/MEZQGK +rcZXtG617m4E82mdIBKPpswSNZ2u+MNt2s1k8Cz7kY++J55nIVzPALV0n+z41LOJ +1CyQcKSf+Olr6oas8OlWkrJip4ZK6JB0WeKIaW45gq/GUXsEXlG7nBaCCaVptsha +Zgosrt8b2ivJOpWL2YZ4Z/yD9Q7H0AEA8c6ks7mobdBOsFxLqG8S+hw+kyGUtKTM +yHhf0enQRYCtnBHls29/TZLUET9zMDuMxFQa4BlwU3jlqjieOAuF2cZhPuWU1ae+ +sjy4fLLJ6NBEN+DPQu6avY0kpao8ltLRHheewqUG/fxtiMzmptyiXTlloS/xbFfS +Y1pbXlNm+znzcSGH1YPOXkfShyhv64DPphz4AkdLCEtSKhYKEuPlgjcElfX89/bm +8fEjZ833w2CHPORWtQbgNFZYpBLkDrnugJY0f4Wr/TCFBSe/h4qgLJv5WQ+u5+sJ +lFDdiE90SEb0x+ZuAe895O4s93g34HRRTssouue392kIybjPkOq10G9eZY4pyN72 +zqz8UZxmSTrMoFve0sTE2yXGa7V7EpHvR5qMtlcn7dy7AU75Px4onIy1l4THuqdd +XErmy1mkvltLyuGv2nnJ3pXfPF2kapB8 +-----END CERTIFICATE----- diff --git a/packages/tom-server/src/search-engine-api/__testData__/nginx/ssl/auth.example.com.key b/packages/tom-server/src/search-engine-api/__testData__/nginx/ssl/auth.example.com.key new file mode 100644 index 00000000..dc2af169 --- /dev/null +++ b/packages/tom-server/src/search-engine-api/__testData__/nginx/ssl/auth.example.com.key @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +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 +8MC5ePCOo8Jid8ttrGvS2XXoxriOVUw= +-----END PRIVATE KEY----- diff --git a/packages/tom-server/src/search-engine-api/__testData__/nginx/ssl/ca.key b/packages/tom-server/src/search-engine-api/__testData__/nginx/ssl/ca.key new file mode 100644 index 00000000..40710292 --- /dev/null +++ b/packages/tom-server/src/search-engine-api/__testData__/nginx/ssl/ca.key @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +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 +ihycX8ry3MlIyX2sgTBP8dmsxMRU +-----END PRIVATE KEY----- diff --git a/packages/tom-server/src/search-engine-api/__testData__/nginx/ssl/ca.pem b/packages/tom-server/src/search-engine-api/__testData__/nginx/ssl/ca.pem new file mode 100644 index 00000000..7e4d2778 --- /dev/null +++ b/packages/tom-server/src/search-engine-api/__testData__/nginx/ssl/ca.pem @@ -0,0 +1,32 @@ +-----BEGIN CERTIFICATE----- +MIIFbTCCA1WgAwIBAgIUGhSA3BDdpHF/Fcq4Ukxpc2sXoAgwDQYJKoZIhvcNAQEL +BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM +GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAgFw0yNDAxMjUxMDU1NDlaGA8yMTI0 +MDEwMTEwNTU0OVowRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUx +ITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDCCAiIwDQYJKoZIhvcN +AQEBBQADggIPADCCAgoCggIBAOY1RstlOKgr8CInAvAaYCnDzgHuFMdSBu7Rcs2w +y35Ls0pUOoC5aW2Tm7IzMRRpwbfM0KpTVkQLZ0GAirfVSBc+iLzChnKesK3RQQ2X +Qtg9E9RHvUR0L/24oHKcVc0D279p+ilgL8/JktaE59RzEhaHidlpRgP0XdDBp1M4 +XG6+3XQ7a5bjsC0i/9WElKb/QhbqDMYtqIGVRAQH2sHeDw70uT8IamwOb1gFn8X/ +kChSFywyoA6kSLtNGTnJGXDVfnuVifdN+pukiV2O/wNKEuuK0pDqjw0Yz/3G4HyR +bNviAEY02hPHwXsseQnstTZOhH0GfKwxDpsLbonW+HC6W+QlDgYOGYDqD64oHyMF +HCPn2g/5dEsjBOprEL6qNT3khLPidr9r3U2WmZsa2pY4Mevaol8RCs8kmu73mLGt +wNVWPtbe7/2ukDIdTqEQWe1wvY0Bo+bbgGbZxuvVe5J+klng2vH/t79crj2M7LzJ +CjpksS2QzvvgoweImRdrZJe63BTr2gxHCA5DvY/Uk26Zi+lhVWf7QJfwb4Y3AoBJ +QNgy36/R/yaLj6E49T0UEwOIFUKUCezzNuJ22jcGJTePFtsUFEMs2l6dbngtS4pU +HhW5WUJ7mmtykofsBW0eT+MVsXggcG5a1UX7fw3CfEIEhxb9gLubTmI79um8fdFX +II6lAgMBAAGjUzBRMB0GA1UdDgQWBBRsZ19BHWcC/szX/TM7jRYUqtrhYzAfBgNV +HSMEGDAWgBRsZ19BHWcC/szX/TM7jRYUqtrhYzAPBgNVHRMBAf8EBTADAQH/MA0G +CSqGSIb3DQEBCwUAA4ICAQDRY1b3E1lW/hAw9z1Ok+ukB/1ObB84MIxOP8Ie0JsX +xGzMyysdREsv9wqZRIx78JX64pXJ9lsdnir6lM6yO7Ur3BkJm08qpbrtEOv2O3x4 +AHv+fyTvfKxOcqFNONw3D0zqwUGACZfAuf65onK04yQCXfa3a1HnLxW3NAPzbAxX +I4h0w0c1sayCHh+g3m8yxe97Hi3X/Wzst4ZzXkt7sy6TenD2+fKLZbKc6FKHugbT +PBSnys4AtyfnxyUZHOrBboKozo9I35NdypYKVPFQT/CbWWfqY3/js8BkBXvnSCy9 +Eo0zokI/bvmj5ooJTigx6uBM5eV1wUNVKREyac1JnthTPCzVzIFm5HkPFvQ7oYrW +PxzQjW+kPI+G2QHMGka+HBI3ITdc0xG7DJquvB+DF3QepT+S7JlnjfQjQeE7SR/7 +HFmHWsEUGGeaqa7NJ8Sysw0VdNDwEgGJLEgRJVyVWLMnz9BTWm04X8o5o4t3mNSJ +bPUnx3M7QGeNTPdYO1TV5Y2lGm7m4piPS5QBl22np/UpAoDk5e4FfDC9JOd2qbZH +giDobUWZYThyvvqCww9xLD0cEbXrIbuDl2PCkloWg1zLxjFOBN+FTzbOLQ4OiV3r +04qrprxl98Gg6vIjO+S0s9EJmgQxxPlhyDUxhFw+v17SqFWlLh6yUObOfu3tnK5m +lA== +-----END CERTIFICATE----- diff --git a/packages/tom-server/src/search-engine-api/__testData__/nginx/ssl/matrix.example.com.crt b/packages/tom-server/src/search-engine-api/__testData__/nginx/ssl/matrix.example.com.crt new file mode 100644 index 00000000..ac592f62 --- /dev/null +++ b/packages/tom-server/src/search-engine-api/__testData__/nginx/ssl/matrix.example.com.crt @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIIFJjCCAw4CAf8wDQYJKoZIhvcNAQELBQAwRTELMAkGA1UEBhMCQVUxEzARBgNV +BAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0 +ZDAgFw0yNDAxMjUxMDU1NDlaGA8yMTI0MDEwMTEwNTU0OVowazELMAkGA1UEBhMC +RlIxDzANBgNVBAgMBkNlbnRyZTEOMAwGA1UEBwwFUGFyaXMxETAPBgNVBAoMCExp +bmFnb3JhMQswCQYDVQQLDAJJVDEbMBkGA1UEAwwSbWF0cml4LmV4YW1wbGUuY29t +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA67bFDFK2lQ/UxB5sG0QU +PrRUhNU6oDm06kmNcPC2++aZ32kbg1oySYkpzGozwWssRvcJlNzniql+vQL/diO3 +H3upTqcBfYv6oidY/BY12ZgIsJ5FWRpLQlY62LUSAzNWzqaI1TyalTUN9r1fMTnm +Qh4ir+DECs0YPXUCsxd4dLbLSgt2JkgDl9/Vwvb0nHmwi5ALJHIT3lYLQG0akJ0p +7vv8CyN5G7l7rFM0Q/fsJ+CQaTweZWhFlqQKWcA6nZpM5xmCqGD1KkhYRvuowJvL +KlmutPHvgzx9ZIoeO6bz/9lH4ql3Qu2qWFQhGVQ0EJUR+b2i5NB5/EAc46BVmNBU +bQa9heztZEMTEtsa/U7d45frT7dv602RHvJv3rttJFicoy0rJCikC4M20Ju6dj+p +XS1fMTY8neWLvOeRru2K3ijxD5kvL1t6tApfV3cpkodp6LaGXcsfVAZntM00sSmp +KbIDJnipXtMJIMmXtthDF0Ov8J5hpXzmUca6Gm7Y/b1yQfyS2zzZK5+D1+W1shiR +k1Lp0MuDBrlXweGe4+4ukik4vjO3crLXSzxIeifYDLG+U45WZgAOpQBVF5g3HIa9 +fsd3KOSW1i1zhDJDseSy8AMszIA9CmTAcXvI80bJ0e9g1T8ndF6DhepE/h1mDHEz +Ah9PgQz/ZRexz7DeNdR3G/kCAwEAATANBgkqhkiG9w0BAQsFAAOCAgEAkzp0fU2A +G0+QElVk10D1NIsxgyV3TDtRl3gQ7nlM9gRc3y+1YDimmgeCDpFrfyqCPHhqxa0+ +nhe5bmBLtK0kfIXTgfVhQAyosxXAzSXu2DwoQC4KeWU9aKP022T1WqPUcPWQUH9N +F7WIg5XMwrghqi2n0XQNLMn5U3VYWkVoHTIULQdOjfhT/5D/0lcr6yKzAwDuEaiN +7tpdmcmDugdyVoYPsh3fhSfjqSYC7pH6x0o1sYcXVEkALOMrbL7HcP7NW0dXxIkp +qu2E2doPzi2ixClWCHMYQQOQgPNJ5Trwjhwe+0Vm1L47DSu4vE4IPX+vRblW9a90 +l2Yj0DOUuj3nGPzs264h2buEMJKAj0iwS+S9N06QSawRRDy0UTb2irPIy/D2cHZx +powB7Ik2DX2WQvTQCcrrk2/T5xYJ4Zn+g8vAd3DpEEWMt4LRdQFIpFTF63R5KYz5 +2xSYwZJ4rCBXgB70IyeuYqJrKnCHSS3xvjw8jmqh4VKyphs7jx4DjeZzhSdQfHqW +TjJKP5VeeEkeZnN1oE5m6L1rqFdqX9egRl7M3+nKe5KSduM9ghSb9Z/PHYO4cofY +zOwTSX1GNtRXwGQQ54NCB4fZCssswNIR0CKbiE5MlsGosY+O3ktqeU5CxumwJRkS +Sw8AqpIlLxEHKRS/JgX0FpqEoNHoqrAIMbY= +-----END CERTIFICATE----- diff --git a/packages/tom-server/src/search-engine-api/__testData__/nginx/ssl/matrix.example.com.key b/packages/tom-server/src/search-engine-api/__testData__/nginx/ssl/matrix.example.com.key new file mode 100644 index 00000000..a4f11057 --- /dev/null +++ b/packages/tom-server/src/search-engine-api/__testData__/nginx/ssl/matrix.example.com.key @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +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 +VQtGdt2o2wkAwabZieczo+3cIUj3di4= +-----END PRIVATE KEY----- diff --git a/packages/tom-server/src/search-engine-api/__testData__/nginx/ssl/opensearch.example.com.crt b/packages/tom-server/src/search-engine-api/__testData__/nginx/ssl/opensearch.example.com.crt new file mode 100644 index 00000000..ad86671e --- /dev/null +++ b/packages/tom-server/src/search-engine-api/__testData__/nginx/ssl/opensearch.example.com.crt @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIIFKjCCAxICAf8wDQYJKoZIhvcNAQELBQAwRTELMAkGA1UEBhMCQVUxEzARBgNV +BAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0 +ZDAgFw0yNDAyMDcxMzMzMzlaGA8yMTI0MDExNDEzMzMzOVowbzELMAkGA1UEBhMC +RlIxDzANBgNVBAgMBkNlbnRyZTEOMAwGA1UEBwwFUGFyaXMxETAPBgNVBAoMCExp +bmFnb3JhMQswCQYDVQQLDAJJVDEfMB0GA1UEAwwWb3BlbnNlYXJjaC5leGFtcGxl +LmNvbTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK45xyC8GVxUaQkd +MIBqHpKelZjYw8xw8s39KE2/bTvHdl5xgNDJj7lbgmYmRQkLFUb5kkB0tFw9jiHk +kpOvKDoonMsM3yJJRgNUXxUFwgn01U/XgyQYkbLzuOGkL5d7o/fep6x1+/GkAn/a +DfCBlVfISOZ6UzDb5SuRFlUXqktfhLqTC24/E25+fBBtcgJBgD/O/GF80y9jD00D +JyHCLH2eEYvzOZ7v6JGfrfeJGM9O4gd8Lq0klXggMuXlsDoRAKRiONICooXBuYG8 +lE0uumLb5ekDeiVm4RdWq4rS2o2UoqQyHs3FE3jmwh9xejXL4rZQhcoLD9AGAsLR +6oUU+JkGUEUPqFsa5YF/jKV0bYM/5JblLxDEq2MRoXdb+N+qWpgPE9phUHQ9lYNf +/+GFjaJrsYswN0uTpDx5qoRE7U2bQmA8Q3NDcy6cbwY5ppB3Tfp3y8xZw2wSjG0P +Mg5pRruu1nSP1veUi6RLRfMuiGo2kUWCCi5C4wgzIuuGMLBiWbzhhf0GeOuj/0tp +QhPeAGYYmSXcGlvSnSypjmCm2Kb1MvWZqKayYaZharTE9FZ0Jp3vA9jqPo1/vep8 +dfRXM7GL3oThvc8K0xiZBORaA8RNgcUUMp8jynT1Ug72AeCnZr3VdcBI3Ww0zrBU +4PPeVpO8OEcGKiVEdxj1p/cBtU1lAgMBAAEwDQYJKoZIhvcNAQELBQADggIBADB2 +Iv4LjwMIuV4shH+VV9WRNLJAK/nVJbKYi02SG013UnQ3sczIVX3YVpkGQfK5pviD +qCTUBa+Nki0AcNxQgFvBfSzzuNRF8IHnGAT8ZGrst/D8ULhQ/FdFt04uHBFDeH0h +LdQ7sjHiN0roTwYdAmmSUUVKtQz8tXh4oKxkLFRID8zjOridKw19x6QTQPElcF16 +OC+yJLFSHANciid4ozuyTtmSkJScuvimwkQwaNsqyAO3MxP21DoKa8a/sPZAyAUz +1Jr61bgEdgs8syQol7SzKnPXbhY/y5Jx2Bh+a/SzaBd/qc0hiZIcdqEwHsXywFv9 +z6W4Wv1gn3xraPClTAUStWnC0uxoPjhKLb0x6zj3R44GRnsdhnz2bCBfGiRU02Fk +7vZuUHXA3mPaPvFTRs+hr6Jpeb1dbPm7N+hKrKm2l0vdzR9pVg8AdMI4xSMwEGdw +Q7inKudqRkpOdVzVK8daVL61ZRtRbJehPxZIrEkk4VvZaGej4+41odGqizQ573U5 +d15pMYhOo23y1SV72taHCJO166GkCBUTOdioJiLYbcrJ/SofbjK0S6O1UEelV4kJ +epnpevDrGII7Uhsqxxc4SqBHYM/BXOd/S3kb8QLPjPDTTHbthbYzdhKLoXadefhP +tkyLHyKvmCtEbzoK7suKKhAcmDytAsDxjDmcXqMO +-----END CERTIFICATE----- diff --git a/packages/tom-server/src/search-engine-api/__testData__/nginx/ssl/opensearch.example.com.key b/packages/tom-server/src/search-engine-api/__testData__/nginx/ssl/opensearch.example.com.key new file mode 100644 index 00000000..02015b88 --- /dev/null +++ b/packages/tom-server/src/search-engine-api/__testData__/nginx/ssl/opensearch.example.com.key @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +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 +YF3wBZPszCouEMHLb0UKcIPkQ2RdMg== +-----END PRIVATE KEY----- diff --git a/packages/tom-server/src/search-engine-api/__testData__/opensearch/tmail-data.json b/packages/tom-server/src/search-engine-api/__testData__/opensearch/tmail-data.json new file mode 100644 index 00000000..a8563511 --- /dev/null +++ b/packages/tom-server/src/search-engine-api/__testData__/opensearch/tmail-data.json @@ -0,0 +1,301 @@ +[ + { + "_index": "mailbox_v2", + "_source": { + "attachments": [{ + "contentDisposition": "attachment", + "fileExtension": "txt", + "fileName": "message1.txt", + "mediaType": "text/plain", + "textContent": "May the Force be with you!" + }], + "bcc": [{ + "address": "hsolo@example.com", + "domain": "example.com", + "name": "Han Solo" + }], + "cc": [{ + "address": "c3po@example.com", + "domain": "example.com", + "name": "C-3PO" + }], + "date": "2024-02-22T12:30:00Z", + "from": [{ + "address": "lskywalker@example.com", + "domain": "example.com", + "name": "Luke Skywalker" + }], + "hasAttachment": true, + "headers": [ + { "name": "Header1", "value": "Value1" }, + { "name": "Header2", "value": "Value2" } + ], + "htmlBody": "

May the Force be with you!

", + "isAnswered": false, + "isDeleted": false, + "isDraft": false, + "isFlagged": true, + "isRecent": true, + "isUnread": false, + "mailboxId": "mailbox1", + "mediaType": "text/plain", + "messageId": "message1", + "mimeMessageID": "mimeMessageID1", + "modSeq": 12345, + "saveDate": "2024-02-22T12:30:00Z", + "sentDate": "2024-02-22T12:30:00Z", + "size": 1024, + "subject": ["Star Wars Message 1"], + "subtype": "subtype1", + "textBody": "May the Force be with you!", + "threadId": "thread1", + "to": [{ + "address": "jbinks@example.com", + "domain": "example.com", + "name": "Jar Jar Binks" + }], + "uid": 123456, + "userFlags": ["Flag1", "Flag2"] + } + }, + { + "_index": "mailbox_v2", + "_source": { + "attachments": [{ + "contentDisposition": "attachment", + "fileExtension": "pdf", + "fileName": "attachment2.pdf", + "mediaType": "application/pdf", + "textContent": "The plans are in the droid." + }], + "bcc": [{ + "address": "myoda@example.com", + "domain": "example.com", + "name": "Master Yoda" + }], + "cc": [{ + "address": "lorgana@example.com", + "domain": "example.com", + "name": "Leia Organa" + }], + "date": "2024-02-23T14:45:00Z", + "from": [{ + "address": "okenobi@example.com", + "domain": "example.com", + "name": "Obi-Wan Kenobi" + }], + "hasAttachment": true, + "headers": [ + { "name": "Header3", "value": "Value3" }, + { "name": "Header4", "value": "Value4" } + ], + "htmlBody": "

The plans are in the droid.

", + "isAnswered": false, + "isDeleted": false, + "isDraft": true, + "isFlagged": false, + "isRecent": false, + "isUnread": true, + "mailboxId": "mailbox2", + "mediaType": "application/pdf", + "messageId": "message2", + "mimeMessageID": "mimeMessageID2", + "modSeq": 54321, + "saveDate": "2024-02-23T14:45:00Z", + "sentDate": "2024-02-23T14:45:00Z", + "size": 2048, + "subject": ["Star Wars Message 2"], + "subtype": "subtype2", + "textBody": "The plans are in the droid.", + "threadId": "thread2", + "to": [{ + "address": "lskywalker@example.com", + "domain": "example.com", + "name": "Luke Skywalker" + }], + "uid": 654321, + "userFlags": ["Flag3"] + } + }, + { + "_index": "mailbox_v2", + "_source": { + "attachments": [{ + "contentDisposition": "attachment", + "fileExtension": "jpg", + "fileName": "image1.jpg", + "mediaType": "image/jpeg", + "textContent": "A beautiful galaxy far, far away." + }], + "bcc": [{ + "address": "okenobi@example.com", + "domain": "example.com", + "name": "Obi-Wan Kenobi" + }], + "cc": [{ + "address": "pamidala@example.com", + "domain": "example.com", + "name": "Padme Amidala" + }], + "date": "2024-02-24T10:15:00Z", + "from": [{ + "address": "dmaul@example.com", + "domain": "example.com", + "name": "Dark Maul" + }], + "hasAttachment": true, + "headers": [ + { "name": "Header5", "value": "Value5" }, + { "name": "Header6", "value": "Value6" } + ], + "htmlBody": "

A beautiful galaxy far, far away.

", + "isAnswered": true, + "isDeleted": false, + "isDraft": false, + "isFlagged": true, + "isRecent": true, + "isUnread": false, + "mailboxId": "mailbox3", + "mediaType": "image/jpeg", + "messageId": "message3", + "mimeMessageID": "mimeMessageID3", + "modSeq": 98765, + "saveDate": "2024-02-24T10:15:00Z", + "sentDate": "2024-02-24T10:15:00Z", + "size": 4096, + "subject": ["Star Wars Message 3"], + "subtype": "subtype3", + "textBody": "A beautiful galaxy far, far away.", + "threadId": "thread3", + "to": [{ + "address": "kren@example.com", + "domain": "example.com", + "name": "Kylo Ren" + }], + "uid": 987654, + "userFlags": ["Flag4", "Flag5"] + } + }, + { + "_index": "mailbox_v2", + "_source": { + "attachments": [{ + "contentDisposition": "inline", + "fileExtension": "png", + "fileName": "image2.png", + "mediaType": "image/png", + "textContent": "May the pixels be with you." + }], + "bcc": [{ + "address": "chewbacca@example.com", + "domain": "example.com", + "name": "Chewbacca" + }, { + "address": "jbinks@example.com", + "domain": "example.com", + "name": "Jar Jar Binks" + }], + "cc": [{ + "address": "qjinn@example.com", + "domain": "example.com", + "name": "Qui-Gon Jinn" + }], + "date": "2024-02-25T08:45:00Z", + "from": [{ + "address": "lskywalker@example.com", + "domain": "example.com", + "name": "Luke Skywalker" + }], + "hasAttachment": true, + "headers": [ + { "name": "Header7", "value": "Value7" }, + { "name": "Header8", "value": "Value8" } + ], + "htmlBody": "

May the pixels be with you.

", + "isAnswered": false, + "isDeleted": true, + "isDraft": false, + "isFlagged": false, + "isRecent": true, + "isUnread": false, + "mailboxId": "mailbox4", + "mediaType": "image/png", + "messageId": "message4", + "mimeMessageID": "mimeMessageID4", + "modSeq": 13579, + "saveDate": "2024-02-25T08:45:00Z", + "sentDate": "2024-02-25T08:45:00Z", + "size": 8192, + "subject": ["Star Wars Message 4"], + "subtype": "subtype4", + "textBody": "May the pixels be with you.", + "threadId": "thread4", + "to": [{ + "address": "hsolo@example.com", + "domain": "example.com", + "name": "Han Solo" + }], + "uid": 1234567, + "userFlags": ["Flag6"] + } + }, + { + "_index": "mailbox_v2", + "_source": { + "attachments": [{ + "contentDisposition": "attachment", + "fileExtension": "docx", + "fileName": "document1.docx", + "mediaType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "textContent": "A long time ago in a galaxy far, far away." + }], + "bcc": [{ + "address": "lskywalker@example.com", + "domain": "example.com", + "name": "Luke Skywalker" + }], + "cc": [{ + "address": "myoda@example.com", + "domain": "example.com", + "name": "Master Yoda" + }], + "date": "2024-02-26T16:30:00Z", + "from": [{ + "address": "hsolo@example.com", + "domain": "example.com", + "name": "Han Solo" + }], + "hasAttachment": true, + "headers": [ + { "name": "Header9", "value": "Value9" }, + { "name": "Header10", "value": "Value10" } + ], + "htmlBody": "

A long time ago in a galaxy far, far away.

", + "isAnswered": true, + "isDeleted": false, + "isDraft": false, + "isFlagged": true, + "isRecent": false, + "isUnread": true, + "mailboxId": "mailbox5", + "mediaType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "messageId": "message5", + "mimeMessageID": "mimeMessageID5", + "modSeq": 24680, + "saveDate": "2024-02-26T16:30:00Z", + "sentDate": "2024-02-26T16:30:00Z", + "size": 16384, + "subject": ["Star Wars Message 5"], + "subtype": "subtype5", + "textBody": "A long time ago in a galaxy far, far away.", + "threadId": "thread5", + "to": [{ + "address": "chewbacca@example.com", + "domain": "example.com", + "name": "Chewbacca" + }], + "uid": 2345678, + "userFlags": ["Flag7", "Flag8"] + } + } +] diff --git a/packages/tom-server/src/search-engine-api/__testData__/opensearch/tmail-mapping.json b/packages/tom-server/src/search-engine-api/__testData__/opensearch/tmail-mapping.json new file mode 100644 index 00000000..ce356979 --- /dev/null +++ b/packages/tom-server/src/search-engine-api/__testData__/opensearch/tmail-mapping.json @@ -0,0 +1,210 @@ +{ + "mailbox_v2": { + "mappings": { + "dynamic": "strict", + "properties": { + "attachments": { + "properties": { + "contentDisposition": { + "type": "keyword" + }, + "fileExtension": { + "type": "keyword" + }, + "fileName": { + "type": "text", + "analyzer": "standard" + }, + "mediaType": { + "type": "keyword" + }, + "subtype": { + "type": "keyword" + }, + "textContent": { + "type": "text", + "analyzer": "standard" + } + } + }, + "bcc": { + "properties": { + "address": { + "type": "text", + "fields": { + "raw": { + "type": "keyword" + } + }, + "analyzer": "standard" + }, + "domain": { + "type": "text", + "analyzer": "simple", + "search_analyzer": "keyword" + }, + "name": { + "type": "text" + } + } + }, + "cc": { + "properties": { + "address": { + "type": "text", + "fields": { + "raw": { + "type": "keyword" + } + }, + "analyzer": "standard" + }, + "domain": { + "type": "text", + "analyzer": "simple", + "search_analyzer": "keyword" + }, + "name": { + "type": "text" + } + } + }, + "date": { + "type": "date", + "format": "uuuu-MM-dd'T'HH:mm:ssX||uuuu-MM-dd'T'HH:mm:ssXXX||uuuu-MM-dd'T'HH:mm:ssXXXXX" + }, + "from": { + "properties": { + "address": { + "type": "text", + "fields": { + "raw": { + "type": "keyword" + } + }, + "analyzer": "standard" + }, + "domain": { + "type": "text", + "analyzer": "simple", + "search_analyzer": "keyword" + }, + "name": { + "type": "text" + } + } + }, + "hasAttachment": { + "type": "boolean" + }, + "headers": { + "type": "nested", + "properties": { + "name": { + "type": "keyword" + }, + "value": { + "type": "text" + } + } + }, + "htmlBody": { + "type": "text", + "analyzer": "standard" + }, + "isAnswered": { + "type": "boolean" + }, + "isDeleted": { + "type": "boolean" + }, + "isDraft": { + "type": "boolean" + }, + "isFlagged": { + "type": "boolean" + }, + "isRecent": { + "type": "boolean" + }, + "isUnread": { + "type": "boolean" + }, + "mailboxId": { + "type": "keyword", + "store": true + }, + "mediaType": { + "type": "keyword" + }, + "messageId": { + "type": "keyword", + "store": true + }, + "mimeMessageID": { + "type": "keyword" + }, + "modSeq": { + "type": "long" + }, + "saveDate": { + "type": "date", + "format": "uuuu-MM-dd'T'HH:mm:ssX||uuuu-MM-dd'T'HH:mm:ssXXX||uuuu-MM-dd'T'HH:mm:ssXXXXX" + }, + "sentDate": { + "type": "date", + "format": "uuuu-MM-dd'T'HH:mm:ssX||uuuu-MM-dd'T'HH:mm:ssXXX||uuuu-MM-dd'T'HH:mm:ssXXXXX" + }, + "size": { + "type": "long" + }, + "subject": { + "type": "text", + "fields": { + "raw": { + "type": "keyword" + } + } + }, + "subtype": { + "type": "keyword" + }, + "textBody": { + "type": "text", + "analyzer": "standard" + }, + "threadId": { + "type": "keyword" + }, + "to": { + "properties": { + "address": { + "type": "text", + "fields": { + "raw": { + "type": "keyword" + } + }, + "analyzer": "standard" + }, + "domain": { + "type": "text", + "analyzer": "simple", + "search_analyzer": "keyword" + }, + "name": { + "type": "text" + } + } + }, + "uid": { + "type": "long", + "store": true + }, + "userFlags": { + "type": "keyword" + } + } + } + } +} diff --git a/packages/tom-server/src/search-engine-api/__testData__/synapse-data/homeserver.yaml b/packages/tom-server/src/search-engine-api/__testData__/synapse-data/homeserver.yaml new file mode 100644 index 00000000..d9cc5250 --- /dev/null +++ b/packages/tom-server/src/search-engine-api/__testData__/synapse-data/homeserver.yaml @@ -0,0 +1,65 @@ +# Configuration file for Synapse. +# +# This is a YAML file: see [1] for a quick introduction. Note in particular +# that *indentation is important*: all the elements of a list or dictionary +# should have the same indentation. +# +# [1] https://docs.ansible.com/ansible/latest/reference_appendices/YAMLSyntax.html +# +# For more information on how to configure Synapse, including a complete accounting of +# each option, go to docs/usage/configuration/config_documentation.md or +# https://matrix-org.github.io/synapse/latest/usage/configuration/config_documentation.html +server_name: "example.com" +public_baseurl: "https://matrix.example.com/" +pid_file: /data/homeserve.pid +listeners: + - port: 8008 + tls: false + type: http + x_forwarded: true + resources: + - names: [client, federation] + compress: false +database: + name: psycopg2 + args: + user: synapse + password: 'synapse!1' + database: synapse + host: postgresql + cp_min: 2 + cp_max: 4 + keepalives_idle: 10 + keepalives_interval: 10 + keepalives_count: 3 +log_config: "/data/matrix.example.com.log.config" +media_store_path: /data/media_store +registration_shared_secret: "u+Q^i6&*Y9azZ*~pID^.a=qrvd+mUIBX9SAreEPGJ=xzP&c+Sk" +report_stats: false +macaroon_secret_key: "=0ws-1~ztzXm&xh+As;7YL5.-U~r-T,F4zR3mW#E;6Y::Rb7&G" +form_secret: "&YFO.XSc*2^2ZsW#hmoR+t:wf03~u#fin#O.R&erFcl9_mEayv" +signing_key_path: "/data/matrix.example.com.signing.key" +trusted_key_servers: + - server_name: "matrix.org" + accept_keys_insecurely: true +accept_keys_insecurely: true +app_service_config_files: + - /data/registration.yaml +oidc_config: + idp_id: lemonldap + idp_name: lemonldap + enabled: true + issuer: "https://auth.example.com/" + client_id: "matrix1" + client_secret: "matrix1*" + scopes: ["openid", "profile"] + discover: true + user_profile_method: "userinfo_endpoint" + user_mapping_provider: + config: + subject_claim: "sub" + localpart_template: "{{ user.preferred_username }}" + display_name_template: "{{ user.name }}" +rc_message: + per_second: 0.5 + burst_count: 20 \ No newline at end of file diff --git a/packages/tom-server/src/search-engine-api/__testData__/synapse-data/matrix.example.com.log.config b/packages/tom-server/src/search-engine-api/__testData__/synapse-data/matrix.example.com.log.config new file mode 100644 index 00000000..3e1efccc --- /dev/null +++ b/packages/tom-server/src/search-engine-api/__testData__/synapse-data/matrix.example.com.log.config @@ -0,0 +1,77 @@ +# Log configuration for Synapse. +# +# This is a YAML file containing a standard Python logging configuration +# dictionary. See [1] for details on the valid settings. +# +# Synapse also supports structured logging for machine readable logs which can +# be ingested by ELK stacks. See [2] for details. +# +# [1]: https://docs.python.org/3/library/logging.config.html#configuration-dictionary-schema +# [2]: https://matrix-org.github.io/synapse/latest/structured_logging.html + +version: 1 + +formatters: + precise: + format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s' + +handlers: + file: + class: logging.handlers.TimedRotatingFileHandler + formatter: precise + filename: /data/homeserver.log + when: midnight + backupCount: 3 # Does not include the current log file. + encoding: utf8 + + # Default to buffering writes to log file for efficiency. + # WARNING/ERROR logs will still be flushed immediately, but there will be a + # delay (of up to `period` seconds, or until the buffer is full with + # `capacity` messages) before INFO/DEBUG logs get written. + buffer: + class: synapse.logging.handlers.PeriodicallyFlushingMemoryHandler + target: file + + # The capacity is the maximum number of log lines that are buffered + # before being written to disk. Increasing this will lead to better + # performance, at the expensive of it taking longer for log lines to + # be written to disk. + # This parameter is required. + capacity: 10 + + # Logs with a level at or above the flush level will cause the buffer to + # be flushed immediately. + # Default value: 40 (ERROR) + # Other values: 50 (CRITICAL), 30 (WARNING), 20 (INFO), 10 (DEBUG) + flushLevel: 30 # Flush immediately for WARNING logs and higher + + # The period of time, in seconds, between forced flushes. + # Messages will not be delayed for longer than this time. + # Default value: 5 seconds + period: 5 + + # A handler that writes logs to stderr. Unused by default, but can be used + # instead of "buffer" and "file" in the logger handlers. + console: + class: logging.StreamHandler + formatter: precise + +loggers: + synapse.storage.SQL: + # beware: increasing this to DEBUG will make synapse log sensitive + # information such as access tokens. + level: INFO + +root: + level: INFO + + # Write logs to the `buffer` handler, which will buffer them together in memory, + # then write them to a file. + # + # Replace "buffer" with "console" to log to stderr instead. (Note that you'll + # also need to update the configuration for the `twisted` logger above, in + # this case.) + # + handlers: [buffer] + +disable_existing_loggers: false diff --git a/packages/tom-server/src/search-engine-api/__testData__/synapse-data/registration.yaml b/packages/tom-server/src/search-engine-api/__testData__/synapse-data/registration.yaml new file mode 100644 index 00000000..7335edd5 --- /dev/null +++ b/packages/tom-server/src/search-engine-api/__testData__/synapse-data/registration.yaml @@ -0,0 +1,10 @@ +id: 'test' +hs_token: 'hsTokenTestwdakZQunWWNe3DZitAerw9aNqJ2a6HVp0sJtg7qTJWXcHnBjgN0NL' +as_token: 'asTokenTestwdakZQunWWNe3DZitAerw9aNqJ2a6HVp0sJtg7qTJWXcHnBjgN0NL' +url: 'http://host.docker.internal:3002/' +sender_localpart: 'sender_localpart_test' +namespaces: + rooms: + - exclusive: false + regex: '!.*' +de.sorunome.msc2409.push_ephemeral: true \ No newline at end of file From 60423b31350e22c2ebabe50decc1a9622291007e Mon Sep 17 00:00:00 2001 From: Jordy Cabannes Date: Mon, 4 Mar 2024 09:01:49 +0100 Subject: [PATCH 14/16] test: integration tests with docker containers This closes #11 --- .../src/search-engine-api/tests/index.test.ts | 1582 +++++++++++++++++ 1 file changed, 1582 insertions(+) create mode 100644 packages/tom-server/src/search-engine-api/tests/index.test.ts diff --git a/packages/tom-server/src/search-engine-api/tests/index.test.ts b/packages/tom-server/src/search-engine-api/tests/index.test.ts new file mode 100644 index 00000000..b2340f6a --- /dev/null +++ b/packages/tom-server/src/search-engine-api/tests/index.test.ts @@ -0,0 +1,1582 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { type TwakeLogger } from '@twake/logger' +import dockerComposeV1, { v2 as dockerComposeV2 } from 'docker-compose' +import express from 'express' +import fs from 'fs' +import type * as http from 'http' +import * as fetch from 'node-fetch' +import os from 'os' +import path from 'path' +import supertest, { type Response } from 'supertest' +import { + DockerComposeEnvironment, + GenericContainer, + Wait, + type StartedDockerComposeEnvironment, + type StartedTestContainer +} from 'testcontainers' +import TwakeServer from '../..' +import JEST_PROCESS_ROOT_PATH from '../../../jest.globals' +import { type Config } from '../../types' +import defaultConfig from '../__testData__/config.json' +import tmailData from '../__testData__/opensearch/tmail-data.json' +import tmailMapping from '../__testData__/opensearch/tmail-mapping.json' +import { + EOpenSearchIndexingAction, + type DocumentWithIndexingAction, + type IOpenSearchRepository +} from '../repositories/interfaces/opensearch-repository.interface' +import { OpenSearchRepository } from '../repositories/opensearch.repository' +import { tmailMailsIndex } from '../utils/constantes' + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const syswideCas = require('@small-tech/syswide-cas') + +const pathToTestDataFolder = path.join( + JEST_PROCESS_ROOT_PATH, + 'src', + 'search-engine-api', + '__testData__' +) +const pathToSynapseDataFolder = path.join(pathToTestDataFolder, 'synapse-data') + +jest.unmock('node-fetch') + +describe('Search engine API - Integration tests', () => { + const matrixServer = defaultConfig.matrix_server + let openSearchContainer: GenericContainer + let openSearchStartedContainer: StartedTestContainer + let containerNameSuffix: string + let startedCompose: StartedDockerComposeEnvironment + let tokens: Record = { + askywalker: '', + lskywalker: '', + okenobi: '', + chewbacca: '' + } + + let twakeServer: TwakeServer + let app: express.Application + let expressTwakeServer: http.Server + let openSearchRepository: IOpenSearchRepository + const { + opensearch_ca_cert_path: osCaCertPath, + opensearch_host: osHost, + opensearch_password: osPwd, + opensearch_user: osUser, + ...testConfig + } = { ...defaultConfig, rate_limiting_window: 600000 } + process.env.OPENSEARCH_CA_CERT_PATH = osCaCertPath + process.env.OPENSEARCH_HOST = osHost + process.env.OPENSEARCH_PASSWORD = osPwd + process.env.OPENSEARCH_USER = osUser + + const addIndexedMailsInOpenSearch = async ( + conf: Config, + logger: TwakeLogger + ): Promise => { + openSearchRepository = new OpenSearchRepository(conf, logger) + await openSearchRepository.createIndex( + tmailMailsIndex, + tmailMapping.mailbox_v2.mappings + ) + await openSearchRepository.indexDocuments({ + [tmailMailsIndex]: tmailData.map((data) => ({ + ...data._source, + action: EOpenSearchIndexingAction.CREATE, + id: data._source.messageId + })) as unknown as DocumentWithIndexingAction[] + }) + } + + const simulationConnection = async ( + username: string, + password: string + ): Promise => { + let response = await fetch.default( + encodeURI( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `https://${matrixServer}/_matrix/client/v3/login` + ) + ) + let body = (await response.json()) as any + const providerId = body.flows[0].identity_providers[0].id + response = await fetch.default( + encodeURI( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `https://${matrixServer}/_matrix/client/r0/login/sso/redirect/${providerId}?redirectUrl=http://localhost:9876` + ), + { + redirect: 'manual' + } + ) + let location = (response.headers.get('location') as string).replace( + 'auth.example.com', + 'auth.example.com:445' + ) + const matrixCookies = response.headers.get('set-cookie') + response = await fetch.default(location) + body = await response.text() + const hiddenInputFieldsWithValue = [ + ...(body as string).matchAll(/ `${matchElt[1]}=${matchElt[2]}&`) + .join('') + const formWithToken = `${hiddenInputFieldsWithValue}user=${username}&password=${password}` + response = await fetch.default(location, { + method: 'POST', + body: new URLSearchParams(formWithToken), + redirect: 'manual' + }) + location = (response.headers.get('location') as string).replace( + 'matrix.example.com', + 'matrix.example.com:445' + ) + response = await fetch.default(location, { + headers: { + cookie: matrixCookies as string + } + }) + body = await response.text() + const loginTokenValue = [ + ...(body as string).matchAll(/loginToken=(\S+?)"/g) + ][0][1] + response = await fetch.default( + encodeURI(`https://${matrixServer}/_matrix/client/v3/login`), + { + method: 'POST', + body: JSON.stringify({ + initial_device_display_name: 'Jest Test Client', + token: loginTokenValue, + type: 'm.login.token' + }) + } + ) + body = (await response.json()) as any + return body.access_token as string + } + + const connectMultipleUsers = async ( + usersCredentials: Array<{ username: string; password: string }> + ): Promise => { + const tokens: string[] = [] + for (let i = 0; i < usersCredentials.length; i++) { + const token = await simulationConnection( + usersCredentials[i].username, + usersCredentials[i].password + ) + tokens.push(token as string) + } + return tokens + } + + const createRoom = async ( + token: string, + invitations: string[] = [], + name?: string, + isDirect?: boolean, + initialState?: Array> + ): Promise => { + if ((isDirect == null || !isDirect) && name == null) { + throw Error('Name must be defined for an undirect room') + } + let requestBody = {} + requestBody = name != null ? { ...requestBody, name } : requestBody + requestBody = + isDirect != null ? { ...requestBody, is_direct: isDirect } : requestBody + requestBody = + invitations != null + ? { ...requestBody, invite: invitations } + : requestBody + requestBody = + initialState != null + ? { ...requestBody, initial_state: initialState } + : requestBody + + const response = await fetch.default( + encodeURI( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `https://${matrixServer}/_matrix/client/v3/createRoom` + ), + { + method: 'POST', + headers: { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + Authorization: `Bearer ${token}` + }, + body: JSON.stringify(requestBody) + } + ) + const responseBody = (await response.json()) as Record + return responseBody.room_id + } + + const joinRoom = async (roomId: string, token: string): Promise => { + await fetch.default( + encodeURI( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `https://${matrixServer}/_matrix/client/v3/join/${roomId}` + ), + { + method: 'POST', + headers: { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + Authorization: `Bearer ${token}` + } + } + ) + } + + const addAvatar = async (userId: string, token: string): Promise => { + await fetch.default( + encodeURI( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `https://${matrixServer}/_matrix/client/v3/profile/${userId}/avatar_url` + ), + { + method: 'PUT', + headers: { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + avatar_url: 'mxc://matrix.org/wefh34uihSDRGhw34' + }) + } + ) + } + + interface IFileInfo { + h: number + mimetype: string + size: number + w: number + 'xyz.amorgan.blurhash': string + } + + interface IFile extends IFileInfo { + thumbnail_url?: string + thumbnail_info: IFileInfo + } + + interface IMessage { + body: string + filename?: string + msgtype: string + url?: string + info?: IFile + } + + const sendMessage = async ( + token: string, + roomId: string, + message: IMessage, + filePath?: string + ): Promise> => { + if (message.msgtype === 'm.image') { + const file = fs.readFileSync(filePath as string) + await fetch.default( + encodeURI( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `https://${matrixServer}/_matrix/media/v3/upload?filename=${message.filename}` + ), + { + method: 'POST', + headers: { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + Authorization: `Bearer ${token}`, + 'content-type': 'image/jpeg' + }, + body: Buffer.from(file).toString('base64') + } + ) + } + + const response = await fetch.default( + encodeURI( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `https://${matrixServer}/_matrix/client/v3/rooms/${roomId}/send/m.room.message/${Math.random()}` + ), + { + method: 'PUT', + headers: { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + Authorization: `Bearer ${token}` + }, + body: JSON.stringify(message) + } + ) + const body = (await response.json()) as Record + return body + } + + beforeAll((done) => { + syswideCas.addCAs(path.join(pathToTestDataFolder, 'nginx', 'ssl', 'ca.pem')) + Promise.allSettled([dockerComposeV1.version(), dockerComposeV2.version()]) + // eslint-disable-next-line @typescript-eslint/promise-function-async + .then((results) => { + const promiseSucceededIndex = results.findIndex( + (res) => res.status === 'fulfilled' + ) + if (promiseSucceededIndex === -1) { + throw new Error('Docker compose is not installed') + } + containerNameSuffix = promiseSucceededIndex === 0 ? '_' : '-' + return new DockerComposeEnvironment( + path.join(pathToTestDataFolder), + 'docker-compose.yml' + ) + .withEnvironment({ MYUID: os.userInfo().uid.toString() }) + .withWaitStrategy( + `postgresql${containerNameSuffix}1`, + Wait.forHealthCheck() + ) + .withWaitStrategy( + `synapse${containerNameSuffix}1`, + Wait.forHealthCheck() + ) + .up() + }) + // eslint-disable-next-line @typescript-eslint/promise-function-async + .then((upResult) => { + startedCompose = upResult + + openSearchContainer = new GenericContainer( + 'opensearchproject/opensearch' + ) + .withHealthCheck({ + test: [ + 'CMD', + 'curl', + 'http://localhost:9200', + '-ku', + "'admin:admin'" + ], + interval: 10000, + timeout: 10000, + retries: 3 + }) + .withNetworkMode( + startedCompose + .getContainer(`nginx-proxy${containerNameSuffix}1`) + .getNetworkNames()[0] + ) + .withEnvironment({ + 'discovery.type': 'single-node', + DISABLE_INSTALL_DEMO_CONFIG: 'true', + DISABLE_SECURITY_PLUGIN: 'true', + VIRTUAL_PORT: '9200', + VIRTUAL_HOST: 'opensearch.example.com' + }) + .withWaitStrategy(Wait.forHealthCheck()) + + return openSearchContainer.start() + }) + // eslint-disable-next-line @typescript-eslint/promise-function-async + .then((startedContainer) => { + openSearchStartedContainer = startedContainer + return startedCompose + .getContainer(`nginx-proxy${containerNameSuffix}1`) + .restart() + }) + // eslint-disable-next-line @typescript-eslint/promise-function-async + .then(() => { + twakeServer = new TwakeServer(testConfig as Config) + app = express() + return twakeServer.ready + }) + // eslint-disable-next-line @typescript-eslint/promise-function-async + .then(() => { + app.use(twakeServer.endpoints) + return new Promise((resolve, reject) => { + expressTwakeServer = app.listen(3002, () => { + resolve() + }) + }) + }) + // eslint-disable-next-line @typescript-eslint/promise-function-async + .then(() => { + return addIndexedMailsInOpenSearch(twakeServer.conf, twakeServer.logger) + }) + // eslint-disable-next-line @typescript-eslint/promise-function-async + .then(() => { + return connectMultipleUsers([ + { username: 'askywalker', password: 'askywalker' }, + { username: 'lskywalker', password: 'lskywalker' }, + { username: 'okenobi', password: 'okenobi' }, + { username: 'chewbacca', password: 'chewbacca' } + ]) + }) + .then((usersTokens) => { + if (usersTokens == null || usersTokens.some((t) => t == null)) { + throw new Error('Error during user authentication') + } + const usersTokensChecked = usersTokens + tokens = { + askywalker: usersTokensChecked[0], + lskywalker: usersTokensChecked[1], + okenobi: usersTokensChecked[2], + chewbacca: usersTokensChecked[3] + } + done() + }) + .catch((e) => { + done(e) + }) + }) + + afterAll((done) => { + const filesToDelete: string[] = [ + path.join(pathToSynapseDataFolder, 'matrix.example.com.signing.key'), + path.join(pathToSynapseDataFolder, 'homeserver.log'), + path.join(pathToSynapseDataFolder, 'media_store') + ] + filesToDelete.forEach((path: string) => { + if (fs.existsSync(path)) { + const isDir = fs.statSync(path).isDirectory() + isDir + ? fs.rmSync(path, { recursive: true, force: true }) + : fs.unlinkSync(path) + } + }) + if (openSearchRepository != null) openSearchRepository.close() + if (twakeServer != null) twakeServer.cleanJobs() + if (expressTwakeServer != null) { + expressTwakeServer.close((err) => { + if (startedCompose != null) { + startedCompose + .down() + .then(() => { + err != null ? done(err) : done() + }) + .catch((e) => { + done(e) + }) + } else if (err != null) { + done(err) + } else { + done() + } + }) + } else { + done() + } + }) + + let roomGroupId1: string + let roomGroupId2: string + let roomGroupId3: string + let roomGroupId4: string + let roomIdDirect: string + let roomIdEncrypted: string + + it('should find rooms matching search', async () => { + await addAvatar('@lskywalker:example.com', tokens.lskywalker) + roomGroupId1 = await createRoom( + tokens.askywalker, + ['@lskywalker:example.com', '@okenobi:example.com'], + 'test skywalkers room', + false, + [ + { + content: { url: 'mxc://linagora.com/IBGFusHnOOzCNfePjaIVHpgR' }, + state_key: '', + type: 'm.room.avatar' + } + ] + ) + await Promise.all( + // eslint-disable-next-line @typescript-eslint/promise-function-async + ['lskywalker', 'okenobi'].map>((uid) => + joinRoom(roomGroupId1, tokens[uid]) + ) + ) + + roomGroupId2 = await createRoom( + tokens.okenobi, + ['@askywalker:example.com', '@lskywalker:example.com'], + 'test okenobi room' + ) + await Promise.all( + // eslint-disable-next-line @typescript-eslint/promise-function-async + ['askywalker', 'lskywalker'].map>((uid) => + joinRoom(roomGroupId2, tokens[uid]) + ) + ) + + roomGroupId3 = await createRoom( + tokens.okenobi, + ['@lskywalker:example.com'], + 'test skywalkers room without Anakin' + ) + await joinRoom(roomGroupId3, tokens.lskywalker) + + roomGroupId4 = await createRoom( + tokens.askywalker, + ['@lskywalker:example.com'], + 'test skywalkers room without avatar' + ) + await joinRoom(roomGroupId4, tokens.lskywalker) + + roomIdDirect = await createRoom( + tokens.lskywalker, + ['@askywalker:example.com'], + undefined, + true + ) + await joinRoom(roomIdDirect, tokens.askywalker) + + roomIdEncrypted = await createRoom( + tokens.askywalker, + ['@lskywalker:example.com', '@okenobi:example.com'], + 'test encrypted skywalkers room', + false, + [ + { + content: { algorithm: 'm.megolm.v1.aes-sha2' }, + type: 'm.room.encryption' + } + ] + ) + await Promise.all( + // eslint-disable-next-line @typescript-eslint/promise-function-async + ['lskywalker', 'okenobi'].map>((uid) => + joinRoom(roomIdEncrypted, tokens[uid]) + ) + ) + + const response = await supertest(app) + .post('/_twake/app/v1/search') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${tokens.askywalker}`) + .send({ + searchValue: 'sky' + }) + expect(response.statusCode).toBe(200) + expect(response.body.rooms).toHaveLength(2) + expect(response.body.messages).toHaveLength(0) + expect(response.body.mails).toHaveLength(0) + expect(response.body.rooms).toEqual( + expect.arrayContaining([ + { + room_id: roomGroupId1, + name: 'test skywalkers room', + avatar_url: 'mxc://linagora.com/IBGFusHnOOzCNfePjaIVHpgR' + }, + { + room_id: roomGroupId4, + name: 'test skywalkers room without avatar', + avatar_url: null + } + ]) + ) + }) + + let msg1EventId: string + let msg2EventId: string + let msg3EventId: string + let msg4EventId: string + let msg5EventId: string + let msg6EventId: string + let msg7EventId: string + let msg8EventId: string + let msg9EventId: string + let msg10EventId: string + let msg11EventId: string + let msg12EventId: string + let msg13EventId: string + let msg14EventId: string + let msg15EventId: string + + it('should find messages matching search', async () => { + msg1EventId = ( + await sendMessage(tokens.askywalker, roomGroupId1, { + body: 'Hello members', + msgtype: 'm.text' + }) + ).event_id + msg2EventId = ( + await sendMessage(tokens.lskywalker, roomGroupId1, { + body: 'Hello others', + msgtype: 'm.text' + }) + ).event_id + msg3EventId = ( + await sendMessage(tokens.okenobi, roomGroupId1, { + body: 'Hello Anakin Skywalker', + msgtype: 'm.text' + }) + ).event_id + + msg4EventId = ( + await sendMessage(tokens.lskywalker, roomIdDirect, { + body: 'Hello Anakin this is Luke, it is a direct message', + msgtype: 'm.text' + }) + ).event_id + msg5EventId = ( + await sendMessage(tokens.askywalker, roomIdDirect, { + body: 'Hey Luke', + msgtype: 'm.text' + }) + ).event_id + msg6EventId = ( + await sendMessage(tokens.lskywalker, roomIdDirect, { + body: 'How are you dad?', + msgtype: 'm.text' + }) + ).event_id + + msg7EventId = ( + await sendMessage(tokens.okenobi, roomGroupId2, { + body: 'Hello this is Obi-Wan, admin of this room', + msgtype: 'm.text' + }) + ).event_id + msg8EventId = ( + await sendMessage(tokens.askywalker, roomGroupId2, { + body: 'Hello master, I will be late', + msgtype: 'm.text' + }) + ).event_id + msg9EventId = ( + await sendMessage(tokens.lskywalker, roomGroupId2, { + body: 'Hye this is Luke', + msgtype: 'm.text' + }) + ).event_id + + msg10EventId = ( + await sendMessage(tokens.okenobi, roomGroupId3, { + body: 'Hello this is Obi-Wan, Anakin is not a member of this room', + msgtype: 'm.text' + }) + ).event_id + msg11EventId = ( + await sendMessage(tokens.lskywalker, roomGroupId3, { + body: 'Hello Obi-Wan, we should invite him', + msgtype: 'm.text' + }) + ).event_id + + msg12EventId = ( + await sendMessage(tokens.lskywalker, roomGroupId4, { + body: 'Hello this is Luke, anakin is a member of this room', + msgtype: 'm.text' + }) + ).event_id + msg13EventId = ( + await sendMessage(tokens.lskywalker, roomGroupId4, { + body: 'But this room does not have avatar', + msgtype: 'm.text' + }) + ).event_id + + msg14EventId = ( + await sendMessage( + tokens.askywalker, + roomGroupId1, + { + body: 'anakin-at-the-office.jpg', + msgtype: 'm.image', + filename: 'anakin-at-the-office.jpg' + }, + path.join(pathToTestDataFolder, 'images', 'anakin-at-the-office.jpg') + ) + ).event_id + msg15EventId = ( + await sendMessage(tokens.askywalker, roomIdEncrypted, { + body: 'Hey this is Anakin, we are in an encrypted room', + msgtype: 'm.text' + }) + ).event_id + await new Promise((resolve, _reject) => { + setTimeout(() => { + resolve() + }, 3000) + }) + + const response = await supertest(app) + .post('/_twake/app/v1/search') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${tokens.askywalker}`) + .send({ + searchValue: 'anak' + }) + expect(response.statusCode).toBe(200) + expect(response.body.rooms).toHaveLength(0) + expect(response.body.messages).toHaveLength(7) + expect(response.body.mails).toHaveLength(0) + expect(response.body.messages).toEqual( + expect.arrayContaining([ + { + room_id: roomGroupId1, + event_id: msg1EventId, + content: 'Hello members', + display_name: 'Anakin Skywalker', + avatar_url: 'mxc://linagora.com/IBGFusHnOOzCNfePjaIVHpgR', + room_name: 'test skywalkers room' + }, + { + room_id: roomGroupId1, + event_id: msg3EventId, + content: 'Hello Anakin Skywalker', + display_name: 'Obi-Wan Kenobi', + avatar_url: 'mxc://linagora.com/IBGFusHnOOzCNfePjaIVHpgR', + room_name: 'test skywalkers room' + }, + { + room_id: roomIdDirect, + event_id: msg4EventId, + content: 'Hello Anakin this is Luke, it is a direct message', + display_name: 'Luke Skywalker', + avatar_url: 'mxc://matrix.org/wefh34uihSDRGhw34', + room_name: null + }, + { + room_id: roomIdDirect, + event_id: msg5EventId, + content: 'Hey Luke', + display_name: 'Anakin Skywalker', + avatar_url: 'mxc://matrix.org/wefh34uihSDRGhw34', + room_name: null + }, + { + room_id: roomGroupId2, + event_id: msg8EventId, + content: 'Hello master, I will be late', + display_name: 'Anakin Skywalker', + avatar_url: null, + room_name: 'test okenobi room' + }, + { + room_id: roomGroupId4, + event_id: msg12EventId, + content: 'Hello this is Luke, anakin is a member of this room', + display_name: 'Luke Skywalker', + avatar_url: null, + room_name: 'test skywalkers room without avatar' + }, + { + room_id: roomGroupId1, + event_id: msg14EventId, + content: 'anakin-at-the-office.jpg', + display_name: 'Anakin Skywalker', + avatar_url: 'mxc://linagora.com/IBGFusHnOOzCNfePjaIVHpgR', + room_name: 'test skywalkers room' + } + ]) + ) + }) + + it('should find mails matching search', async () => { + const response = await supertest(app) + .post('/_twake/app/v1/search') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${tokens.lskywalker}`) + .send({ + searchValue: 'ay' + }) + expect(response.statusCode).toBe(200) + expect(response.body.rooms).toHaveLength(0) + expect(response.body.messages).toHaveLength(0) + expect(response.body.mails).toHaveLength(3) + expect(response.body.mails).toEqual( + expect.arrayContaining([ + { + ...tmailData[0]._source, + id: tmailData[0]._source.messageId + }, + { + ...tmailData[3]._source, + id: tmailData[3]._source.messageId + }, + { + ...tmailData[4]._source, + id: tmailData[4]._source.messageId + } + ]) + ) + }) + + let expectedRooms: Array<{ + room_id: string + name: string + avatar_url: string | null + }> + + let expectedMessages: Array<{ + room_id: string + event_id: string + content: string + display_name: string | null + avatar_url: string | null + room_name: string | null + }> + + const expectedMails = [ + { + ...tmailData[0]._source, + id: tmailData[0]._source.messageId + }, + { + ...tmailData[1]._source, + id: tmailData[1]._source.messageId + }, + { + ...tmailData[3]._source, + id: tmailData[3]._source.messageId + }, + { + ...tmailData[4]._source, + id: tmailData[4]._source.messageId + } + ] + it('should find rooms, messages and mails matching search', async () => { + expectedRooms = [ + { + room_id: roomGroupId1, + name: 'test skywalkers room', + avatar_url: 'mxc://linagora.com/IBGFusHnOOzCNfePjaIVHpgR' + }, + { + room_id: roomGroupId4, + name: 'test skywalkers room without avatar', + avatar_url: null + }, + { + room_id: roomGroupId3, + name: 'test skywalkers room without Anakin', + avatar_url: null + } + ] + + expectedMessages = [ + { + room_id: roomGroupId1, + event_id: msg1EventId, + content: 'Hello members', + display_name: 'Anakin Skywalker', + avatar_url: 'mxc://linagora.com/IBGFusHnOOzCNfePjaIVHpgR', + room_name: 'test skywalkers room' + }, + { + room_id: roomGroupId1, + event_id: msg2EventId, + content: 'Hello others', + display_name: 'Luke Skywalker', + avatar_url: 'mxc://linagora.com/IBGFusHnOOzCNfePjaIVHpgR', + room_name: 'test skywalkers room' + }, + { + room_id: roomGroupId1, + event_id: msg3EventId, + content: 'Hello Anakin Skywalker', + display_name: 'Obi-Wan Kenobi', + avatar_url: 'mxc://linagora.com/IBGFusHnOOzCNfePjaIVHpgR', + room_name: 'test skywalkers room' + }, + { + room_id: roomIdDirect, + event_id: msg4EventId, + content: 'Hello Anakin this is Luke, it is a direct message', + display_name: 'Luke Skywalker', + avatar_url: null, + room_name: null + }, + { + room_id: roomIdDirect, + event_id: msg5EventId, + content: 'Hey Luke', + display_name: 'Anakin Skywalker', + avatar_url: null, + room_name: null + }, + { + room_id: roomIdDirect, + event_id: msg6EventId, + content: 'How are you dad?', + display_name: 'Luke Skywalker', + avatar_url: null, + room_name: null + }, + { + room_id: roomGroupId2, + event_id: msg8EventId, + content: 'Hello master, I will be late', + display_name: 'Anakin Skywalker', + avatar_url: null, + room_name: 'test okenobi room' + }, + { + room_id: roomGroupId2, + event_id: msg9EventId, + content: 'Hye this is Luke', + display_name: 'Luke Skywalker', + avatar_url: null, + room_name: 'test okenobi room' + }, + { + room_id: roomGroupId3, + event_id: msg11EventId, + content: 'Hello Obi-Wan, we should invite him', + display_name: 'Luke Skywalker', + avatar_url: null, + room_name: 'test skywalkers room without Anakin' + }, + { + room_id: roomGroupId4, + event_id: msg12EventId, + content: 'Hello this is Luke, anakin is a member of this room', + display_name: 'Luke Skywalker', + avatar_url: null, + room_name: 'test skywalkers room without avatar' + }, + { + room_id: roomGroupId4, + event_id: msg13EventId, + content: 'But this room does not have avatar', + display_name: 'Luke Skywalker', + avatar_url: null, + room_name: 'test skywalkers room without avatar' + }, + { + room_id: roomGroupId1, + event_id: msg14EventId, + content: 'anakin-at-the-office.jpg', + display_name: 'Anakin Skywalker', + avatar_url: 'mxc://linagora.com/IBGFusHnOOzCNfePjaIVHpgR', + room_name: 'test skywalkers room' + } + ] + + const response = await supertest(app) + .post('/_twake/app/v1/search') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${tokens.lskywalker}`) + .send({ + searchValue: 'skywalker' + }) + expect(response.statusCode).toBe(200) + expect(response.body.rooms).toHaveLength(3) + expect(response.body.messages).toHaveLength(12) + expect(response.body.mails).toHaveLength(4) + expect(response.body.rooms).toEqual(expect.arrayContaining(expectedRooms)) + expect(response.body.messages).toEqual( + expect.arrayContaining(expectedMessages) + ) + expect(response.body.mails).toEqual(expect.arrayContaining(expectedMails)) + }) + + it('should find rooms, messages and mails matching search after opensearch container and ToM server restart', async () => { + if (openSearchRepository != null) openSearchRepository.close() + if (twakeServer != null) twakeServer.cleanJobs() + await new Promise((resolve, reject) => { + expressTwakeServer.close((err) => { + if (err != null) { + reject(err) + } + resolve() + }) + }) + + await openSearchStartedContainer.stop() + await openSearchContainer.start() + + console.info('Server closed. Restarting.') + + await startedCompose + .getContainer(`nginx-proxy${containerNameSuffix}1`) + .restart() + + twakeServer = new TwakeServer(testConfig as Config) + app = express() + await twakeServer.ready + app.use(twakeServer.endpoints) + await new Promise((resolve, reject) => { + expressTwakeServer = app.listen(3002, () => { + resolve() + }) + }) + await addIndexedMailsInOpenSearch(twakeServer.conf, twakeServer.logger) + await new Promise((resolve, reject) => { + setTimeout(() => { + resolve() + }, 3000) + }) + console.info('Server is listening to port 3002.') + + const response = await supertest(app) + .post('/_twake/app/v1/search') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${tokens.lskywalker}`) + .send({ + searchValue: 'skywalker' + }) + expect(response.statusCode).toBe(200) + expect(response.body.rooms).toHaveLength(3) + expect(response.body.messages).toHaveLength(12) + expect(response.body.mails).toHaveLength(4) + expect(response.body.rooms).toEqual(expect.arrayContaining(expectedRooms)) + expect(response.body.messages).toEqual( + expect.arrayContaining(expectedMessages) + ) + expect(response.body.mails).toEqual(expect.arrayContaining(expectedMails)) + }) + + it('should find rooms, messages and mails matching search after manual restore', async () => { + let response = await supertest(app).post( + '/_twake/app/v1/opensearch/restore' + ) + expect(response.statusCode).toBe(204) + await new Promise((resolve, reject) => { + setTimeout(() => { + resolve() + }, 3000) + }) + + response = await supertest(app) + .post('/_twake/app/v1/search') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${tokens.lskywalker}`) + .send({ + searchValue: 'skywalker' + }) + expect(response.statusCode).toBe(200) + expect(response.body.rooms).toHaveLength(3) + expect(response.body.messages).toHaveLength(12) + expect(response.body.mails).toHaveLength(4) + expect(response.body.rooms).toEqual(expect.arrayContaining(expectedRooms)) + expect(response.body.messages).toEqual( + expect.arrayContaining(expectedMessages) + ) + expect(response.body.mails).toEqual(expect.arrayContaining(expectedMails)) + }) + + it('should find rooms matching search after update room name', async () => { + await fetch.default( + encodeURI( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `https://${matrixServer}/_matrix/client/v3/rooms/${roomGroupId1}/state/m.room.name/` + ), + { + method: 'PUT', + headers: { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + Authorization: `Bearer ${tokens.askywalker}` + }, + body: JSON.stringify({ name: 'Skywalkers room updated' }) + } + ) + await new Promise((resolve, reject) => { + setTimeout(() => { + resolve() + }, 3000) + }) + + expectedRooms = [ + { + room_id: roomGroupId1, + name: 'Skywalkers room updated', + avatar_url: 'mxc://linagora.com/IBGFusHnOOzCNfePjaIVHpgR' + }, + { + room_id: roomGroupId4, + name: 'test skywalkers room without avatar', + avatar_url: null + }, + { + room_id: roomGroupId3, + name: 'test skywalkers room without Anakin', + avatar_url: null + } + ] + + expectedMessages = [ + { + room_id: roomGroupId1, + event_id: msg1EventId, + content: 'Hello members', + display_name: 'Anakin Skywalker', + avatar_url: 'mxc://linagora.com/IBGFusHnOOzCNfePjaIVHpgR', + room_name: 'Skywalkers room updated' + }, + { + room_id: roomGroupId1, + event_id: msg2EventId, + content: 'Hello others', + display_name: 'Luke Skywalker', + avatar_url: 'mxc://linagora.com/IBGFusHnOOzCNfePjaIVHpgR', + room_name: 'Skywalkers room updated' + }, + { + room_id: roomGroupId1, + event_id: msg3EventId, + content: 'Hello Anakin Skywalker', + display_name: 'Obi-Wan Kenobi', + avatar_url: 'mxc://linagora.com/IBGFusHnOOzCNfePjaIVHpgR', + room_name: 'Skywalkers room updated' + }, + { + room_id: roomIdDirect, + event_id: msg4EventId, + content: 'Hello Anakin this is Luke, it is a direct message', + display_name: 'Luke Skywalker', + avatar_url: null, + room_name: null + }, + { + room_id: roomIdDirect, + event_id: msg5EventId, + content: 'Hey Luke', + display_name: 'Anakin Skywalker', + avatar_url: null, + room_name: null + }, + { + room_id: roomIdDirect, + event_id: msg6EventId, + content: 'How are you dad?', + display_name: 'Luke Skywalker', + avatar_url: null, + room_name: null + }, + { + room_id: roomGroupId2, + event_id: msg8EventId, + content: 'Hello master, I will be late', + display_name: 'Anakin Skywalker', + avatar_url: null, + room_name: 'test okenobi room' + }, + { + room_id: roomGroupId2, + event_id: msg9EventId, + content: 'Hye this is Luke', + display_name: 'Luke Skywalker', + avatar_url: null, + room_name: 'test okenobi room' + }, + { + room_id: roomGroupId3, + event_id: msg11EventId, + content: 'Hello Obi-Wan, we should invite him', + display_name: 'Luke Skywalker', + avatar_url: null, + room_name: 'test skywalkers room without Anakin' + }, + { + room_id: roomGroupId4, + event_id: msg12EventId, + content: 'Hello this is Luke, anakin is a member of this room', + display_name: 'Luke Skywalker', + avatar_url: null, + room_name: 'test skywalkers room without avatar' + }, + { + room_id: roomGroupId4, + event_id: msg13EventId, + content: 'But this room does not have avatar', + display_name: 'Luke Skywalker', + avatar_url: null, + room_name: 'test skywalkers room without avatar' + }, + { + room_id: roomGroupId1, + event_id: msg14EventId, + content: 'anakin-at-the-office.jpg', + display_name: 'Anakin Skywalker', + avatar_url: 'mxc://linagora.com/IBGFusHnOOzCNfePjaIVHpgR', + room_name: 'Skywalkers room updated' + } + ] + + const response = await supertest(app) + .post('/_twake/app/v1/search') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${tokens.lskywalker}`) + .send({ + searchValue: 'skywalker' + }) + expect(response.statusCode).toBe(200) + expect(response.body.rooms).toHaveLength(3) + expect(response.body.messages).toHaveLength(12) + expect(response.body.mails).toHaveLength(4) + expect(response.body.rooms).toEqual(expect.arrayContaining(expectedRooms)) + expect(response.body.messages).toEqual( + expect.arrayContaining(expectedMessages) + ) + expect(response.body.mails).toEqual(expect.arrayContaining(expectedMails)) + }) + + it('should find messages matching search after display name update', async () => { + await fetch.default( + encodeURI( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `https://${matrixServer}/_matrix/client/v3/profile/@askywalker:example.com/displayname` + ), + { + method: 'PUT', + headers: { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + Authorization: `Bearer ${tokens.askywalker}` + }, + body: JSON.stringify({ displayname: 'Dark Vador' }) + } + ) + await new Promise((resolve, reject) => { + setTimeout(() => { + resolve() + }, 3000) + }) + + expectedMessages = [ + { + room_id: roomGroupId1, + event_id: msg2EventId, + content: 'Hello others', + display_name: 'Luke Skywalker', + avatar_url: 'mxc://linagora.com/IBGFusHnOOzCNfePjaIVHpgR', + room_name: 'Skywalkers room updated' + }, + { + room_id: roomGroupId1, + event_id: msg3EventId, + content: 'Hello Anakin Skywalker', + display_name: 'Obi-Wan Kenobi', + avatar_url: 'mxc://linagora.com/IBGFusHnOOzCNfePjaIVHpgR', + room_name: 'Skywalkers room updated' + }, + { + room_id: roomIdDirect, + event_id: msg4EventId, + content: 'Hello Anakin this is Luke, it is a direct message', + display_name: 'Luke Skywalker', + avatar_url: null, + room_name: null + }, + { + room_id: roomIdDirect, + event_id: msg6EventId, + content: 'How are you dad?', + display_name: 'Luke Skywalker', + avatar_url: null, + room_name: null + }, + { + room_id: roomGroupId2, + event_id: msg9EventId, + content: 'Hye this is Luke', + display_name: 'Luke Skywalker', + avatar_url: null, + room_name: 'test okenobi room' + }, + { + room_id: roomGroupId3, + event_id: msg11EventId, + content: 'Hello Obi-Wan, we should invite him', + display_name: 'Luke Skywalker', + avatar_url: null, + room_name: 'test skywalkers room without Anakin' + }, + { + room_id: roomGroupId4, + event_id: msg12EventId, + content: 'Hello this is Luke, anakin is a member of this room', + display_name: 'Luke Skywalker', + avatar_url: null, + room_name: 'test skywalkers room without avatar' + }, + { + room_id: roomGroupId4, + event_id: msg13EventId, + content: 'But this room does not have avatar', + display_name: 'Luke Skywalker', + avatar_url: null, + room_name: 'test skywalkers room without avatar' + } + ] + + const response = await supertest(app) + .post('/_twake/app/v1/search') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${tokens.lskywalker}`) + .send({ + searchValue: 'skywalker' + }) + expect(response.statusCode).toBe(200) + expect(response.body.rooms).toHaveLength(3) + expect(response.body.messages).toHaveLength(8) + expect(response.body.mails).toHaveLength(4) + expect(response.body.rooms).toEqual(expect.arrayContaining(expectedRooms)) + expect(response.body.messages).toEqual( + expect.arrayContaining(expectedMessages) + ) + expect(response.body.mails).toEqual(expect.arrayContaining(expectedMails)) + }) + + it("should remove room and room's messages from results after room encryption", async () => { + await fetch.default( + encodeURI( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `https://${matrixServer}/_matrix/client/v3/rooms/${roomGroupId1}/state/m.room.encryption` + ), + { + method: 'PUT', + headers: { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + Authorization: `Bearer ${tokens.askywalker}` + }, + body: JSON.stringify({ algorithm: 'm.megolm.v1.aes-sha2' }) + } + ) + await new Promise((resolve, reject) => { + setTimeout(() => { + resolve() + }, 3000) + }) + + expectedRooms = [ + { + room_id: roomGroupId4, + name: 'test skywalkers room without avatar', + avatar_url: null + }, + { + room_id: roomGroupId3, + name: 'test skywalkers room without Anakin', + avatar_url: null + } + ] + + expectedMessages = [ + { + room_id: roomIdDirect, + event_id: msg4EventId, + content: 'Hello Anakin this is Luke, it is a direct message', + display_name: 'Luke Skywalker', + avatar_url: null, + room_name: null + }, + { + room_id: roomIdDirect, + event_id: msg6EventId, + content: 'How are you dad?', + display_name: 'Luke Skywalker', + avatar_url: null, + room_name: null + }, + { + room_id: roomGroupId2, + event_id: msg9EventId, + content: 'Hye this is Luke', + display_name: 'Luke Skywalker', + avatar_url: null, + room_name: 'test okenobi room' + }, + { + room_id: roomGroupId3, + event_id: msg11EventId, + content: 'Hello Obi-Wan, we should invite him', + display_name: 'Luke Skywalker', + avatar_url: null, + room_name: 'test skywalkers room without Anakin' + }, + { + room_id: roomGroupId4, + event_id: msg12EventId, + content: 'Hello this is Luke, anakin is a member of this room', + display_name: 'Luke Skywalker', + avatar_url: null, + room_name: 'test skywalkers room without avatar' + }, + { + room_id: roomGroupId4, + event_id: msg13EventId, + content: 'But this room does not have avatar', + display_name: 'Luke Skywalker', + avatar_url: null, + room_name: 'test skywalkers room without avatar' + } + ] + + const response = await supertest(app) + .post('/_twake/app/v1/search') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${tokens.lskywalker}`) + .send({ + searchValue: 'skywalker' + }) + expect(response.statusCode).toBe(200) + expect(response.body.rooms).toHaveLength(2) + expect(response.body.messages).toHaveLength(6) + expect(response.body.mails).toHaveLength(4) + expect(response.body.rooms).toEqual(expect.arrayContaining(expectedRooms)) + expect(response.body.messages).toEqual( + expect.arrayContaining(expectedMessages) + ) + expect(response.body.mails).toEqual(expect.arrayContaining(expectedMails)) + }) + + it('should remove message from results after deleting message', async () => { + await fetch.default( + encodeURI( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `https://${matrixServer}/_matrix/client/v3/rooms/${roomGroupId4}/redact/${msg12EventId}/123` + ), + { + method: 'PUT', + headers: { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + Authorization: `Bearer ${tokens.lskywalker}` + }, + body: JSON.stringify({ reason: 'Message content is invalid' }) + } + ) + await new Promise((resolve, reject) => { + setTimeout(() => { + resolve() + }, 3000) + }) + + expectedMessages = [ + { + room_id: roomIdDirect, + event_id: msg4EventId, + content: 'Hello Anakin this is Luke, it is a direct message', + display_name: 'Luke Skywalker', + avatar_url: null, + room_name: null + }, + { + room_id: roomIdDirect, + event_id: msg6EventId, + content: 'How are you dad?', + display_name: 'Luke Skywalker', + avatar_url: null, + room_name: null + }, + { + room_id: roomGroupId2, + event_id: msg9EventId, + content: 'Hye this is Luke', + display_name: 'Luke Skywalker', + avatar_url: null, + room_name: 'test okenobi room' + }, + { + room_id: roomGroupId3, + event_id: msg11EventId, + content: 'Hello Obi-Wan, we should invite him', + display_name: 'Luke Skywalker', + avatar_url: null, + room_name: 'test skywalkers room without Anakin' + }, + { + room_id: roomGroupId4, + event_id: msg13EventId, + content: 'But this room does not have avatar', + display_name: 'Luke Skywalker', + avatar_url: null, + room_name: 'test skywalkers room without avatar' + } + ] + + const response = await supertest(app) + .post('/_twake/app/v1/search') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${tokens.lskywalker}`) + .send({ + searchValue: 'skywalker' + }) + expect(response.statusCode).toBe(200) + expect(response.body.rooms).toHaveLength(2) + expect(response.body.messages).toHaveLength(5) + expect(response.body.mails).toHaveLength(4) + expect(response.body.rooms).toEqual(expect.arrayContaining(expectedRooms)) + expect(response.body.messages).toEqual( + expect.arrayContaining(expectedMessages) + ) + expect(response.body.mails).toEqual(expect.arrayContaining(expectedMails)) + }) + + it('results should contain message with correct content after updating message content', async () => { + await fetch.default( + encodeURI( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `https://${matrixServer}/_matrix/client/v3/rooms/${roomGroupId4}/send/m.room.message/124` + ), + { + method: 'PUT', + headers: { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + Authorization: `Bearer ${tokens.lskywalker}` + }, + body: JSON.stringify({ + msgtype: 'm.text', + body: ' * But this room does not have avatar unless you add one', + 'm.new_content': { + msgtype: 'm.text', + body: 'But this room does not have avatar unless you add one', + 'm.mentions': {} + }, + 'm.mentions': {}, + 'm.relates_to': { + rel_type: 'm.replace', + event_id: msg13EventId + } + }) + } + ) + await new Promise((resolve, reject) => { + setTimeout(() => { + resolve() + }, 3000) + }) + + expectedMessages[expectedMessages.length - 1] = { + room_id: roomGroupId4, + event_id: msg13EventId, + content: 'But this room does not have avatar unless you add one', + display_name: 'Luke Skywalker', + avatar_url: null, + room_name: 'test skywalkers room without avatar' + } + + const response = await supertest(app) + .post('/_twake/app/v1/search') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${tokens.lskywalker}`) + .send({ + searchValue: 'skywalker' + }) + expect(response.statusCode).toBe(200) + expect(response.body.rooms).toHaveLength(2) + expect(response.body.messages).toHaveLength(5) + expect(response.body.mails).toHaveLength(4) + expect(response.body.rooms).toEqual(expect.arrayContaining(expectedRooms)) + expect(response.body.messages).toEqual( + expect.arrayContaining(expectedMessages) + ) + expect(response.body.mails).toEqual(expect.arrayContaining(expectedMails)) + }) + + it('should reject if more than 100 requests are done in less than 10 seconds', async () => { + let response + let token + // eslint-disable-next-line @typescript-eslint/no-for-in-array, @typescript-eslint/no-unused-vars + for (const i in [...Array(101).keys()]) { + token = + Number(i) % 2 === 0 ? `Bearer ${tokens.lskywalker}` : 'falsy_token' + response = await supertest(app) + .post('/_twake/app/v1/search') + .set('Accept', 'application/json') + .set('Authorization', token) + .send({ + searchValue: 'skywalker' + }) + } + expect((response as Response).statusCode).toEqual(429) + await new Promise((resolve) => setTimeout(resolve, 11000)) + }) +}) From 30967d68d51b23fb1821e2742ce90491867c09a6 Mon Sep 17 00:00:00 2001 From: Jordy Cabannes Date: Mon, 4 Mar 2024 09:04:59 +0100 Subject: [PATCH 15/16] chore: update package-lock.json --- package-lock.json | 365 ++++++++++++++++++++++++++++++---------------- 1 file changed, 242 insertions(+), 123 deletions(-) diff --git a/package-lock.json b/package-lock.json index e0fbaf7b..80b41e88 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,7 +59,7 @@ "supertest": "^6.3.3", "swagger-jsdoc": "^6.2.8", "swagger-ui-dist": "^4.18.3", - "testcontainers": "^9.8.0", + "testcontainers": "^10.6.0", "toad-cache": "^3.3.0", "ts-jest": "^29.1.0", "typescript": "^4.9.5" @@ -4446,6 +4446,27 @@ "@octokit/openapi-types": "^18.0.0" } }, + "node_modules/@opensearch-project/opensearch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@opensearch-project/opensearch/-/opensearch-2.7.0.tgz", + "integrity": "sha512-ee4XEU0CSwbThGgKcROmQPwG48QjMaMJzJdgUaGqeIeni7YMJqlZ6g4pbPD7iDE19Y1e2/OEzeW54DE/Fyky2g==", + "dependencies": { + "aws4": "^1.11.0", + "debug": "^4.3.1", + "hpagent": "^1.2.0", + "ms": "^2.1.3", + "secure-json-parse": "^2.4.0" + }, + "engines": { + "node": ">=10", + "yarn": "^1.22.10" + } + }, + "node_modules/@opensearch-project/opensearch/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, "node_modules/@parcel/watcher": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.0.4.tgz", @@ -4959,9 +4980,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.16.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.16.2.tgz", - "integrity": "sha512-VGodkwtEuZ+ENPz/CpDSl091koMv8ao5jHVMbG1vNK+sbx/48/wVzP84M5xSfDAC69mAKKoEkSo+ym9bXYRK9w==", + "version": "4.16.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.16.3.tgz", + "integrity": "sha512-1ACInKIT0pXmTYuPoJAL8sOT0lV3PEACFSVxnD03hGIojJ1CmbzZmLJyk2xew+yxqTlmx7xydkiJcBzdp0V+AQ==", "cpu": [ "arm" ], @@ -4972,9 +4993,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.16.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.16.2.tgz", - "integrity": "sha512-5/W1xyIdc7jw6c/f1KEtg1vYDBWnWCsLiipK41NiaWGLG93eH2edgE6EgQJ3AGiPERhiOLUqlDSfjRK08C9xFg==", + "version": "4.16.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.16.3.tgz", + "integrity": "sha512-vGl+Bny8cawCM7ExugzqEB8ke3t7Pm9/mo+ciA9kJh6pMuNyM+31qhewMwHwseDZ/LtdW0SCocW1CsMxcq1Lsg==", "cpu": [ "arm64" ], @@ -4985,9 +5006,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.16.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.16.2.tgz", - "integrity": "sha512-vOAKMqZSTbPfyPVu1jBiy+YniIQd3MG7LUnqV0dA6Q5tyhdqYtxacTHP1+S/ksKl6qCtMG1qQ0grcIgk/19JEA==", + "version": "4.16.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.16.3.tgz", + "integrity": "sha512-Lj8J9WzQRvfWO4GfI+bBkIThUFV1PtI+es/YH/3cwUQ+edXu8Mre0JRJfRrAeRjPiHDPFFZaX51zfgHHEhgRAg==", "cpu": [ "arm64" ], @@ -4998,9 +5019,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.16.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.16.2.tgz", - "integrity": "sha512-aIJVRUS3Dnj6MqocBMrcXlatKm64O3ITeQAdAxVSE9swyhNyV1dwnRgw7IGKIkDQofatd8UqMSyUxuFEa42EcA==", + "version": "4.16.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.16.3.tgz", + "integrity": "sha512-NPPOXMTIWJk50lgZmRReEYJFvLG5rgMDzaVauWNB2MgFQYm9HuNXQdVVg3iEZ3A5StIzxhMlPjVyS5fsv4PJmg==", "cpu": [ "x64" ], @@ -5011,9 +5032,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.16.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.16.2.tgz", - "integrity": "sha512-/bjfUiXwy3P5vYr6/ezv//Yle2Y0ak3a+Av/BKoi76nFryjWCkki8AuVoPR7ZU/ckcvAWFo77OnFK14B9B5JsA==", + "version": "4.16.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.16.3.tgz", + "integrity": "sha512-ij4tv1XtWcDScaTgoMnvDEYZ2Wjl2ZhDFEyftjBKu6sNNLHIkKuXBol/bVSh+md5zSJ6em9hUXyPO3cVPCsl4Q==", "cpu": [ "arm" ], @@ -5024,9 +5045,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.16.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.16.2.tgz", - "integrity": "sha512-S24b+tJHwpq2TNRz9T+r71FjMvyBBApY8EkYxz8Cwi/rhH6h+lu/iDUxyc9PuHf9UvyeBFYkWWcrDahai/NCGw==", + "version": "4.16.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.16.3.tgz", + "integrity": "sha512-MTMAl30dzcfYB+smHe1sJuS2P1/hB8pqylkCe0/8/Lo8CADjy/eM8x43nBoR5eqcYgpOtCh7IgHpvqSMAE38xw==", "cpu": [ "arm" ], @@ -5037,9 +5058,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.16.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.16.2.tgz", - "integrity": "sha512-UN7VAXLyeyGbCQWiOtQN7BqmjTDw1ON2Oos4lfk0YR7yNhFEJWZiwGtvj9Ay4lsT/ueT04sh80Sg2MlWVVZ+Ug==", + "version": "4.16.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.16.3.tgz", + "integrity": "sha512-vY3fAg6JLDoNh781HHHMPvt8K6RWG3OmEj3xI9BOFSQTD5PNaGKvCB815MyGlDnFYUw7lH+WvvQqoBwLtRDR1A==", "cpu": [ "arm64" ], @@ -5050,9 +5071,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.16.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.16.2.tgz", - "integrity": "sha512-ZBKvz3+rIhQjusKMccuJiPsStCrPOtejCHxTe+yWp3tNnuPWtyCh9QLGPKz6bFNFbwbw28E2T6zDgzJZ05F1JQ==", + "version": "4.16.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.16.3.tgz", + "integrity": "sha512-61SpQGBSb8QkfV/hUYWezlEig4ro55t8NcE5wWmy1bqRsRVHCEDkF534d+Lln/YeLUoSWtJHvvG3bx9lH/S6uA==", "cpu": [ "arm64" ], @@ -5063,9 +5084,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.16.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.16.2.tgz", - "integrity": "sha512-LjMMFiVBRL3wOe095vHAekL4b7nQqf4KZEpdMWd3/W+nIy5o9q/8tlVKiqMbfieDypNXLsxM9fexOxd9Qcklyg==", + "version": "4.16.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.16.3.tgz", + "integrity": "sha512-4XGexJthsNhEEgv/zK4/NnAOjYKoeCsIoT+GkqTY2u3rse0lbJ8ft1bpDCdlkvifsLDL2uwe4fn8PLR4IMTKQQ==", "cpu": [ "ppc64" ], @@ -5076,9 +5097,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.16.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.16.2.tgz", - "integrity": "sha512-ohkPt0lKoCU0s4B6twro2aft+QROPdUiWwOjPNTzwTsBK5w+2+iT9kySdtOdq0gzWJAdiqsV4NFtXOwGZmIsHA==", + "version": "4.16.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.16.3.tgz", + "integrity": "sha512-/pArXjqnEdhbQ1qe4CTTlJ6/GjWGdWNRucKAp4fqKnKf7QC0BES3QEV34ACumHHQ4uEGt4GctF2ISCMRhkli0A==", "cpu": [ "riscv64" ], @@ -5089,9 +5110,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.16.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.16.2.tgz", - "integrity": "sha512-jm2lvLc+/gqXfndlpDw05jKvsl/HKYxUEAt1h5UXcMFVpO4vGpoWmJVUfKDtTqSaHcCNw1his1XjkgR9aort3w==", + "version": "4.16.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.16.3.tgz", + "integrity": "sha512-vu4f3Y8iwjtRfSZdmtP8nC1jmRx1IrRVo2cLQlQfpFZ0e2AE9YbPgfIzpuK+i3C4zFETaLLNGezbBns2NuS/uA==", "cpu": [ "s390x" ], @@ -5102,9 +5123,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.16.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.16.2.tgz", - "integrity": "sha512-oc5/SlITI/Vj/qL4UM+lXN7MERpiy1HEOnrE+SegXwzf7WP9bzmZd6+MDljCEZTdSY84CpvUv9Rq7bCaftn1+g==", + "version": "4.16.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.16.3.tgz", + "integrity": "sha512-n4HEgIJulNSmAKT3SYF/1wuzf9od14woSBseNkzur7a+KJIbh2Jb+J9KIsdGt3jJnsLW0BT1Sj6MiwL4Zzku6Q==", "cpu": [ "x64" ], @@ -5115,9 +5136,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.16.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.16.2.tgz", - "integrity": "sha512-/2VWEBG6mKbS2itm7hzPwhIPaxfZh/KLWrYg20pCRLHhNFtF+epLgcBtwy3m07bl/k86Q3PFRAf2cX+VbZbwzQ==", + "version": "4.16.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.16.3.tgz", + "integrity": "sha512-guO/4N1884ig2AzTKPc6qA7OTnFMUEg/X2wiesywRO1eRD7FzHiaiTQQOLFmnUXWj2pgQXIT1g5g3e2RpezXcQ==", "cpu": [ "x64" ], @@ -5128,9 +5149,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.16.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.16.2.tgz", - "integrity": "sha512-Wg7ANh7+hSilF0lG3e/0Oy8GtfTIfEk1327Bw8juZOMOoKmJLs3R+a4JDa/4cHJp2Gs7QfCDTepXXcyFD0ubBg==", + "version": "4.16.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.16.3.tgz", + "integrity": "sha512-+rxD3memdkhGz0NhNqbYHXBoA33MoHBK4uubZjF1IeQv1Psi6tqgsCcC6vwQjxBM1qoCqOQQBy0cgNbbZKnGUg==", "cpu": [ "arm64" ], @@ -5141,9 +5162,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.16.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.16.2.tgz", - "integrity": "sha512-J/jCDKVMWp0Y2ELnTjpQFYUCUWv1Jr+LdFrJVZtdqGyjDo0PHPa7pCamjHvJel6zBFM3doFFqAr7cmXYWBAbfw==", + "version": "4.16.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.16.3.tgz", + "integrity": "sha512-0NxVbLhBXmwANWWbgZY/RdSkeuHEgF+u8Dc0qBowUVBYsR2y2vwVGjKgUcj1wtu3jpjs057io5g9HAPr3Icqjg==", "cpu": [ "ia32" ], @@ -5154,9 +5175,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.16.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.16.2.tgz", - "integrity": "sha512-3nIf+SJMs2ZzrCh+SKNqgLVV9hS/UY0UjT1YU8XQYFGLiUfmHYJ/5trOU1XSvmHjV5gTF/K3DjrWxtyzKKcAHA==", + "version": "4.16.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.16.3.tgz", + "integrity": "sha512-hutnZavtOx/G4uVdgoZz5279By9NVbgmxOmGGgnzUjZYuwp2+NzGq6KXQmHXBWz7W/vottXn38QmKYAdQLa/vQ==", "cpu": [ "x64" ], @@ -5738,15 +5759,6 @@ "@types/estree": "*" } }, - "node_modules/@types/archiver": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-5.3.4.tgz", - "integrity": "sha512-Lj7fLBIMwYFgViVVZHEdExZC3lVYsl+QL0VmdNdIzGZH544jHveYWij6qdnBgJQDnR7pMKliN9z2cPZFEbhyPw==", - "dev": true, - "dependencies": { - "@types/readdir-glob": "*" - } - }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -6195,15 +6207,6 @@ "@types/react": "*" } }, - "node_modules/@types/readdir-glob": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz", - "integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -6945,9 +6948,9 @@ } }, "node_modules/@vanilla-extract/integration/node_modules/rollup": { - "version": "4.16.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.16.2.tgz", - "integrity": "sha512-sxDP0+pya/Yi5ZtptF4p3avI+uWCIf/OdrfdH2Gbv1kWddLKk0U7WE3PmQokhi5JrektxsK3sK8s4hzAmjqahw==", + "version": "4.16.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.16.3.tgz", + "integrity": "sha512-Ygm4fFO4usWcAG3Ud36Lmif5nudoi0X6QPLC+kRgrRjulAbmFkaTawP7fTIkRDnCNSf/4IAQzXM1T8e691kRtw==", "dev": true, "dependencies": { "@types/estree": "1.0.5" @@ -6960,22 +6963,22 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.16.2", - "@rollup/rollup-android-arm64": "4.16.2", - "@rollup/rollup-darwin-arm64": "4.16.2", - "@rollup/rollup-darwin-x64": "4.16.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.16.2", - "@rollup/rollup-linux-arm-musleabihf": "4.16.2", - "@rollup/rollup-linux-arm64-gnu": "4.16.2", - "@rollup/rollup-linux-arm64-musl": "4.16.2", - "@rollup/rollup-linux-powerpc64le-gnu": "4.16.2", - "@rollup/rollup-linux-riscv64-gnu": "4.16.2", - "@rollup/rollup-linux-s390x-gnu": "4.16.2", - "@rollup/rollup-linux-x64-gnu": "4.16.2", - "@rollup/rollup-linux-x64-musl": "4.16.2", - "@rollup/rollup-win32-arm64-msvc": "4.16.2", - "@rollup/rollup-win32-ia32-msvc": "4.16.2", - "@rollup/rollup-win32-x64-msvc": "4.16.2", + "@rollup/rollup-android-arm-eabi": "4.16.3", + "@rollup/rollup-android-arm64": "4.16.3", + "@rollup/rollup-darwin-arm64": "4.16.3", + "@rollup/rollup-darwin-x64": "4.16.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.16.3", + "@rollup/rollup-linux-arm-musleabihf": "4.16.3", + "@rollup/rollup-linux-arm64-gnu": "4.16.3", + "@rollup/rollup-linux-arm64-musl": "4.16.3", + "@rollup/rollup-linux-powerpc64le-gnu": "4.16.3", + "@rollup/rollup-linux-riscv64-gnu": "4.16.3", + "@rollup/rollup-linux-s390x-gnu": "4.16.3", + "@rollup/rollup-linux-x64-gnu": "4.16.3", + "@rollup/rollup-linux-x64-musl": "4.16.3", + "@rollup/rollup-win32-arm64-msvc": "4.16.3", + "@rollup/rollup-win32-ia32-msvc": "4.16.3", + "@rollup/rollup-win32-x64-msvc": "4.16.3", "fsevents": "~2.3.2" } }, @@ -7814,6 +7817,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/aws4": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.12.0.tgz", + "integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==" + }, "node_modules/axe-core": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.7.0.tgz", @@ -7843,6 +7851,12 @@ "dequal": "^2.0.3" } }, + "node_modules/b4a": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.6.tgz", + "integrity": "sha512-5Tk1HLk6b6ctmjIkAcU/Ujv/1WqiDl0F0JdRCR80VsOcUlHcu7pWeWRlOqQLHfDEsVx9YH/aif5AG4ehoCtTmg==", + "dev": true + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -8035,6 +8049,42 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "devOptional": true }, + "node_modules/bare-events": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.2.2.tgz", + "integrity": "sha512-h7z00dWdG0PYOQEvChhOSWvOfkIKsdZGkWr083FgN/HyoQuebSew/cgirYqh9SCuy/hRvxc5Vy6Fw8xAmYHLkQ==", + "dev": true, + "optional": true + }, + "node_modules/bare-fs": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-2.2.3.tgz", + "integrity": "sha512-amG72llr9pstfXOBOHve1WjiuKKAMnebcmMbPWDZ7BCevAoJLpugjuAPRsDINEyjT0a6tbaVx3DctkXIRbLuJw==", + "dev": true, + "optional": true, + "dependencies": { + "bare-events": "^2.0.0", + "bare-path": "^2.0.0", + "streamx": "^2.13.0" + } + }, + "node_modules/bare-os": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-2.2.1.tgz", + "integrity": "sha512-OwPyHgBBMkhC29Hl3O4/YfxW9n7mdTr2+SsO29XBWKKJsbgj3mnorDB80r5TiCQgQstgE5ga1qNYrpes6NvX2w==", + "dev": true, + "optional": true + }, + "node_modules/bare-path": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-2.1.1.tgz", + "integrity": "sha512-OHM+iwRDRMDBsSW7kl3dO62JyHdBKO3B25FB9vNQBPcGHMo4+eA8Yj41Lfbk3pS/seDY+siNge0LdRTulAau/A==", + "dev": true, + "optional": true, + "dependencies": { + "bare-os": "^2.1.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -9595,7 +9645,6 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "devOptional": true, "dependencies": { "ms": "2.1.2" }, @@ -11971,6 +12020,12 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true + }, "node_modules/fast-glob": { "version": "3.2.11", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", @@ -13227,6 +13282,14 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, + "node_modules/hpagent": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hpagent/-/hpagent-1.2.0.tgz", + "integrity": "sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==", + "engines": { + "node": ">=14" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -17935,9 +17998,9 @@ "dev": true }, "node_modules/node-abi": { - "version": "3.60.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.60.0.tgz", - "integrity": "sha512-zcGgwoXbzw9NczqbGzAWL/ToDYAxv1V8gL1D67ClbdkIfeeDBbY0GelZtC25ayLvVjr2q2cloHeQV1R0QAWqRQ==", + "version": "3.61.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.61.0.tgz", + "integrity": "sha512-dYDO1rxzvMXjEMi37PBeFuYgwh3QZpsw/jt+qOmnRSwiV4z4c+OLoRlTa3V8ID4TrkSQpzCVc9OI2sstFaINfQ==", "optional": true, "dependencies": { "semver": "^7.3.5" @@ -21291,6 +21354,12 @@ } ] }, + "node_modules/queue-tick": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", + "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==", + "dev": true + }, "node_modules/quick-lru": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", @@ -22563,6 +22632,11 @@ "loose-envify": "^1.1.0" } }, + "node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==" + }, "node_modules/semver": { "version": "7.6.0", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", @@ -23738,6 +23812,19 @@ "resolved": "https://registry.npmjs.org/stream-slice/-/stream-slice-0.1.2.tgz", "integrity": "sha512-QzQxpoacatkreL6jsxnVb7X5R/pGw9OUv2qWTYWnmLpg4NdN31snPy/f3TdQE1ZUXaThRvj1Zw4/OGg0ZkaLMA==" }, + "node_modules/streamx": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.16.1.tgz", + "integrity": "sha512-m9QYj6WygWyWa3H1YY69amr4nVgy61xfjys7xO7kviL5rfIEc2naf+ewFiOA+aEJD7y0JO3h2GoiUv4TDwEGzQ==", + "dev": true, + "dependencies": { + "fast-fifo": "^1.1.0", + "queue-tick": "^1.0.1" + }, + "optionalDependencies": { + "bare-events": "^2.2.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -24655,30 +24742,26 @@ } }, "node_modules/testcontainers": { - "version": "9.12.0", - "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-9.12.0.tgz", - "integrity": "sha512-zmjLTAUqCiDvhDq7TCwcyhI3m/cXXKGnhyLLJ9pgh53VgG9O+P+opX1pIx28aYTUQ7Yu6b5sJf0xoIuxoiclWg==", + "version": "10.8.2", + "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-10.8.2.tgz", + "integrity": "sha512-9Ink7NUyYZwOjQhk0C6R6basWy2WADNly+md3D9YDap0pcDr3C+vrO8Ah1bkYco/9Zg8VoYTHO+blkLeebBYkA==", "dev": true, "dependencies": { "@balena/dockerignore": "^1.0.2", - "@types/archiver": "^5.3.2", - "@types/dockerode": "^3.3.19", - "archiver": "^5.3.1", - "async-lock": "^1.4.0", + "@types/dockerode": "^3.3.24", + "archiver": "^5.3.2", + "async-lock": "^1.4.1", "byline": "^5.0.0", "debug": "^4.3.4", - "docker-compose": "^0.24.1", + "docker-compose": "^0.24.6", "dockerode": "^3.3.5", "get-port": "^5.1.1", - "node-fetch": "^2.6.12", + "node-fetch": "^2.7.0", "proper-lockfile": "^4.1.2", - "properties-reader": "^2.2.0", + "properties-reader": "^2.3.0", "ssh-remote-port-forward": "^1.0.4", - "tar-fs": "^2.1.1", + "tar-fs": "^3.0.5", "tmp": "^0.2.1" - }, - "engines": { - "node": ">= 10.16" } }, "node_modules/testcontainers/node_modules/node-fetch": { @@ -24701,6 +24784,41 @@ } } }, + "node_modules/testcontainers/node_modules/pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/testcontainers/node_modules/tar-fs": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.5.tgz", + "integrity": "sha512-JOgGAmZyMgbqpLwct7ZV8VzkEB6pxXFBVErLtb+XCOqzc6w1xiWKI9GVd6bwk68EX7eJ4DWmfXVmq8K2ziZTGg==", + "dev": true, + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^2.1.1", + "bare-path": "^2.1.0" + } + }, + "node_modules/testcontainers/node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dev": true, + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, "node_modules/testcontainers/node_modules/tmp": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", @@ -26457,9 +26575,9 @@ } }, "node_modules/vite-node/node_modules/rollup": { - "version": "4.16.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.16.2.tgz", - "integrity": "sha512-sxDP0+pya/Yi5ZtptF4p3avI+uWCIf/OdrfdH2Gbv1kWddLKk0U7WE3PmQokhi5JrektxsK3sK8s4hzAmjqahw==", + "version": "4.16.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.16.3.tgz", + "integrity": "sha512-Ygm4fFO4usWcAG3Ud36Lmif5nudoi0X6QPLC+kRgrRjulAbmFkaTawP7fTIkRDnCNSf/4IAQzXM1T8e691kRtw==", "dev": true, "dependencies": { "@types/estree": "1.0.5" @@ -26472,22 +26590,22 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.16.2", - "@rollup/rollup-android-arm64": "4.16.2", - "@rollup/rollup-darwin-arm64": "4.16.2", - "@rollup/rollup-darwin-x64": "4.16.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.16.2", - "@rollup/rollup-linux-arm-musleabihf": "4.16.2", - "@rollup/rollup-linux-arm64-gnu": "4.16.2", - "@rollup/rollup-linux-arm64-musl": "4.16.2", - "@rollup/rollup-linux-powerpc64le-gnu": "4.16.2", - "@rollup/rollup-linux-riscv64-gnu": "4.16.2", - "@rollup/rollup-linux-s390x-gnu": "4.16.2", - "@rollup/rollup-linux-x64-gnu": "4.16.2", - "@rollup/rollup-linux-x64-musl": "4.16.2", - "@rollup/rollup-win32-arm64-msvc": "4.16.2", - "@rollup/rollup-win32-ia32-msvc": "4.16.2", - "@rollup/rollup-win32-x64-msvc": "4.16.2", + "@rollup/rollup-android-arm-eabi": "4.16.3", + "@rollup/rollup-android-arm64": "4.16.3", + "@rollup/rollup-darwin-arm64": "4.16.3", + "@rollup/rollup-darwin-x64": "4.16.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.16.3", + "@rollup/rollup-linux-arm-musleabihf": "4.16.3", + "@rollup/rollup-linux-arm64-gnu": "4.16.3", + "@rollup/rollup-linux-arm64-musl": "4.16.3", + "@rollup/rollup-linux-powerpc64le-gnu": "4.16.3", + "@rollup/rollup-linux-riscv64-gnu": "4.16.3", + "@rollup/rollup-linux-s390x-gnu": "4.16.3", + "@rollup/rollup-linux-x64-gnu": "4.16.3", + "@rollup/rollup-linux-x64-musl": "4.16.3", + "@rollup/rollup-win32-arm64-msvc": "4.16.3", + "@rollup/rollup-win32-ia32-msvc": "4.16.3", + "@rollup/rollup-win32-x64-msvc": "4.16.3", "fsevents": "~2.3.2" } }, @@ -27869,6 +27987,7 @@ "version": "0.0.1", "license": "AGPL-3.0-or-later", "dependencies": { + "@opensearch-project/opensearch": "^2.5.0", "@twake/matrix-application-server": "*", "@twake/matrix-identity-server": "*", "lodash": "^4.17.21", From 6f8f844c78c64c192c36d6331a6c66d45e0f1d93 Mon Sep 17 00:00:00 2001 From: Jordy Cabannes Date: Tue, 5 Mar 2024 09:00:23 +0100 Subject: [PATCH 16/16] fix: set explicit test containers names --- .../src/__testData__/docker-compose.yml | 10 +++ packages/federation-server/src/index.test.ts | 82 ++++++------------- .../__testData__/docker-compose.yml | 1 + .../src/application-server/index.test.ts | 23 +----- .../__testData__/docker-compose.yml | 3 + .../src/search-engine-api/tests/index.test.ts | 47 +++-------- 6 files changed, 51 insertions(+), 115 deletions(-) diff --git a/packages/federation-server/src/__testData__/docker-compose.yml b/packages/federation-server/src/__testData__/docker-compose.yml index ddb51bf3..df75879e 100644 --- a/packages/federation-server/src/__testData__/docker-compose.yml +++ b/packages/federation-server/src/__testData__/docker-compose.yml @@ -3,6 +3,7 @@ version: '3.8' services: postgresql: image: postgres:13-bullseye + container_name: postgresql volumes: - ./synapse-data/matrix.example.com.log.config:/data/matrix.example.com.log.config - ./db/init-synapse-db.sh:/docker-entrypoint-initdb.d/init-synapse-db.sh @@ -23,6 +24,7 @@ services: synapse-federation: &synapse_template image: matrixdotorg/synapse:v1.89.0 + container_name: synapse-federation volumes: - ./synapse-data:/data - ./nginx/ssl/ca.pem:/etc/ssl/certs/ca.pem @@ -44,6 +46,7 @@ services: synapse-1: <<: *synapse_template + container_name: synapse-1 environment: - UID=${MYUID} - VIRTUAL_PORT=8008 @@ -52,6 +55,7 @@ services: synapse-2: <<: *synapse_template + container_name: synapse-2 environment: - UID=${MYUID} - VIRTUAL_PORT=8008 @@ -60,6 +64,7 @@ services: synapse-3: <<: *synapse_template + container_name: synapse-3 environment: - UID=${MYUID} - VIRTUAL_PORT=8008 @@ -93,6 +98,7 @@ services: federation-server: image: federation-server + container_name: federation-server build: context: ../../../.. dockerfile: ./packages/federation-server/Dockerfile @@ -117,6 +123,7 @@ services: identity-server-1: &identity-server-template image: identity-server + container_name: identity-server-1 build: context: ../../../.. dockerfile: ./packages/federation-server/src/__testData__/identity-server/Dockerfile @@ -139,6 +146,7 @@ services: identity-server-2: <<: *identity-server-template + container_name: identity-server-2 depends_on: annuaire: condition: service_started @@ -156,6 +164,7 @@ services: identity-server-3: <<: *identity-server-template + container_name: identity-server-3 depends_on: annuaire: condition: service_started @@ -173,6 +182,7 @@ services: nginx-proxy: image: nginxproxy/nginx-proxy + container_name: nginx-proxy ports: - 443:443 volumes: diff --git a/packages/federation-server/src/index.test.ts b/packages/federation-server/src/index.test.ts index d374d670..1e3bd8fd 100644 --- a/packages/federation-server/src/index.test.ts +++ b/packages/federation-server/src/index.test.ts @@ -1,5 +1,4 @@ import { Hash } from '@twake/crypto' -import dockerComposeV1, { v2 as dockerComposeV2 } from 'docker-compose' import express from 'express' import fs from 'fs' import type * as http from 'http' @@ -57,7 +56,6 @@ describe('Federation server', () => { }) describe('Integration tests', () => { - let containerNameSuffix: string let startedCompose: StartedDockerComposeEnvironment let identity1IPAddress: string let identity2IPAddress: string @@ -172,43 +170,18 @@ describe('Federation server', () => { syswideCas.addCAs( path.join(pathToTestDataFolder, 'nginx', 'ssl', 'ca.pem') ) - Promise.allSettled([dockerComposeV1.version(), dockerComposeV2.version()]) - // eslint-disable-next-line @typescript-eslint/promise-function-async - .then((results) => { - const promiseSucceededIndex = results.findIndex( - (res) => res.status === 'fulfilled' - ) - if (promiseSucceededIndex === -1) { - throw new Error('Docker compose is not installed') - } - containerNameSuffix = promiseSucceededIndex === 0 ? '_' : '-' - return new DockerComposeEnvironment( - path.join(pathToTestDataFolder), - 'docker-compose.yml' - ) - .withEnvironment({ MYUID: os.userInfo().uid.toString() }) - .withWaitStrategy( - `postgresql${containerNameSuffix}1`, - Wait.forHealthCheck() - ) - .withWaitStrategy( - `synapse-federation${containerNameSuffix}1`, - Wait.forHealthCheck() - ) - .withWaitStrategy( - `synapse-1${containerNameSuffix}1`, - Wait.forHealthCheck() - ) - .withWaitStrategy( - `synapse-2${containerNameSuffix}1`, - Wait.forHealthCheck() - ) - .withWaitStrategy( - `synapse-3${containerNameSuffix}1`, - Wait.forHealthCheck() - ) - .up() - }) + + new DockerComposeEnvironment( + path.join(pathToTestDataFolder), + 'docker-compose.yml' + ) + .withEnvironment({ MYUID: os.userInfo().uid.toString() }) + .withWaitStrategy('postgresql', Wait.forHealthCheck()) + .withWaitStrategy('synapse-federation', Wait.forHealthCheck()) + .withWaitStrategy('synapse-1', Wait.forHealthCheck()) + .withWaitStrategy('synapse-2', Wait.forHealthCheck()) + .withWaitStrategy('synapse-3', Wait.forHealthCheck()) + .up() // eslint-disable-next-line @typescript-eslint/promise-function-async .then((upResult) => { startedCompose = upResult @@ -275,10 +248,10 @@ describe('Federation server', () => { beforeAll((done) => { identity1IPAddress = startedCompose - .getContainer(`identity-server-1${containerNameSuffix}1`) + .getContainer(`identity-server-1`) .getIpAddress('test') identity2IPAddress = startedCompose - .getContainer(`identity-server-2${containerNameSuffix}1`) + .getContainer(`identity-server-2`) .getIpAddress('test') confOriginalContent = fs.readFileSync( @@ -295,9 +268,8 @@ describe('Federation server', () => { 'utf-8' ) - federationServerContainer = startedCompose.getContainer( - `federation-server${containerNameSuffix}1` - ) + federationServerContainer = + startedCompose.getContainer('federation-server') federationServerContainer .restart() @@ -746,25 +718,19 @@ describe('Federation server', () => { 'Certificates files for federation server has not been created' ) return Promise.all([ - startedCompose - .getContainer(`identity-server-1${containerNameSuffix}1`) - .restart(), - startedCompose - .getContainer(`identity-server-2${containerNameSuffix}1`) - .restart(), - startedCompose - .getContainer(`identity-server-3${containerNameSuffix}1`) - .restart() + startedCompose.getContainer('identity-server-1').restart(), + startedCompose.getContainer('identity-server-2').restart(), + startedCompose.getContainer('identity-server-3').restart() ]) }) // eslint-disable-next-line @typescript-eslint/promise-function-async .then(() => { identity1IPAddress = startedCompose - .getContainer(`identity-server-1${containerNameSuffix}1`) + .getContainer(`identity-server-1`) .getIpAddress('test') identity2IPAddress = startedCompose - .getContainer(`identity-server-2${containerNameSuffix}1`) + .getContainer(`identity-server-2`) .getIpAddress('test') const testConfig: Config = { @@ -776,16 +742,16 @@ describe('Federation server', () => { database_user: 'twake', database_password: 'twake!1', database_host: `${startedCompose - .getContainer(`postgresql${containerNameSuffix}1`) + .getContainer(`postgresql`) .getHost()}:5432`, database_name: 'federation', ldap_base: 'dc=example,dc=com', ldap_uri: `ldap://${startedCompose - .getContainer(`postgresql${containerNameSuffix}1`) + .getContainer(`postgresql`) .getHost()}:389`, matrix_database_engine: 'pg', matrix_database_host: `${startedCompose - .getContainer(`postgresql${containerNameSuffix}1`) + .getContainer(`postgresql`) .getHost()}:5432`, matrix_database_name: 'synapsefederation', matrix_database_user: 'synapse', diff --git a/packages/tom-server/src/application-server/__testData__/docker-compose.yml b/packages/tom-server/src/application-server/__testData__/docker-compose.yml index 818ed188..378cd76a 100644 --- a/packages/tom-server/src/application-server/__testData__/docker-compose.yml +++ b/packages/tom-server/src/application-server/__testData__/docker-compose.yml @@ -3,6 +3,7 @@ version: '3.8' services: synapse: image: matrixdotorg/synapse:v1.89.0 + container_name: synapse-tom-1 volumes: - ./synapse-data:/data - ./nginx/ssl/auth.example.com.crt:/etc/ssl/certs/ca-certificates.crt diff --git a/packages/tom-server/src/application-server/index.test.ts b/packages/tom-server/src/application-server/index.test.ts index 3d138256..99a78fb8 100644 --- a/packages/tom-server/src/application-server/index.test.ts +++ b/packages/tom-server/src/application-server/index.test.ts @@ -1,7 +1,6 @@ import { type TwakeLogger } from '@twake/logger' import { type AppServiceOutput } from '@twake/matrix-application-server/src/utils' import { type DbGetResult } from '@twake/matrix-identity-server' -import dockerComposeV1, { v2 as dockerComposeV2 } from 'docker-compose' import express from 'express' import fs from 'fs' import type * as http from 'http' @@ -218,7 +217,6 @@ describe('ApplicationServer', () => { let appServiceToken: string let newRoomId: string let rSkywalkerMatrixToken: string - let containerNameSuffix: string beforeAll((done) => { syswideCas.addCAs( @@ -241,30 +239,13 @@ describe('ApplicationServer', () => { ).as_token deleteUserDB(testConfig) // eslint-disable-next-line @typescript-eslint/promise-function-async - .then((_) => - Promise.allSettled([ - dockerComposeV1.version(), - dockerComposeV2.version() - ]) - ) - // eslint-disable-next-line @typescript-eslint/promise-function-async - .then((results) => { - const promiseSucceededIndex = results.findIndex( - (res) => res.status === 'fulfilled' - ) - if (promiseSucceededIndex === -1) { - throw new Error('Docker compose is not installed') - } - containerNameSuffix = promiseSucceededIndex === 0 ? '_' : '-' + .then((_) => { return new DockerComposeEnvironment( path.join(pathToTestDataFolder), 'docker-compose.yml' ) .withEnvironment({ MYUID: os.userInfo().uid.toString() }) - .withWaitStrategy( - `synapse${containerNameSuffix}1`, - Wait.forHealthCheck() - ) + .withWaitStrategy('synapse-tom-1', Wait.forHealthCheck()) .up() }) // eslint-disable-next-line @typescript-eslint/promise-function-async diff --git a/packages/tom-server/src/search-engine-api/__testData__/docker-compose.yml b/packages/tom-server/src/search-engine-api/__testData__/docker-compose.yml index 86361d5d..0aee22da 100644 --- a/packages/tom-server/src/search-engine-api/__testData__/docker-compose.yml +++ b/packages/tom-server/src/search-engine-api/__testData__/docker-compose.yml @@ -6,6 +6,7 @@ networks: services: postgresql: image: postgres:13-bullseye + container_name: postgresql-tom volumes: - ./synapse-data/matrix.example.com.log.config:/data/matrix.example.com.log.config - ./db/init-synapse-db.sh:/docker-entrypoint-initdb.d/init-synapse-db.sh @@ -26,6 +27,7 @@ services: synapse: image: matrixdotorg/synapse:v1.89.0 + container_name: synapse-tom volumes: - ./synapse-data:/data - ./nginx/ssl/ca.pem:/etc/ssl/certs/ca.pem @@ -85,6 +87,7 @@ services: nginx-proxy: image: nginxproxy/nginx-proxy + container_name: nginx-proxy-tom ports: - 445:443 volumes: diff --git a/packages/tom-server/src/search-engine-api/tests/index.test.ts b/packages/tom-server/src/search-engine-api/tests/index.test.ts index b2340f6a..1e15a2d3 100644 --- a/packages/tom-server/src/search-engine-api/tests/index.test.ts +++ b/packages/tom-server/src/search-engine-api/tests/index.test.ts @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { type TwakeLogger } from '@twake/logger' -import dockerComposeV1, { v2 as dockerComposeV2 } from 'docker-compose' import express from 'express' import fs from 'fs' import type * as http from 'http' @@ -46,7 +45,6 @@ describe('Search engine API - Integration tests', () => { const matrixServer = defaultConfig.matrix_server let openSearchContainer: GenericContainer let openSearchStartedContainer: StartedTestContainer - let containerNameSuffix: string let startedCompose: StartedDockerComposeEnvironment let tokens: Record = { askywalker: '', @@ -312,31 +310,14 @@ describe('Search engine API - Integration tests', () => { beforeAll((done) => { syswideCas.addCAs(path.join(pathToTestDataFolder, 'nginx', 'ssl', 'ca.pem')) - Promise.allSettled([dockerComposeV1.version(), dockerComposeV2.version()]) - // eslint-disable-next-line @typescript-eslint/promise-function-async - .then((results) => { - const promiseSucceededIndex = results.findIndex( - (res) => res.status === 'fulfilled' - ) - if (promiseSucceededIndex === -1) { - throw new Error('Docker compose is not installed') - } - containerNameSuffix = promiseSucceededIndex === 0 ? '_' : '-' - return new DockerComposeEnvironment( - path.join(pathToTestDataFolder), - 'docker-compose.yml' - ) - .withEnvironment({ MYUID: os.userInfo().uid.toString() }) - .withWaitStrategy( - `postgresql${containerNameSuffix}1`, - Wait.forHealthCheck() - ) - .withWaitStrategy( - `synapse${containerNameSuffix}1`, - Wait.forHealthCheck() - ) - .up() - }) + new DockerComposeEnvironment( + path.join(pathToTestDataFolder), + 'docker-compose.yml' + ) + .withEnvironment({ MYUID: os.userInfo().uid.toString() }) + .withWaitStrategy('postgresql-tom', Wait.forHealthCheck()) + .withWaitStrategy('synapse-tom', Wait.forHealthCheck()) + .up() // eslint-disable-next-line @typescript-eslint/promise-function-async .then((upResult) => { startedCompose = upResult @@ -357,9 +338,7 @@ describe('Search engine API - Integration tests', () => { retries: 3 }) .withNetworkMode( - startedCompose - .getContainer(`nginx-proxy${containerNameSuffix}1`) - .getNetworkNames()[0] + startedCompose.getContainer('nginx-proxy-tom').getNetworkNames()[0] ) .withEnvironment({ 'discovery.type': 'single-node', @@ -375,9 +354,7 @@ describe('Search engine API - Integration tests', () => { // eslint-disable-next-line @typescript-eslint/promise-function-async .then((startedContainer) => { openSearchStartedContainer = startedContainer - return startedCompose - .getContainer(`nginx-proxy${containerNameSuffix}1`) - .restart() + return startedCompose.getContainer('nginx-proxy-tom').restart() }) // eslint-disable-next-line @typescript-eslint/promise-function-async .then(() => { @@ -985,9 +962,7 @@ describe('Search engine API - Integration tests', () => { console.info('Server closed. Restarting.') - await startedCompose - .getContainer(`nginx-proxy${containerNameSuffix}1`) - .restart() + await startedCompose.getContainer('nginx-proxy-tom').restart() twakeServer = new TwakeServer(testConfig as Config) app = express()