2023-12-28 15:04:03 +00:00
|
|
|
import { ComplementHomeServer, createHS, destroyHS } from "./homerunner";
|
|
|
|
import { IAppserviceRegistration, MatrixClient, MembershipEventContent, PowerLevelsEventContent } from "matrix-bot-sdk";
|
|
|
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
|
|
import { BridgeConfig, BridgeConfigRoot } from "../../src/config/Config";
|
|
|
|
import { start } from "../../src/App/BridgeApp";
|
|
|
|
import { RSAKeyPairOptions, generateKeyPair } from "node:crypto";
|
|
|
|
import path from "node:path";
|
2024-11-26 16:04:56 +00:00
|
|
|
import Redis from "ioredis";
|
2023-12-28 15:04:03 +00:00
|
|
|
|
2024-11-26 16:04:56 +00:00
|
|
|
const WAIT_EVENT_TIMEOUT = 20000;
|
2023-12-28 15:04:03 +00:00
|
|
|
export const E2ESetupTestTimeout = 60000;
|
2024-11-26 16:04:56 +00:00
|
|
|
const REDIS_DATABASE_URI = process.env.HOOKSHOT_E2E_REDIS_DB_URI ?? "redis://localhost:6379";
|
2023-12-28 15:04:03 +00:00
|
|
|
|
|
|
|
interface Opts {
|
|
|
|
matrixLocalparts?: string[];
|
|
|
|
config?: Partial<BridgeConfigRoot>,
|
2024-11-26 16:04:56 +00:00
|
|
|
enableE2EE?: boolean,
|
|
|
|
useRedis?: boolean,
|
|
|
|
}
|
|
|
|
|
|
|
|
interface WaitForEventResponse<T extends object = Record<string, unknown>> {
|
|
|
|
roomId: string,
|
|
|
|
data: {
|
|
|
|
sender: string, type: string, state_key?: string, content: T, event_id: string,
|
|
|
|
}
|
2023-12-28 15:04:03 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
export class E2ETestMatrixClient extends MatrixClient {
|
|
|
|
|
|
|
|
public async waitForPowerLevel(
|
|
|
|
roomId: string, expected: Partial<PowerLevelsEventContent>,
|
|
|
|
): Promise<{roomId: string, data: {
|
|
|
|
sender: string, type: string, state_key?: string, content: PowerLevelsEventContent, event_id: string,
|
|
|
|
}}> {
|
|
|
|
return this.waitForEvent('room.event', (eventRoomId: string, eventData: {
|
|
|
|
sender: string, type: string, content: Record<string, unknown>, event_id: string, state_key: string,
|
|
|
|
}) => {
|
|
|
|
if (eventRoomId !== roomId) {
|
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (eventData.type !== "m.room.power_levels") {
|
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (eventData.state_key !== "") {
|
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Check only the keys we care about
|
|
|
|
for (const [key, value] of Object.entries(expected)) {
|
|
|
|
const evValue = eventData.content[key] ?? undefined;
|
|
|
|
const sortOrder = value !== null && typeof value === "object" ? Object.keys(value).sort() : undefined;
|
|
|
|
const jsonLeft = JSON.stringify(evValue, sortOrder);
|
|
|
|
const jsonRight = JSON.stringify(value, sortOrder);
|
|
|
|
if (jsonLeft !== jsonRight) {
|
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
console.info(
|
|
|
|
// eslint-disable-next-line max-len
|
|
|
|
`${eventRoomId} ${eventData.event_id} ${eventData.sender}`
|
|
|
|
);
|
|
|
|
return {roomId: eventRoomId, data: eventData};
|
|
|
|
}, `Timed out waiting for powerlevel from in ${roomId}`)
|
|
|
|
}
|
|
|
|
|
2024-11-26 16:04:56 +00:00
|
|
|
private async innerWaitForRoomEvent<T extends object = Record<string, unknown>>(
|
|
|
|
{eventType, sender, roomId, stateKey, eventId, body}: {eventType: string, sender: string, roomId?: string, stateKey?: string, body?: string, eventId?: string}, expectEncrypted: boolean,
|
|
|
|
): Promise<WaitForEventResponse<T>> {
|
|
|
|
return this.waitForEvent(expectEncrypted ? 'room.decrypted_event' : 'room.event', (eventRoomId: string, eventData: {
|
2023-12-28 15:04:03 +00:00
|
|
|
sender: string, type: string, state_key?: string, content: T, event_id: string,
|
|
|
|
}) => {
|
|
|
|
if (eventData.sender !== sender) {
|
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
if (eventData.type !== eventType) {
|
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
if (roomId && eventRoomId !== roomId) {
|
|
|
|
return undefined;
|
|
|
|
}
|
2024-11-26 16:04:56 +00:00
|
|
|
if (eventId && eventData.event_id !== eventId) {
|
|
|
|
return undefined;
|
|
|
|
}
|
2023-12-28 15:04:03 +00:00
|
|
|
if (stateKey !== undefined && eventData.state_key !== stateKey) {
|
|
|
|
return undefined;
|
|
|
|
}
|
2024-11-26 16:04:56 +00:00
|
|
|
const evtBody = 'body' in eventData.content && eventData.content.body;
|
|
|
|
if (body && body !== evtBody) {
|
2023-12-28 15:04:03 +00:00
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
console.info(
|
|
|
|
// eslint-disable-next-line max-len
|
2024-11-26 16:04:56 +00:00
|
|
|
`${eventRoomId} ${eventData.event_id} ${eventData.type} ${eventData.sender} ${eventData.state_key ?? evtBody ?? ''}`
|
2023-12-28 15:04:03 +00:00
|
|
|
);
|
|
|
|
return {roomId: eventRoomId, data: eventData};
|
|
|
|
}, `Timed out waiting for ${eventType} from ${sender} in ${roomId || "any room"}`)
|
|
|
|
}
|
|
|
|
|
2024-11-26 16:04:56 +00:00
|
|
|
public async waitForRoomEvent<T extends object = Record<string, unknown>>(
|
|
|
|
opts: Parameters<E2ETestMatrixClient["innerWaitForRoomEvent"]>[0]
|
|
|
|
): Promise<WaitForEventResponse<T>> {
|
|
|
|
return this.innerWaitForRoomEvent(opts, false);
|
|
|
|
}
|
|
|
|
|
|
|
|
public async waitForEncryptedEvent<T extends object = Record<string, unknown>>(
|
|
|
|
opts: Parameters<E2ETestMatrixClient["innerWaitForRoomEvent"]>[0]
|
|
|
|
): Promise<WaitForEventResponse<T>> {
|
|
|
|
return this.innerWaitForRoomEvent(opts, true);
|
|
|
|
}
|
|
|
|
|
2023-12-28 15:04:03 +00:00
|
|
|
public async waitForRoomJoin(
|
|
|
|
opts: {sender: string, roomId?: string}
|
|
|
|
): Promise<{roomId: string, data: unknown}> {
|
|
|
|
const {sender, roomId} = opts;
|
|
|
|
return this.waitForEvent('room.event', (eventRoomId: string, eventData: {
|
|
|
|
sender: string,
|
|
|
|
state_key: string,
|
|
|
|
content: MembershipEventContent,
|
|
|
|
}) => {
|
|
|
|
if (eventData.state_key !== sender) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (roomId && eventRoomId !== roomId) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (eventData.content.membership !== "join") {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
return {roomId: eventRoomId, data: eventData};
|
|
|
|
}, `Timed out waiting for join to ${roomId || "any room"} from ${sender}`)
|
|
|
|
}
|
|
|
|
|
|
|
|
public async waitForRoomInvite(
|
|
|
|
opts: {sender: string, roomId?: string}
|
|
|
|
): Promise<{roomId: string, data: unknown}> {
|
|
|
|
const {sender, roomId} = opts;
|
|
|
|
return this.waitForEvent('room.invite', (eventRoomId: string, eventData: {
|
|
|
|
sender: string
|
|
|
|
}) => {
|
|
|
|
if (eventData.sender !== sender) {
|
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
if (roomId && eventRoomId !== roomId) {
|
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
return {roomId: eventRoomId, data: eventData};
|
|
|
|
}, `Timed out waiting for invite to ${roomId || "any room"} from ${sender}`)
|
|
|
|
}
|
|
|
|
|
|
|
|
public async waitForEvent<T>(
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
|
emitterType: string, filterFn: (...args: any[]) => T|undefined, timeoutMsg: string)
|
|
|
|
: Promise<T> {
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
// eslint-disable-next-line prefer-const
|
|
|
|
let timer: NodeJS.Timeout;
|
|
|
|
const fn = (...args: unknown[]) => {
|
|
|
|
const data = filterFn(...args);
|
|
|
|
if (data) {
|
|
|
|
clearTimeout(timer);
|
|
|
|
resolve(data);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
timer = setTimeout(() => {
|
|
|
|
this.removeListener(emitterType, fn);
|
|
|
|
reject(new Error(timeoutMsg));
|
|
|
|
}, WAIT_EVENT_TIMEOUT);
|
|
|
|
this.on(emitterType, fn)
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export class E2ETestEnv {
|
2023-12-29 16:11:08 +00:00
|
|
|
|
|
|
|
static get workerId() {
|
|
|
|
return parseInt(process.env.JEST_WORKER_ID ?? '0');
|
|
|
|
}
|
|
|
|
|
2023-12-28 15:04:03 +00:00
|
|
|
static async createTestEnv(opts: Opts): Promise<E2ETestEnv> {
|
2023-12-29 16:11:08 +00:00
|
|
|
const workerID = this.workerId;
|
2023-12-28 15:04:03 +00:00
|
|
|
const { matrixLocalparts, config: providedConfig } = opts;
|
|
|
|
const keyPromise = new Promise<string>((resolve, reject) => generateKeyPair("rsa", {
|
|
|
|
modulusLength: 4096,
|
|
|
|
privateKeyEncoding: {
|
|
|
|
type: "pkcs8",
|
|
|
|
format: "pem",
|
|
|
|
},
|
|
|
|
publicKeyEncoding: {
|
|
|
|
format: "pem",
|
|
|
|
type: "pkcs1",
|
|
|
|
}
|
|
|
|
} satisfies RSAKeyPairOptions<"pem", "pem">, (err, _, privateKey) => {
|
|
|
|
if (err) { reject(err) } else { resolve(privateKey) }
|
|
|
|
}));
|
|
|
|
|
2024-11-26 16:04:56 +00:00
|
|
|
const dir = await mkdtemp('hookshot-int-test');
|
|
|
|
|
2023-12-28 15:04:03 +00:00
|
|
|
// Configure homeserver and bots
|
2024-11-26 16:04:56 +00:00
|
|
|
const [homeserver, privateKey] = await Promise.all([
|
|
|
|
createHS([...matrixLocalparts || []], workerID, opts.enableE2EE ? path.join(dir, 'client-crypto') : undefined),
|
2023-12-28 15:04:03 +00:00
|
|
|
keyPromise,
|
|
|
|
]);
|
|
|
|
const keyPath = path.join(dir, 'key.pem');
|
|
|
|
await writeFile(keyPath, privateKey, 'utf-8');
|
|
|
|
const webhooksPort = 9500 + workerID;
|
|
|
|
|
2023-12-29 16:11:08 +00:00
|
|
|
if (providedConfig?.widgets) {
|
|
|
|
providedConfig.widgets.openIdOverrides = {
|
|
|
|
'hookshot': homeserver.url,
|
|
|
|
}
|
|
|
|
}
|
2024-01-02 15:52:43 +00:00
|
|
|
|
|
|
|
if (providedConfig?.github) {
|
|
|
|
providedConfig.github.auth.privateKeyFile = keyPath;
|
|
|
|
}
|
|
|
|
|
2024-11-26 16:04:56 +00:00
|
|
|
opts.useRedis = opts.enableE2EE || opts.useRedis;
|
|
|
|
|
|
|
|
let cacheConfig: BridgeConfigRoot["cache"]|undefined;
|
|
|
|
if (opts.useRedis) {
|
|
|
|
cacheConfig = {
|
|
|
|
redisUri: `${REDIS_DATABASE_URI}/${workerID}`,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-12-28 15:04:03 +00:00
|
|
|
const config = new BridgeConfig({
|
|
|
|
bridge: {
|
|
|
|
domain: homeserver.domain,
|
|
|
|
url: homeserver.url,
|
|
|
|
port: homeserver.appPort,
|
|
|
|
bindAddress: '0.0.0.0',
|
|
|
|
},
|
|
|
|
logging: {
|
2024-11-26 16:04:56 +00:00
|
|
|
level: 'debug',
|
2023-12-28 15:04:03 +00:00
|
|
|
},
|
|
|
|
// Always enable webhooks so that hookshot starts.
|
|
|
|
generic: {
|
|
|
|
enabled: true,
|
|
|
|
urlPrefix: `http://localhost:${webhooksPort}/webhook`,
|
|
|
|
},
|
|
|
|
listeners: [{
|
|
|
|
port: webhooksPort,
|
|
|
|
bindAddress: '0.0.0.0',
|
|
|
|
resources: ['webhooks'],
|
|
|
|
}],
|
|
|
|
passFile: keyPath,
|
2024-11-26 16:04:56 +00:00
|
|
|
...(opts.enableE2EE ? {
|
|
|
|
encryption: {
|
|
|
|
storagePath: path.join(dir, 'crypto-store'),
|
|
|
|
}
|
|
|
|
} : undefined),
|
|
|
|
cache: cacheConfig,
|
2023-12-28 15:04:03 +00:00
|
|
|
...providedConfig,
|
|
|
|
});
|
|
|
|
const registration: IAppserviceRegistration = {
|
|
|
|
as_token: homeserver.asToken,
|
|
|
|
hs_token: homeserver.hsToken,
|
|
|
|
sender_localpart: 'hookshot',
|
|
|
|
namespaces: {
|
|
|
|
users: [{
|
|
|
|
regex: `@hookshot:${homeserver.domain}`,
|
|
|
|
exclusive: true,
|
|
|
|
}],
|
|
|
|
rooms: [],
|
|
|
|
aliases: [],
|
2024-11-26 16:04:56 +00:00
|
|
|
},
|
|
|
|
"de.sorunome.msc2409.push_ephemeral": true
|
2023-12-28 15:04:03 +00:00
|
|
|
};
|
|
|
|
const app = await start(config, registration);
|
2024-01-02 15:58:25 +00:00
|
|
|
app.listener.finaliseListeners();
|
2023-12-28 15:04:03 +00:00
|
|
|
|
|
|
|
return new E2ETestEnv(homeserver, app, opts, config, dir);
|
|
|
|
}
|
|
|
|
|
|
|
|
private constructor(
|
|
|
|
public readonly homeserver: ComplementHomeServer,
|
|
|
|
public app: Awaited<ReturnType<typeof start>>,
|
|
|
|
public readonly opts: Opts,
|
|
|
|
private readonly config: BridgeConfig,
|
|
|
|
private readonly dir: string,
|
|
|
|
) { }
|
|
|
|
|
|
|
|
public get botMxid() {
|
|
|
|
return `@hookshot:${this.homeserver.domain}`;
|
|
|
|
}
|
|
|
|
|
|
|
|
public async setUp(): Promise<void> {
|
|
|
|
await this.app.bridgeApp.start();
|
|
|
|
}
|
|
|
|
|
|
|
|
public async tearDown(): Promise<void> {
|
|
|
|
await this.app.bridgeApp.stop();
|
|
|
|
await this.app.listener.stop();
|
|
|
|
await this.app.storage.disconnect?.();
|
2024-11-26 16:04:56 +00:00
|
|
|
|
|
|
|
// Clear the redis DB.
|
|
|
|
if (this.config.cache?.redisUri) {
|
|
|
|
await new Redis(this.config.cache.redisUri).flushdb();
|
|
|
|
}
|
|
|
|
|
2023-12-28 15:04:03 +00:00
|
|
|
this.homeserver.users.forEach(u => u.client.stop());
|
|
|
|
await destroyHS(this.homeserver.id);
|
|
|
|
await rm(this.dir, { recursive: true });
|
|
|
|
}
|
|
|
|
|
|
|
|
public getUser(localpart: string) {
|
|
|
|
const u = this.homeserver.users.find(u => u.userId === `@${localpart}:${this.homeserver.domain}`);
|
|
|
|
if (!u) {
|
|
|
|
throw Error("User missing from test");
|
|
|
|
}
|
|
|
|
return u.client;
|
|
|
|
}
|
|
|
|
}
|