mirror of
https://github.com/matrix-org/matrix-hookshot.git
synced 2025-03-10 21:19:13 +00:00
Add End to End testing (#868)
* Ensure connection state always explicitly states all keys, even if some are undefined. * changelog * Fix type * fix test types * Add support for E2E testing * Add CI job for e2e test * Ensure integration test only runs when regular tests complete * Add homerunner image * Disallow concurrent runs * Add concurrency to other expensive steps * changelog * Fix mq test * Cache rust deps * Drop only * Use a shared key
This commit is contained in:
parent
46d198c2c0
commit
8e115b40ab
5
.github/workflows/docker-hub-latest.yml
vendored
5
.github/workflows/docker-hub-latest.yml
vendored
@ -12,6 +12,11 @@ on:
|
||||
- changelog.d/**'
|
||||
merge_group:
|
||||
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
DOCKER_NAMESPACE: halfshot
|
||||
PLATFORMS: linux/amd64
|
||||
|
4
.github/workflows/docker-hub-release.yml
vendored
4
.github/workflows/docker-hub-release.yml
vendored
@ -10,6 +10,10 @@ env:
|
||||
DOCKER_NAMESPACE: halfshot
|
||||
PLATFORMS: linux/amd64,linux/arm64
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
docker-release:
|
||||
runs-on: ubuntu-latest
|
||||
|
5
.github/workflows/docs-latest.yml
vendored
5
.github/workflows/docs-latest.yml
vendored
@ -4,6 +4,11 @@ on:
|
||||
push:
|
||||
paths-ignore:
|
||||
- changelog.d/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-20.04
|
||||
|
4
.github/workflows/docs-release.yml
vendored
4
.github/workflows/docs-release.yml
vendored
@ -4,6 +4,10 @@ on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-20.04
|
||||
|
83
.github/workflows/main.yml
vendored
83
.github/workflows/main.yml
vendored
@ -13,6 +13,11 @@ on:
|
||||
workflow_dispatch:
|
||||
merge_group:
|
||||
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint-node:
|
||||
runs-on: ubuntu-latest
|
||||
@ -75,5 +80,83 @@ jobs:
|
||||
with:
|
||||
toolchain: stable
|
||||
profile: minimal
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: rust-cache
|
||||
- run: yarn
|
||||
- run: yarn test:cover
|
||||
|
||||
build-homerunner:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
homerunnersha: ${{ steps.gitsha.outputs.sha }}
|
||||
steps:
|
||||
- name: Checkout matrix-org/complement
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
repository: matrix-org/complement
|
||||
- name: Get complement git sha
|
||||
id: gitsha
|
||||
run: echo sha=`git rev-parse --short HEAD` >> "$GITHUB_OUTPUT"
|
||||
- name: Cache homerunner
|
||||
id: cached
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: homerunner
|
||||
key: ${{ runner.os }}-homerunner-${{ steps.gitsha.outputs.sha }}
|
||||
- name: "Set Go Version"
|
||||
if: ${{ steps.cached.outputs.cache-hit != 'true' }}
|
||||
run: |
|
||||
echo "$GOROOT_1_18_X64/bin" >> $GITHUB_PATH
|
||||
echo "~/go/bin" >> $GITHUB_PATH
|
||||
# Build and install homerunner
|
||||
- name: Install Complement Dependencies
|
||||
if: ${{ steps.cached.outputs.cache-hit != 'true' }}
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y libolm3 libolm-dev
|
||||
- name: Build homerunner
|
||||
if: ${{ steps.cached.outputs.cache-hit != 'true' }}
|
||||
run: |
|
||||
go build ./cmd/homerunner
|
||||
|
||||
integration-test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
needs:
|
||||
- test
|
||||
- build-homerunner
|
||||
steps:
|
||||
- name: Install Complement Dependencies
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y libolm3
|
||||
- name: Load cached homerunner bin
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: homerunner
|
||||
key: ${{ runner.os }}-homerunner-${{ needs.build-synapse.outputs.homerunnersha }}
|
||||
fail-on-cache-miss: true # Shouldn't happen, we build this in the needs step.
|
||||
- name: Checkout matrix-hookshot
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
path: matrix-hookshot
|
||||
# Setup node & run tests
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version-file: matrix-hookshot/.node-version
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: matrix-hookshot
|
||||
shared-key: rust-cache
|
||||
- name: Run Homerunner tests
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
HOMERUNNER_SPAWN_HS_TIMEOUT_SECS: 100
|
||||
HOMERUNNER_IMAGE: ghcr.io/element-hq/synapse/complement-synapse:latest
|
||||
NODE_OPTIONS: --dns-result-order ipv4first
|
||||
run: |
|
||||
docker pull $HOMERUNNER_IMAGE
|
||||
cd matrix-hookshot
|
||||
yarn --strict-semver --frozen-lockfile
|
||||
../homerunner &
|
||||
bash -ic 'yarn test:e2e'
|
1
changelog.d/869.misc
Normal file
1
changelog.d/869.misc
Normal file
@ -0,0 +1 @@
|
||||
Integrate end to end testing.
|
21
jest.config.ts
Normal file
21
jest.config.ts
Normal file
@ -0,0 +1,21 @@
|
||||
/**
|
||||
* For a detailed explanation regarding each configuration property, visit:
|
||||
* https://jestjs.io/docs/configuration
|
||||
*/
|
||||
|
||||
import type {Config} from 'jest';
|
||||
|
||||
const config: Config = {
|
||||
// The root directory that Jest should scan for tests and modules within
|
||||
rootDir: "spec",
|
||||
testTimeout: 60000,
|
||||
transform: {
|
||||
'^.+\\.tsx?$': [
|
||||
'ts-jest',
|
||||
{
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
10
package.json
10
package.json
@ -32,7 +32,7 @@
|
||||
"start:matrixsender": "node --require source-map-support/register lib/App/MatrixSenderApp.js",
|
||||
"start:resetcrypto": "node --require source-map-support/register lib/App/ResetCryptoStore.js",
|
||||
"test": "mocha -r ts-node/register tests/init.ts tests/*.ts tests/**/*.ts",
|
||||
"test:e2e": "mocha -r ts-node/register tests/init.ts tests/*.ts tests/**/*.ts",
|
||||
"test:e2e": "yarn node --experimental-vm-modules $(yarn bin jest)",
|
||||
"test:cover": "nyc --reporter=lcov --reporter=text yarn test",
|
||||
"lint": "yarn run lint:js && yarn run lint:rs",
|
||||
"lint:js": "eslint -c .eslintrc.js 'src/**/*.ts' 'tests/**/*.ts' 'web/**/*.ts' 'web/**/*.tsx'",
|
||||
@ -60,7 +60,7 @@
|
||||
"jira-client": "^8.2.2",
|
||||
"markdown-it": "^14.0.0",
|
||||
"matrix-appservice-bridge": "^9.0.1",
|
||||
"matrix-bot-sdk": "npm:@vector-im/matrix-bot-sdk@^0.6.7-element.1",
|
||||
"matrix-bot-sdk": "npm:@vector-im/matrix-bot-sdk@^0.7.0-specific-device-2",
|
||||
"matrix-widget-api": "^1.6.0",
|
||||
"micromatch": "^4.0.5",
|
||||
"mime": "^4.0.1",
|
||||
@ -88,6 +88,7 @@
|
||||
"@types/chai": "^4.2.22",
|
||||
"@types/cors": "^2.8.12",
|
||||
"@types/express": "^4.17.14",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/jira-client": "^7.1.0",
|
||||
"@types/markdown-it": "^13.0.7",
|
||||
"@types/micromatch": "^4.0.1",
|
||||
@ -102,14 +103,17 @@
|
||||
"eslint": "^8.49.0",
|
||||
"eslint-config-preact": "^1.3.0",
|
||||
"eslint-plugin-mocha": "^10.1.0",
|
||||
"homerunner-client": "^1.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"mini.css": "^3.0.1",
|
||||
"mocha": "^10.2.0",
|
||||
"nyc": "^15.1.0",
|
||||
"preact": "^10.5.15",
|
||||
"rimraf": "^5.0.5",
|
||||
"sass": "^1.51.0",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.1.3",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.10"
|
||||
}
|
||||
}
|
||||
|
62
spec/basic.spec.ts
Normal file
62
spec/basic.spec.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { MessageEventContent } from "matrix-bot-sdk";
|
||||
import { E2ESetupTestTimeout, E2ETestEnv } from "./util/e2e-test";
|
||||
import { describe, it, beforeEach, afterEach } from "@jest/globals";
|
||||
import { expect } from "chai";
|
||||
|
||||
describe('Basic test setup', () => {
|
||||
let testEnv: E2ETestEnv;
|
||||
|
||||
beforeEach(async () => {
|
||||
testEnv = await E2ETestEnv.createTestEnv({matrixLocalparts: ['user']});
|
||||
await testEnv.setUp();
|
||||
}, E2ESetupTestTimeout);
|
||||
|
||||
afterEach(() => {
|
||||
return testEnv?.tearDown();
|
||||
});
|
||||
|
||||
it('should be able to invite the bot to a room', async () => {
|
||||
const user = testEnv.getUser('user');
|
||||
const roomId = await user.createRoom({ name: 'Test room', invite:[testEnv.botMxid] });
|
||||
await user.waitForRoomJoin({sender: testEnv.botMxid, roomId });
|
||||
await user.sendText(roomId, "!hookshot help");
|
||||
const msg = await user.waitForRoomEvent<MessageEventContent>({
|
||||
eventType: 'm.room.message', sender: testEnv.botMxid, roomId
|
||||
});
|
||||
// Expect help text.
|
||||
expect(msg.data.content.body).to.include('!hookshot help` - This help text\n');
|
||||
});
|
||||
|
||||
// TODO: Move test to it's own generic connections file.
|
||||
it('should be able to setup a webhook', async () => {
|
||||
const user = testEnv.getUser('user');
|
||||
const testRoomId = await user.createRoom({ name: 'Test room', invite:[testEnv.botMxid] });
|
||||
await user.waitForRoomJoin({sender: testEnv.botMxid, roomId: testRoomId });
|
||||
await user.setUserPowerLevel(testEnv.botMxid, testRoomId, 50);
|
||||
await user.sendText(testRoomId, "!hookshot webhook test-webhook");
|
||||
const inviteResponse = await user.waitForRoomInvite({sender: testEnv.botMxid});
|
||||
await user.waitForRoomEvent<MessageEventContent>({
|
||||
eventType: 'm.room.message', sender: testEnv.botMxid, roomId: testRoomId,
|
||||
body: 'Room configured to bridge webhooks. See admin room for secret url.'
|
||||
});
|
||||
const webhookUrlMessage = user.waitForRoomEvent<MessageEventContent>({
|
||||
eventType: 'm.room.message', sender: testEnv.botMxid, roomId: inviteResponse.roomId
|
||||
});
|
||||
await user.joinRoom(inviteResponse.roomId);
|
||||
const msgData = (await webhookUrlMessage).data.content.body;
|
||||
const webhookUrl = msgData.split('\n')[2];
|
||||
const webhookNotice = user.waitForRoomEvent<MessageEventContent>({
|
||||
eventType: 'm.room.message', sender: testEnv.botMxid, roomId: testRoomId, body: 'Hello world!'
|
||||
});
|
||||
|
||||
// Send a webhook
|
||||
await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({text: 'Hello world!'})
|
||||
});
|
||||
|
||||
// And await the notice.
|
||||
await webhookNotice;
|
||||
});
|
||||
});
|
257
spec/util/e2e-test.ts
Normal file
257
spec/util/e2e-test.ts
Normal file
@ -0,0 +1,257 @@
|
||||
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";
|
||||
|
||||
const WAIT_EVENT_TIMEOUT = 10000;
|
||||
export const E2ESetupTestTimeout = 60000;
|
||||
|
||||
interface Opts {
|
||||
matrixLocalparts?: string[];
|
||||
config?: Partial<BridgeConfigRoot>,
|
||||
}
|
||||
|
||||
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}`)
|
||||
}
|
||||
|
||||
public async waitForRoomEvent<T extends object = Record<string, unknown>>(
|
||||
opts: {eventType: string, sender: string, roomId?: string, stateKey?: string, body?: string}
|
||||
): Promise<{roomId: string, data: {
|
||||
sender: string, type: string, state_key?: string, content: T, event_id: string,
|
||||
}}> {
|
||||
const {eventType, sender, roomId, stateKey} = opts;
|
||||
return this.waitForEvent('room.event', (eventRoomId: string, eventData: {
|
||||
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;
|
||||
}
|
||||
if (stateKey !== undefined && eventData.state_key !== stateKey) {
|
||||
return undefined;
|
||||
}
|
||||
const body = 'body' in eventData.content && eventData.content.body;
|
||||
if (opts.body && body !== opts.body) {
|
||||
return undefined;
|
||||
}
|
||||
console.info(
|
||||
// eslint-disable-next-line max-len
|
||||
`${eventRoomId} ${eventData.event_id} ${eventData.type} ${eventData.sender} ${eventData.state_key ?? body ?? ''}`
|
||||
);
|
||||
return {roomId: eventRoomId, data: eventData};
|
||||
}, `Timed out waiting for ${eventType} from ${sender} in ${roomId || "any room"}`)
|
||||
}
|
||||
|
||||
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 {
|
||||
static async createTestEnv(opts: Opts): Promise<E2ETestEnv> {
|
||||
const workerID = parseInt(process.env.JEST_WORKER_ID ?? '0');
|
||||
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) }
|
||||
}));
|
||||
|
||||
// Configure homeserver and bots
|
||||
const [homeserver, dir, privateKey] = await Promise.all([
|
||||
createHS([...matrixLocalparts || []], workerID),
|
||||
mkdtemp('hookshot-int-test'),
|
||||
keyPromise,
|
||||
]);
|
||||
const keyPath = path.join(dir, 'key.pem');
|
||||
await writeFile(keyPath, privateKey, 'utf-8');
|
||||
const webhooksPort = 9500 + workerID;
|
||||
|
||||
const config = new BridgeConfig({
|
||||
bridge: {
|
||||
domain: homeserver.domain,
|
||||
url: homeserver.url,
|
||||
port: homeserver.appPort,
|
||||
bindAddress: '0.0.0.0',
|
||||
},
|
||||
logging: {
|
||||
level: 'info',
|
||||
},
|
||||
queue: {
|
||||
monolithic: true,
|
||||
},
|
||||
// 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,
|
||||
...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: [],
|
||||
}
|
||||
};
|
||||
const app = await start(config, registration);
|
||||
|
||||
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?.();
|
||||
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;
|
||||
}
|
||||
}
|
134
spec/util/homerunner.ts
Normal file
134
spec/util/homerunner.ts
Normal file
@ -0,0 +1,134 @@
|
||||
import { MatrixClient } from "matrix-bot-sdk";
|
||||
import { createHash, createHmac, randomUUID } from "crypto";
|
||||
import { Homerunner } from "homerunner-client";
|
||||
import { E2ETestMatrixClient } from "./e2e-test";
|
||||
|
||||
const HOMERUNNER_IMAGE = process.env.HOMERUNNER_IMAGE || 'ghcr.io/element-hq/synapse/complement-synapse:latest';
|
||||
export const DEFAULT_REGISTRATION_SHARED_SECRET = (
|
||||
process.env.REGISTRATION_SHARED_SECRET || 'complement'
|
||||
);
|
||||
const COMPLEMENT_HOSTNAME_RUNNING_COMPLEMENT = (
|
||||
process.env.COMPLEMENT_HOSTNAME_RUNNING_COMPLEMENT || "host.docker.internal"
|
||||
);
|
||||
|
||||
const homerunner = new Homerunner.Client();
|
||||
|
||||
export interface ComplementHomeServer {
|
||||
id: string,
|
||||
url: string,
|
||||
domain: string,
|
||||
users: {userId: string, accessToken: string, deviceId: string, client: E2ETestMatrixClient}[]
|
||||
asToken: string,
|
||||
hsToken: string,
|
||||
appPort: number,
|
||||
}
|
||||
|
||||
async function waitForHomerunner() {
|
||||
let attempts = 0;
|
||||
do {
|
||||
attempts++;
|
||||
console.log(`Waiting for homerunner to be ready (${attempts}/100)`);
|
||||
try {
|
||||
await homerunner.health();
|
||||
break;
|
||||
}
|
||||
catch (ex) {
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
} while (attempts < 100)
|
||||
if (attempts === 100) {
|
||||
throw Error('Homerunner was not ready after 100 attempts');
|
||||
}
|
||||
}
|
||||
|
||||
export async function createHS(localparts: string[] = [], workerId: number): Promise<ComplementHomeServer> {
|
||||
await waitForHomerunner();
|
||||
|
||||
const appPort = 49600 + workerId;
|
||||
const blueprint = `hookshot_integration_test_${Date.now()}`;
|
||||
const asToken = randomUUID();
|
||||
const hsToken = randomUUID();
|
||||
const blueprintResponse = await homerunner.create({
|
||||
base_image_uri: HOMERUNNER_IMAGE,
|
||||
blueprint: {
|
||||
Name: blueprint,
|
||||
Homeservers: [{
|
||||
Name: 'hookshot',
|
||||
Users: localparts.map(localpart => ({Localpart: localpart, DisplayName: localpart})),
|
||||
ApplicationServices: [{
|
||||
ID: 'hookshot',
|
||||
URL: `http://${COMPLEMENT_HOSTNAME_RUNNING_COMPLEMENT}:${appPort}`,
|
||||
SenderLocalpart: 'hookshot',
|
||||
RateLimited: false,
|
||||
...{ASToken: asToken,
|
||||
HSToken: hsToken},
|
||||
}]
|
||||
}],
|
||||
}
|
||||
});
|
||||
const [homeserverName, homeserver] = Object.entries(blueprintResponse.homeservers)[0];
|
||||
// Skip AS user.
|
||||
const users = Object.entries(homeserver.AccessTokens)
|
||||
.filter(([_uId, accessToken]) => accessToken !== asToken)
|
||||
.map(([userId, accessToken]) => ({
|
||||
userId: userId,
|
||||
accessToken,
|
||||
deviceId: homeserver.DeviceIDs[userId],
|
||||
client: new E2ETestMatrixClient(homeserver.BaseURL, accessToken),
|
||||
})
|
||||
);
|
||||
|
||||
// Start syncing proactively.
|
||||
await Promise.all(users.map(u => u.client.start()));
|
||||
return {
|
||||
users,
|
||||
id: blueprint,
|
||||
url: homeserver.BaseURL,
|
||||
domain: homeserverName,
|
||||
asToken,
|
||||
appPort,
|
||||
hsToken,
|
||||
};
|
||||
}
|
||||
|
||||
export function destroyHS(
|
||||
id: string
|
||||
): Promise<void> {
|
||||
return homerunner.destroy(id);
|
||||
}
|
||||
|
||||
export async function registerUser(
|
||||
homeserverUrl: string,
|
||||
user: { username: string, admin: boolean },
|
||||
sharedSecret = DEFAULT_REGISTRATION_SHARED_SECRET,
|
||||
): Promise<{mxid: string, client: MatrixClient}> {
|
||||
const registerUrl: string = (() => {
|
||||
const url = new URL(homeserverUrl);
|
||||
url.pathname = '/_synapse/admin/v1/register';
|
||||
return url.toString();
|
||||
})();
|
||||
|
||||
const nonce = await fetch(registerUrl, { method: 'GET' }).then(res => res.json()).then((res) => (res as any).nonce);
|
||||
const password = createHash('sha256')
|
||||
.update(user.username)
|
||||
.update(sharedSecret)
|
||||
.digest('hex');
|
||||
const hmac = createHmac('sha1', sharedSecret)
|
||||
.update(nonce).update("\x00")
|
||||
.update(user.username).update("\x00")
|
||||
.update(password).update("\x00")
|
||||
.update(user.admin ? 'admin' : 'notadmin')
|
||||
.digest('hex');
|
||||
return await fetch(registerUrl, { method: "POST", body: JSON.stringify(
|
||||
{
|
||||
nonce,
|
||||
username: user.username,
|
||||
password,
|
||||
admin: user.admin,
|
||||
mac: hmac,
|
||||
}
|
||||
)}).then(res => res.json()).then(res => ({
|
||||
mxid: (res as {user_id: string}).user_id,
|
||||
client: new MatrixClient(homeserverUrl, (res as {access_token: string}).access_token),
|
||||
})).catch(err => { console.log(err.response.body); throw new Error(`Failed to register user: ${err}`); });
|
||||
}
|
@ -1,12 +1,11 @@
|
||||
import { Bridge } from "../Bridge";
|
||||
|
||||
import { BridgeConfig, parseRegistrationFile } from "../config/Config";
|
||||
import { Webhooks } from "../Webhooks";
|
||||
import { MatrixSender } from "../MatrixSender";
|
||||
import { UserNotificationWatcher } from "../Notifications/UserNotificationWatcher";
|
||||
import { ListenerService } from "../ListenerService";
|
||||
import { Logger, getBridgeVersion } from "matrix-appservice-bridge";
|
||||
import { LogService } from "matrix-bot-sdk";
|
||||
import { IAppserviceRegistration, LogService } from "matrix-bot-sdk";
|
||||
import { getAppservice } from "../appservice";
|
||||
import BotUsersManager from "../Managers/BotUsersManager";
|
||||
import * as Sentry from '@sentry/node';
|
||||
@ -15,11 +14,7 @@ import { GenericHookConnection } from "../Connections";
|
||||
Logger.configure({console: "info"});
|
||||
const log = new Logger("App");
|
||||
|
||||
async function start() {
|
||||
const configFile = process.argv[2] || "./config.yml";
|
||||
const registrationFile = process.argv[3] || "./registration.yml";
|
||||
const config = await BridgeConfig.parseConfig(configFile, process.env);
|
||||
const registration = await parseRegistrationFile(registrationFile);
|
||||
export async function start(config: BridgeConfig, registration: IAppserviceRegistration) {
|
||||
const listener = new ListenerService(config.listeners);
|
||||
listener.start();
|
||||
Logger.configure({
|
||||
@ -65,7 +60,6 @@ async function start() {
|
||||
// Don't care to await this, as the process is about to end
|
||||
storage.disconnect?.();
|
||||
});
|
||||
await bridgeApp.start();
|
||||
|
||||
// XXX: Since the webhook listener listens on /, it must listen AFTER other resources
|
||||
// have bound themselves.
|
||||
@ -73,14 +67,30 @@ async function start() {
|
||||
const webhookHandler = new Webhooks(config);
|
||||
listener.bindResource('webhooks', webhookHandler.expressRouter);
|
||||
}
|
||||
return {
|
||||
bridgeApp,
|
||||
storage,
|
||||
listener,
|
||||
};
|
||||
}
|
||||
|
||||
start().catch((ex) => {
|
||||
if (Logger.root.configured) {
|
||||
log.error("BridgeApp encountered an error and has stopped:", ex);
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("BridgeApp encountered an error and has stopped", ex);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
async function startFromFile() {
|
||||
const configFile = process.argv[2] || "./config.yml";
|
||||
const registrationFile = process.argv[3] || "./registration.yml";
|
||||
const config = await BridgeConfig.parseConfig(configFile, process.env);
|
||||
const registration = await parseRegistrationFile(registrationFile);
|
||||
const { bridgeApp } = await start(config, registration);
|
||||
await bridgeApp.start();
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
startFromFile().catch((ex) => {
|
||||
if (Logger.root.configured) {
|
||||
log.error("BridgeApp encountered an error and has stopped:", ex);
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("BridgeApp encountered an error and has stopped", ex);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
@ -7,7 +7,7 @@ const mq = createMessageQueue({
|
||||
|
||||
describe("MessageQueueTest", () => {
|
||||
describe("LocalMq", () => {
|
||||
it("should be able to push an event, and listen for it", async (done) => {
|
||||
it("should be able to push an event, and listen for it", (done) => {
|
||||
mq.subscribe("fakeevent");
|
||||
mq.on("fakeevent", (msg) => {
|
||||
expect(msg).to.deep.equal({
|
||||
@ -18,7 +18,7 @@ describe("MessageQueueTest", () => {
|
||||
});
|
||||
done();
|
||||
});
|
||||
await mq.push<number>({
|
||||
mq.push<number>({
|
||||
sender: "foo",
|
||||
eventName: "fakeevent",
|
||||
messageId: "foooo",
|
||||
|
Loading…
x
Reference in New Issue
Block a user