@@ -366,28 +366,28 @@ test.describe("Spotlight", () => {
|
||||
await spotlight.search("b");
|
||||
|
||||
let resultLocator = spotlight.results;
|
||||
await expect(resultLocator).toHaveCount(2);
|
||||
await expect(resultLocator.count()).resolves.toBeGreaterThan(2);
|
||||
await expect(resultLocator.first()).toHaveAttribute("aria-selected", "true");
|
||||
await expect(resultLocator.last()).toHaveAttribute("aria-selected", "false");
|
||||
|
||||
await spotlight.searchBox.press("ArrowDown");
|
||||
resultLocator = spotlight.results;
|
||||
await expect(resultLocator.first()).toHaveAttribute("aria-selected", "false");
|
||||
await expect(resultLocator.last()).toHaveAttribute("aria-selected", "true");
|
||||
await expect(resultLocator.nth(2)).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await spotlight.searchBox.press("ArrowDown");
|
||||
resultLocator = spotlight.results;
|
||||
await expect(resultLocator.first()).toHaveAttribute("aria-selected", "false");
|
||||
await expect(resultLocator.last()).toHaveAttribute("aria-selected", "false");
|
||||
await expect(resultLocator.nth(2)).toHaveAttribute("aria-selected", "false");
|
||||
|
||||
await spotlight.searchBox.press("ArrowUp");
|
||||
resultLocator = spotlight.results;
|
||||
await expect(resultLocator.first()).toHaveAttribute("aria-selected", "false");
|
||||
await expect(resultLocator.last()).toHaveAttribute("aria-selected", "true");
|
||||
await expect(resultLocator.nth(2)).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await spotlight.searchBox.press("ArrowUp");
|
||||
resultLocator = spotlight.results;
|
||||
await expect(resultLocator.first()).toHaveAttribute("aria-selected", "true");
|
||||
await expect(resultLocator.last()).toHaveAttribute("aria-selected", "false");
|
||||
await expect(resultLocator.nth(2)).toHaveAttribute("aria-selected", "false");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,6 +116,8 @@ export const test = base.extend<{}, Services>({
|
||||
.withConfig(synapseConfigOptions)
|
||||
.start();
|
||||
|
||||
container.setMatrixAuthenticationService(mas);
|
||||
|
||||
await use(container);
|
||||
await container.stop();
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ import { AbstractStartedContainer, GenericContainer } from "testcontainers";
|
||||
import { APIRequestContext, TestInfo } from "@playwright/test";
|
||||
|
||||
import { HomeserverInstance } from "../plugins/homeserver";
|
||||
import { StartedMatrixAuthenticationServiceContainer } from "./mas.ts";
|
||||
|
||||
export interface HomeserverContainer<Config> extends GenericContainer {
|
||||
withConfigField(key: string, value: any): this;
|
||||
@@ -18,5 +19,6 @@ export interface HomeserverContainer<Config> extends GenericContainer {
|
||||
|
||||
export interface StartedHomeserverContainer extends AbstractStartedContainer, HomeserverInstance {
|
||||
setRequest(request: APIRequestContext): void;
|
||||
setMatrixAuthenticationService(mas?: StartedMatrixAuthenticationServiceContainer): void;
|
||||
onTestFinished(testInfo: TestInfo): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -258,5 +258,5 @@ export class PineconeContainer extends DendriteContainer {
|
||||
}
|
||||
}
|
||||
|
||||
// Surprisingly, Dendrite implements the same register user Admin API Synapse, so we can just extend it
|
||||
// Surprisingly, Dendrite implements the same register user Synapse Admin API, so we can just extend it
|
||||
export class StartedDendriteContainer extends StartedSynapseContainer {}
|
||||
|
||||
@@ -11,6 +11,8 @@ import * as YAML from "yaml";
|
||||
|
||||
import { getFreePort } from "../plugins/utils/port.ts";
|
||||
import { deepCopy } from "../plugins/utils/object.ts";
|
||||
import { Credentials } from "../plugins/homeserver";
|
||||
import { ClientServerApi } from "./utils.ts";
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
http: {
|
||||
@@ -18,18 +20,11 @@ const DEFAULT_CONFIG = {
|
||||
{
|
||||
name: "web",
|
||||
resources: [
|
||||
{
|
||||
name: "discovery",
|
||||
},
|
||||
{
|
||||
name: "human",
|
||||
},
|
||||
{
|
||||
name: "oauth",
|
||||
},
|
||||
{
|
||||
name: "compat",
|
||||
},
|
||||
{ name: "discovery" },
|
||||
{ name: "human" },
|
||||
{ name: "oauth" },
|
||||
{ name: "compat" },
|
||||
{ name: "adminapi" },
|
||||
{
|
||||
name: "graphql",
|
||||
playground: true,
|
||||
@@ -172,9 +167,12 @@ const DEFAULT_CONFIG = {
|
||||
|
||||
export class MatrixAuthenticationServiceContainer extends GenericContainer {
|
||||
private config: typeof DEFAULT_CONFIG;
|
||||
private readonly args = ["-c", "/config/config.yaml"];
|
||||
|
||||
constructor(db: StartedPostgreSqlContainer) {
|
||||
super("ghcr.io/element-hq/matrix-authentication-service:0.12.0");
|
||||
// We rely on `mas-cli manage add-email` which isn't in a release yet
|
||||
// https://github.com/element-hq/matrix-authentication-service/pull/3235
|
||||
super("ghcr.io/element-hq/matrix-authentication-service:sha-0b90c33");
|
||||
|
||||
this.config = deepCopy(DEFAULT_CONFIG);
|
||||
this.config.database.username = db.getUsername();
|
||||
@@ -182,7 +180,7 @@ export class MatrixAuthenticationServiceContainer extends GenericContainer {
|
||||
|
||||
this.withExposedPorts(8080, 8081)
|
||||
.withWaitStrategy(Wait.forHttp("/health", 8081))
|
||||
.withCommand(["server", "--config", "/config/config.yaml"]);
|
||||
.withCommand(["server", ...this.args]);
|
||||
}
|
||||
|
||||
public withConfig(config: object): this {
|
||||
@@ -210,15 +208,78 @@ export class MatrixAuthenticationServiceContainer extends GenericContainer {
|
||||
},
|
||||
]);
|
||||
|
||||
return new StartedMatrixAuthenticationServiceContainer(await super.start(), `http://localhost:${port}`);
|
||||
return new StartedMatrixAuthenticationServiceContainer(
|
||||
await super.start(),
|
||||
`http://localhost:${port}`,
|
||||
this.args,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class StartedMatrixAuthenticationServiceContainer extends AbstractStartedContainer {
|
||||
private adminTokenPromise?: Promise<string>;
|
||||
|
||||
constructor(
|
||||
container: StartedTestContainer,
|
||||
public readonly baseUrl: string,
|
||||
private readonly args: string[],
|
||||
) {
|
||||
super(container);
|
||||
}
|
||||
|
||||
public async getAdminToken(csApi: ClientServerApi): Promise<string> {
|
||||
if (this.adminTokenPromise === undefined) {
|
||||
this.adminTokenPromise = this.registerUserInternal(
|
||||
csApi,
|
||||
"admin",
|
||||
"totalyinsecureadminpassword",
|
||||
undefined,
|
||||
true,
|
||||
).then((res) => res.accessToken);
|
||||
}
|
||||
return this.adminTokenPromise;
|
||||
}
|
||||
|
||||
private async registerUserInternal(
|
||||
csApi: ClientServerApi,
|
||||
username: string,
|
||||
password: string,
|
||||
displayName?: string,
|
||||
admin = false,
|
||||
): Promise<Credentials> {
|
||||
const args: string[] = [];
|
||||
if (admin) args.push("-a");
|
||||
await this.exec([
|
||||
"mas-cli",
|
||||
"manage",
|
||||
"register-user",
|
||||
...this.args,
|
||||
...args,
|
||||
"-y",
|
||||
"-p",
|
||||
password,
|
||||
"-d",
|
||||
displayName ?? "",
|
||||
username,
|
||||
]);
|
||||
|
||||
return csApi.loginUser(username, password);
|
||||
}
|
||||
|
||||
public async registerUser(
|
||||
csApi: ClientServerApi,
|
||||
username: string,
|
||||
password: string,
|
||||
displayName?: string,
|
||||
): Promise<Credentials> {
|
||||
return this.registerUserInternal(csApi, username, password, displayName, false);
|
||||
}
|
||||
|
||||
public async setThreepid(username: string, medium: string, address: string): Promise<void> {
|
||||
if (medium !== "email") {
|
||||
throw new Error("Only email threepids are supported by MAS");
|
||||
}
|
||||
|
||||
await this.exec(["mas-cli", "manage", "add-email", ...this.args, username, address]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import { randB64Bytes } from "../plugins/utils/rand.ts";
|
||||
import { Credentials } from "../plugins/homeserver";
|
||||
import { deepCopy } from "../plugins/utils/object.ts";
|
||||
import { HomeserverContainer, StartedHomeserverContainer } from "./HomeserverContainer.ts";
|
||||
import { StartedMatrixAuthenticationServiceContainer } from "./mas.ts";
|
||||
import { Api, ClientServerApi, Verb } from "./utils.ts";
|
||||
|
||||
const TAG = "develop@sha256:b69222d98abe9625d46f5d3cb01683d5dc173ae339215297138392cfeec935d9";
|
||||
|
||||
@@ -138,8 +140,6 @@ const DEFAULT_CONFIG = {
|
||||
},
|
||||
};
|
||||
|
||||
type Verb = "GET" | "POST" | "PUT" | "DELETE";
|
||||
|
||||
export type SynapseConfigOptions = Partial<typeof DEFAULT_CONFIG>;
|
||||
|
||||
export class SynapseContainer extends GenericContainer implements HomeserverContainer<typeof DEFAULT_CONFIG> {
|
||||
@@ -231,7 +231,10 @@ export class SynapseContainer extends GenericContainer implements HomeserverCont
|
||||
|
||||
export class StartedSynapseContainer extends AbstractStartedContainer implements StartedHomeserverContainer {
|
||||
private adminTokenPromise?: Promise<string>;
|
||||
private _mas?: StartedMatrixAuthenticationServiceContainer;
|
||||
protected _request?: APIRequestContext;
|
||||
protected csApi: ClientServerApi;
|
||||
protected adminApi: Api;
|
||||
|
||||
constructor(
|
||||
container: StartedTestContainer,
|
||||
@@ -239,10 +242,17 @@ export class StartedSynapseContainer extends AbstractStartedContainer implements
|
||||
private readonly registrationSharedSecret: string,
|
||||
) {
|
||||
super(container);
|
||||
this.csApi = new ClientServerApi(this.baseUrl);
|
||||
this.adminApi = new Api(`${this.baseUrl}/_synapse/admin/`);
|
||||
}
|
||||
|
||||
public setRequest(request: APIRequestContext): void {
|
||||
this._request = request;
|
||||
this.csApi.setRequest(request);
|
||||
}
|
||||
|
||||
public setMatrixAuthenticationService(mas?: StartedMatrixAuthenticationServiceContainer): void {
|
||||
this._mas = mas;
|
||||
}
|
||||
|
||||
public async onTestFinished(testInfo: TestInfo): Promise<void> {
|
||||
@@ -251,13 +261,14 @@ export class StartedSynapseContainer extends AbstractStartedContainer implements
|
||||
}
|
||||
|
||||
protected async deletePublicRooms(): Promise<void> {
|
||||
const token = await this.getAdminToken();
|
||||
// We hide the rooms from the room directory to save time between tests and for portability between homeservers
|
||||
const { chunk: rooms } = await this.request<{
|
||||
const { chunk: rooms } = await this.csApi.request<{
|
||||
chunk: { room_id: string }[];
|
||||
}>("GET", "v3/publicRooms", {});
|
||||
}>("GET", "v3/publicRooms", token, {});
|
||||
await Promise.all(
|
||||
rooms.map((room) =>
|
||||
this.request("PUT", `v3/directory/list/room/${room.room_id}`, { visibility: "private" }),
|
||||
this.csApi.request("PUT", `v3/directory/list/room/${room.room_id}`, token, { visibility: "private" }),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -268,13 +279,18 @@ export class StartedSynapseContainer extends AbstractStartedContainer implements
|
||||
displayName?: string,
|
||||
admin = false,
|
||||
): Promise<Credentials> {
|
||||
const url = `${this.baseUrl}/_synapse/admin/v1/register`;
|
||||
const { nonce } = await this._request.get(url).then((r) => r.json());
|
||||
const path = `v1/register`;
|
||||
const { nonce } = await this.adminApi.request<{ nonce: string }>("GET", path, undefined, {});
|
||||
const mac = crypto
|
||||
.createHmac("sha1", this.registrationSharedSecret)
|
||||
.update(`${nonce}\0${username}\0${password}\0${admin ? "" : "not"}admin`)
|
||||
.digest("hex");
|
||||
const res = await this._request.post(url, {
|
||||
const data = await this.adminApi.request<{
|
||||
home_server: string;
|
||||
access_token: string;
|
||||
user_id: string;
|
||||
device_id: string;
|
||||
}>("POST", path, undefined, {
|
||||
data: {
|
||||
nonce,
|
||||
username,
|
||||
@@ -285,11 +301,6 @@ export class StartedSynapseContainer extends AbstractStartedContainer implements
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok()) {
|
||||
throw await res.json();
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return {
|
||||
homeServer: data.home_server,
|
||||
accessToken: data.access_token,
|
||||
@@ -303,6 +314,10 @@ export class StartedSynapseContainer extends AbstractStartedContainer implements
|
||||
|
||||
protected async getAdminToken(): Promise<string> {
|
||||
if (this.adminTokenPromise === undefined) {
|
||||
if (this._mas) {
|
||||
return (this.adminTokenPromise = this._mas.getAdminToken(this.csApi));
|
||||
}
|
||||
|
||||
this.adminTokenPromise = this.registerUserInternal(
|
||||
"admin",
|
||||
"totalyinsecureadminpassword",
|
||||
@@ -317,72 +332,24 @@ export class StartedSynapseContainer extends AbstractStartedContainer implements
|
||||
private async adminRequest<R extends {}>(verb: Verb, path: string, data?: object): Promise<R>;
|
||||
private async adminRequest<R extends {}>(verb: Verb, path: string, data?: object): Promise<R> {
|
||||
const adminToken = await this.getAdminToken();
|
||||
const url = `${this.baseUrl}/_synapse/admin/${path}`;
|
||||
const res = await this._request.fetch(url, {
|
||||
data,
|
||||
method: verb,
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok()) {
|
||||
throw await res.json();
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
public async request<R extends {}>(verb: "GET", path: string, data?: never): Promise<R>;
|
||||
public async request<R extends {}>(verb: Verb, path: string, data?: object): Promise<R>;
|
||||
public async request<R extends {}>(verb: Verb, path: string, data?: object): Promise<R> {
|
||||
const token = await this.getAdminToken();
|
||||
const url = `${this.baseUrl}/_matrix/client/${path}`;
|
||||
const res = await this._request.fetch(url, {
|
||||
data,
|
||||
method: verb,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok()) {
|
||||
throw await res.json();
|
||||
}
|
||||
|
||||
return res.json();
|
||||
return this.adminApi.request(verb, path, adminToken, data);
|
||||
}
|
||||
|
||||
public registerUser(username: string, password: string, displayName?: string): Promise<Credentials> {
|
||||
if (this._mas) {
|
||||
return this._mas.registerUser(this.csApi, username, password, displayName);
|
||||
}
|
||||
return this.registerUserInternal(username, password, displayName, false);
|
||||
}
|
||||
|
||||
public async loginUser(userId: string, password: string): Promise<Credentials> {
|
||||
const json = await this.request<{
|
||||
access_token: string;
|
||||
user_id: string;
|
||||
device_id: string;
|
||||
home_server: string;
|
||||
}>("POST", "v3/login", {
|
||||
type: "m.login.password",
|
||||
identifier: {
|
||||
type: "m.id.user",
|
||||
user: userId,
|
||||
},
|
||||
password: password,
|
||||
});
|
||||
|
||||
return {
|
||||
password,
|
||||
accessToken: json.access_token,
|
||||
userId: json.user_id,
|
||||
deviceId: json.device_id,
|
||||
homeServer: json.home_server,
|
||||
username: userId.slice(1).split(":")[0],
|
||||
};
|
||||
return this.csApi.loginUser(userId, password);
|
||||
}
|
||||
|
||||
public async setThreepid(userId: string, medium: string, address: string): Promise<void> {
|
||||
if (this._mas) {
|
||||
return this._mas.setThreepid(userId, medium, address);
|
||||
}
|
||||
await this.adminRequest("PUT", `v2/users/${userId}`, {
|
||||
threepids: [
|
||||
{
|
||||
|
||||
@@ -5,10 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { TestInfo } from "@playwright/test";
|
||||
import { APIRequestContext, TestInfo } from "@playwright/test";
|
||||
import { Readable } from "stream";
|
||||
import stripAnsi from "strip-ansi";
|
||||
|
||||
import { Credentials } from "../plugins/homeserver";
|
||||
|
||||
export class ContainerLogger {
|
||||
private logs: Record<string, string> = {};
|
||||
|
||||
@@ -41,3 +43,67 @@ export class ContainerLogger {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type Verb = "GET" | "POST" | "PUT" | "DELETE";
|
||||
|
||||
export class Api {
|
||||
private _request?: APIRequestContext;
|
||||
|
||||
constructor(private readonly baseUrl: string) {}
|
||||
|
||||
public setRequest(request: APIRequestContext): void {
|
||||
this._request = request;
|
||||
}
|
||||
|
||||
public async request<R extends {}>(verb: "GET", path: string, token?: string, data?: never): Promise<R>;
|
||||
public async request<R extends {}>(verb: Verb, path: string, token?: string, data?: object): Promise<R>;
|
||||
public async request<R extends {}>(verb: Verb, path: string, token?: string, data?: object): Promise<R> {
|
||||
const url = `${this.baseUrl}/_matrix/client/${path}`;
|
||||
const res = await this._request.fetch(url, {
|
||||
data,
|
||||
method: verb,
|
||||
headers: token
|
||||
? {
|
||||
Authorization: `Bearer ${token}`,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (!res.ok()) {
|
||||
throw await res.json();
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
}
|
||||
|
||||
export class ClientServerApi extends Api {
|
||||
constructor(baseUrl: string) {
|
||||
super(`${baseUrl}/_matrix/client/`);
|
||||
}
|
||||
|
||||
public async loginUser(userId: string, password: string): Promise<Credentials> {
|
||||
const json = await this.request<{
|
||||
access_token: string;
|
||||
user_id: string;
|
||||
device_id: string;
|
||||
home_server: string;
|
||||
}>("POST", "v3/login", undefined, {
|
||||
type: "m.login.password",
|
||||
identifier: {
|
||||
type: "m.id.user",
|
||||
user: userId,
|
||||
},
|
||||
password: password,
|
||||
});
|
||||
|
||||
return {
|
||||
password,
|
||||
accessToken: json.access_token,
|
||||
userId: json.user_id,
|
||||
deviceId: json.device_id,
|
||||
homeServer: json.home_server,
|
||||
username: userId.slice(1).split(":")[0],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user