@@ -9,24 +9,24 @@ import { APIRequestContext } from "playwright-core";
|
||||
import { KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
|
||||
|
||||
import { HomeserverInstance } from "../plugins/homeserver";
|
||||
import { ClientServerApi } from "../testcontainers/utils.ts";
|
||||
|
||||
/**
|
||||
* A small subset of the Client-Server API used to manipulate the state of the
|
||||
* account on the homeserver independently of the client under test.
|
||||
*/
|
||||
export class TestClientServerAPI {
|
||||
export class TestClientServerAPI extends ClientServerApi {
|
||||
public constructor(
|
||||
private request: APIRequestContext,
|
||||
private homeserver: HomeserverInstance,
|
||||
request: APIRequestContext,
|
||||
homeserver: HomeserverInstance,
|
||||
private accessToken: string,
|
||||
) {}
|
||||
) {
|
||||
super(`${homeserver.baseUrl}/_matrix/client`);
|
||||
this.setRequest(request);
|
||||
}
|
||||
|
||||
public async getCurrentBackupInfo(): Promise<KeyBackupInfo | null> {
|
||||
const res = await this.request.get(`${this.homeserver.baseUrl}/_matrix/client/v3/room_keys/version`, {
|
||||
headers: { Authorization: `Bearer ${this.accessToken}` },
|
||||
});
|
||||
|
||||
return await res.json();
|
||||
return this.request("GET", `/v3/room_keys/version`, this.accessToken);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,15 +34,6 @@ export class TestClientServerAPI {
|
||||
* @param version The version to delete
|
||||
*/
|
||||
public async deleteBackupVersion(version: string): Promise<void> {
|
||||
const res = await this.request.delete(
|
||||
`${this.homeserver.baseUrl}/_matrix/client/v3/room_keys/version/${version}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${this.accessToken}` },
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to delete backup version: ${res.status}`);
|
||||
}
|
||||
await this.request("DELETE", `/v3/room_keys/version/${version}`, this.accessToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,9 @@ async function login(page: Page, homeserver: HomeserverInstance, credentials: Cr
|
||||
await page.getByRole("button", { name: "Sign in" }).click();
|
||||
}
|
||||
|
||||
// This test suite uses the same userId for all tests in the suite
|
||||
// due to DEVICE_SIGNING_KEYS_BODY being specific to that userId,
|
||||
// so we restart the Synapse container to make it forget everything.
|
||||
test.use(consentHomeserver);
|
||||
test.use({
|
||||
config: {
|
||||
@@ -97,6 +100,9 @@ test.use({
|
||||
...credentials,
|
||||
displayName,
|
||||
});
|
||||
|
||||
// Restart the homeserver to wipe its in-memory db so we can reuse the same user ID without cross-signing prompts
|
||||
await homeserver.restart();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ export async function registerAccountMas(
|
||||
expect(messages.items).toHaveLength(1);
|
||||
}).toPass();
|
||||
expect(messages.items[0].to).toEqual(`${username} <${email}>`);
|
||||
const [code] = messages.items[0].text.match(/(\d{6})/);
|
||||
const [, code] = messages.items[0].text.match(/Your verification code to confirm this email address is: (\d{6})/);
|
||||
|
||||
await page.getByRole("textbox", { name: "6-digit code" }).fill(code);
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
@@ -24,6 +24,8 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
mailhogClient,
|
||||
mas,
|
||||
}, testInfo) => {
|
||||
await page.clock.install();
|
||||
|
||||
const tokenUri = `${mas.baseUrl}/oauth2/token`;
|
||||
const tokenApiPromise = page.waitForRequest(
|
||||
(request) => request.url() === tokenUri && request.postDataJSON()["grant_type"] === "authorization_code",
|
||||
@@ -31,11 +33,14 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
|
||||
await page.goto("/#/login");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await registerAccountMas(page, mailhogClient, `alice_${testInfo.testId}`, "alice@email.com", "Pa$sW0rD!");
|
||||
|
||||
const userId = `alice_${testInfo.testId}`;
|
||||
await registerAccountMas(page, mailhogClient, userId, "alice@email.com", "Pa$sW0rD!");
|
||||
|
||||
// Eventually, we should end up at the home screen.
|
||||
await expect(page).toHaveURL(/\/#\/home$/, { timeout: 10000 });
|
||||
await expect(page.getByRole("heading", { name: "Welcome alice", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: `Welcome ${userId}`, exact: true })).toBeVisible();
|
||||
await page.clock.runFor(20000); // run the timer so we see the token request
|
||||
|
||||
const tokenApiRequest = await tokenApiPromise;
|
||||
expect(tokenApiRequest.postDataJSON()["grant_type"]).toBe("authorization_code");
|
||||
|
||||
@@ -108,7 +108,6 @@ test.describe("Sliding Sync", () => {
|
||||
await page.getByRole("menuitemradio", { name: "A-Z" }).dispatchEvent("click");
|
||||
await expect(page.locator(".mx_StyledRadioButton_checked").getByText("A-Z")).toBeVisible();
|
||||
|
||||
await page.pause();
|
||||
await checkOrder(["Apple", "Orange", "Pineapple", "Test Room"], page);
|
||||
});
|
||||
|
||||
|
||||
@@ -366,28 +366,28 @@ test.describe("Spotlight", () => {
|
||||
await spotlight.search("b");
|
||||
|
||||
let resultLocator = spotlight.results;
|
||||
await expect(resultLocator.count()).resolves.toBeGreaterThan(2);
|
||||
await expect(resultLocator).toHaveCount(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.nth(2)).toHaveAttribute("aria-selected", "true");
|
||||
await expect(resultLocator.last()).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await spotlight.searchBox.press("ArrowDown");
|
||||
resultLocator = spotlight.results;
|
||||
await expect(resultLocator.first()).toHaveAttribute("aria-selected", "false");
|
||||
await expect(resultLocator.nth(2)).toHaveAttribute("aria-selected", "false");
|
||||
await expect(resultLocator.last()).toHaveAttribute("aria-selected", "false");
|
||||
|
||||
await spotlight.searchBox.press("ArrowUp");
|
||||
resultLocator = spotlight.results;
|
||||
await expect(resultLocator.first()).toHaveAttribute("aria-selected", "false");
|
||||
await expect(resultLocator.nth(2)).toHaveAttribute("aria-selected", "true");
|
||||
await expect(resultLocator.last()).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await spotlight.searchBox.press("ArrowUp");
|
||||
resultLocator = spotlight.results;
|
||||
await expect(resultLocator.first()).toHaveAttribute("aria-selected", "true");
|
||||
await expect(resultLocator.nth(2)).toHaveAttribute("aria-selected", "false");
|
||||
await expect(resultLocator.last()).toHaveAttribute("aria-selected", "false");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@ const CONFIG_JSON: Partial<IConfigOptions> = {
|
||||
},
|
||||
};
|
||||
|
||||
interface CredentialsWithDisplayName extends Credentials {
|
||||
export interface CredentialsWithDisplayName extends Credentials {
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,11 @@ 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 { ClientServerApi } from "../../testcontainers/utils.ts";
|
||||
|
||||
export interface HomeserverInstance {
|
||||
readonly baseUrl: string;
|
||||
readonly csApi: ClientServerApi;
|
||||
|
||||
/**
|
||||
* Register a user on the given Homeserver using the shared registration secret.
|
||||
|
||||
@@ -114,10 +114,9 @@ export const test = base.extend<{}, Services>({
|
||||
.withNetworkAliases("homeserver")
|
||||
.withLogConsumer(logger.getConsumer("synapse"))
|
||||
.withConfig(synapseConfigOptions)
|
||||
.withMatrixAuthenticationService(mas)
|
||||
.start();
|
||||
|
||||
container.setMatrixAuthenticationService(mas);
|
||||
|
||||
await use(container);
|
||||
await container.stop();
|
||||
},
|
||||
|
||||
@@ -14,11 +14,11 @@ import { StartedMatrixAuthenticationServiceContainer } from "./mas.ts";
|
||||
export interface HomeserverContainer<Config> extends GenericContainer {
|
||||
withConfigField(key: string, value: any): this;
|
||||
withConfig(config: Partial<Config>): this;
|
||||
withMatrixAuthenticationService(mas?: StartedMatrixAuthenticationServiceContainer): this;
|
||||
start(): Promise<StartedHomeserverContainer>;
|
||||
}
|
||||
|
||||
export interface StartedHomeserverContainer extends AbstractStartedContainer, HomeserverInstance {
|
||||
setRequest(request: APIRequestContext): void;
|
||||
setMatrixAuthenticationService(mas?: StartedMatrixAuthenticationServiceContainer): void;
|
||||
onTestFinished(testInfo: TestInfo): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { randB64Bytes } from "../plugins/utils/rand.ts";
|
||||
import { StartedSynapseContainer } from "./synapse.ts";
|
||||
import { deepCopy } from "../plugins/utils/object.ts";
|
||||
import { HomeserverContainer } from "./HomeserverContainer.ts";
|
||||
import { StartedMatrixAuthenticationServiceContainer } from "./mas.ts";
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
version: 2,
|
||||
@@ -235,6 +236,10 @@ export class DendriteContainer extends GenericContainer implements HomeserverCon
|
||||
return this;
|
||||
}
|
||||
|
||||
public withMatrixAuthenticationService(mas?: StartedMatrixAuthenticationServiceContainer): this {
|
||||
throw new Error("Dendrite does not support MAS.");
|
||||
}
|
||||
|
||||
public override async start(): Promise<StartedDendriteContainer> {
|
||||
this.withCopyContentToContainer([
|
||||
{
|
||||
|
||||
@@ -5,14 +5,13 @@ 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 { AbstractStartedContainer, GenericContainer, StartedTestContainer, Wait } from "testcontainers";
|
||||
import { AbstractStartedContainer, GenericContainer, StartedTestContainer, Wait, ExecResult } from "testcontainers";
|
||||
import { StartedPostgreSqlContainer } from "@testcontainers/postgresql";
|
||||
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: {
|
||||
@@ -227,10 +226,9 @@ export class StartedMatrixAuthenticationServiceContainer extends AbstractStarted
|
||||
super(container);
|
||||
}
|
||||
|
||||
public async getAdminToken(csApi: ClientServerApi): Promise<string> {
|
||||
public async getAdminToken(): Promise<string> {
|
||||
if (this.adminTokenPromise === undefined) {
|
||||
this.adminTokenPromise = this.registerUserInternal(
|
||||
csApi,
|
||||
"admin",
|
||||
"totalyinsecureadminpassword",
|
||||
undefined,
|
||||
@@ -240,20 +238,24 @@ export class StartedMatrixAuthenticationServiceContainer extends AbstractStarted
|
||||
return this.adminTokenPromise;
|
||||
}
|
||||
|
||||
private async registerUserInternal(
|
||||
csApi: ClientServerApi,
|
||||
private async manage(cmd: string, ...args: string[]): Promise<ExecResult> {
|
||||
const result = await this.exec(["mas-cli", "manage", cmd, ...this.args, ...args]);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`Failed mas-cli manage ${cmd}: ${result.output}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private async manageRegisterUser(
|
||||
username: string,
|
||||
password: string,
|
||||
displayName?: string,
|
||||
admin = false,
|
||||
): Promise<Credentials> {
|
||||
): Promise<string> {
|
||||
const args: string[] = [];
|
||||
if (admin) args.push("-a");
|
||||
await this.exec([
|
||||
"mas-cli",
|
||||
"manage",
|
||||
const result = await this.manage(
|
||||
"register-user",
|
||||
...this.args,
|
||||
...args,
|
||||
"-y",
|
||||
"-p",
|
||||
@@ -261,18 +263,62 @@ export class StartedMatrixAuthenticationServiceContainer extends AbstractStarted
|
||||
"-d",
|
||||
displayName ?? "",
|
||||
username,
|
||||
]);
|
||||
);
|
||||
|
||||
return csApi.loginUser(username, password);
|
||||
const registerLines = result.output.trim().split("\n");
|
||||
const userId = registerLines
|
||||
.find((line) => line.includes("Matrix ID: "))
|
||||
?.split(": ")
|
||||
.pop();
|
||||
|
||||
if (!userId) {
|
||||
throw new Error(`Failed to register user: ${result.output}`);
|
||||
}
|
||||
|
||||
return userId;
|
||||
}
|
||||
|
||||
public async registerUser(
|
||||
csApi: ClientServerApi,
|
||||
private async manageIssueCompatibilityToken(
|
||||
username: string,
|
||||
admin = false,
|
||||
): Promise<{ accessToken: string; deviceId: string }> {
|
||||
const args: string[] = [];
|
||||
if (admin) args.push("--yes-i-want-to-grant-synapse-admin-privileges");
|
||||
const result = await this.manage("issue-compatibility-token", ...args, username);
|
||||
|
||||
const parts = result.output.trim().split(/\s+/);
|
||||
const accessToken = parts.find((part) => part.startsWith("mct_"));
|
||||
const deviceId = parts.find((part) => part.startsWith("compat_session.device="))?.split("=")[1];
|
||||
|
||||
if (!accessToken || !deviceId) {
|
||||
throw new Error(`Failed to issue compatibility token: ${result.output}`);
|
||||
}
|
||||
|
||||
return { accessToken, deviceId };
|
||||
}
|
||||
|
||||
private async registerUserInternal(
|
||||
username: string,
|
||||
password: string,
|
||||
displayName?: string,
|
||||
admin = false,
|
||||
): Promise<Credentials> {
|
||||
return this.registerUserInternal(csApi, username, password, displayName, false);
|
||||
const userId = await this.manageRegisterUser(username, password, displayName, admin);
|
||||
const { deviceId, accessToken } = await this.manageIssueCompatibilityToken(username, admin);
|
||||
|
||||
return {
|
||||
userId,
|
||||
accessToken,
|
||||
deviceId,
|
||||
homeServer: userId.slice(1).split(":").slice(1).join(":"),
|
||||
displayName,
|
||||
username,
|
||||
password,
|
||||
};
|
||||
}
|
||||
|
||||
public async registerUser(username: string, password: string, displayName?: string): Promise<Credentials> {
|
||||
return this.registerUserInternal(username, password, displayName, false);
|
||||
}
|
||||
|
||||
public async setThreepid(username: string, medium: string, address: string): Promise<void> {
|
||||
@@ -280,6 +326,6 @@ export class StartedMatrixAuthenticationServiceContainer extends AbstractStarted
|
||||
throw new Error("Only email threepids are supported by MAS");
|
||||
}
|
||||
|
||||
await this.exec(["mas-cli", "manage", "add-email", ...this.args, username, address]);
|
||||
await this.manage("add-email", username, address);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,14 @@ 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 { AbstractStartedContainer, GenericContainer, StartedTestContainer, Wait } from "testcontainers";
|
||||
import {
|
||||
AbstractStartedContainer,
|
||||
GenericContainer,
|
||||
ImageName,
|
||||
RestartOptions,
|
||||
StartedTestContainer,
|
||||
Wait,
|
||||
} from "testcontainers";
|
||||
import { APIRequestContext, TestInfo } from "@playwright/test";
|
||||
import crypto from "node:crypto";
|
||||
import * as YAML from "yaml";
|
||||
@@ -144,6 +151,7 @@ export type SynapseConfigOptions = Partial<typeof DEFAULT_CONFIG>;
|
||||
|
||||
export class SynapseContainer extends GenericContainer implements HomeserverContainer<typeof DEFAULT_CONFIG> {
|
||||
private config: typeof DEFAULT_CONFIG;
|
||||
private mas?: StartedMatrixAuthenticationServiceContainer;
|
||||
|
||||
constructor() {
|
||||
super(`ghcr.io/element-hq/synapse:${TAG}`);
|
||||
@@ -203,6 +211,11 @@ export class SynapseContainer extends GenericContainer implements HomeserverCont
|
||||
return this;
|
||||
}
|
||||
|
||||
public withMatrixAuthenticationService(mas?: StartedMatrixAuthenticationServiceContainer): this {
|
||||
this.mas = mas;
|
||||
return this;
|
||||
}
|
||||
|
||||
public override async start(): Promise<StartedSynapseContainer> {
|
||||
// Synapse config public_baseurl needs to know what URL it'll be accessed from, so we have to map the port manually
|
||||
const port = await getFreePort();
|
||||
@@ -221,20 +234,26 @@ export class SynapseContainer extends GenericContainer implements HomeserverCont
|
||||
},
|
||||
]);
|
||||
|
||||
return new StartedSynapseContainer(
|
||||
await super.start(),
|
||||
`http://localhost:${port}`,
|
||||
this.config.registration_shared_secret,
|
||||
);
|
||||
const container = await super.start();
|
||||
const baseUrl = `http://localhost:${port}`;
|
||||
if (this.mas) {
|
||||
return new StartedSynapseWithMasContainer(
|
||||
container,
|
||||
baseUrl,
|
||||
this.config.registration_shared_secret,
|
||||
this.mas,
|
||||
);
|
||||
}
|
||||
|
||||
return new StartedSynapseContainer(container, baseUrl, this.config.registration_shared_secret);
|
||||
}
|
||||
}
|
||||
|
||||
export class StartedSynapseContainer extends AbstractStartedContainer implements StartedHomeserverContainer {
|
||||
private adminTokenPromise?: Promise<string>;
|
||||
private _mas?: StartedMatrixAuthenticationServiceContainer;
|
||||
protected adminTokenPromise?: Promise<string>;
|
||||
protected _request?: APIRequestContext;
|
||||
protected csApi: ClientServerApi;
|
||||
protected adminApi: Api;
|
||||
protected readonly adminApi: Api;
|
||||
public readonly csApi: ClientServerApi;
|
||||
|
||||
constructor(
|
||||
container: StartedTestContainer,
|
||||
@@ -242,8 +261,13 @@ 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`);
|
||||
this.csApi = new ClientServerApi(this.baseUrl);
|
||||
}
|
||||
|
||||
public restart(options?: Partial<RestartOptions>): Promise<void> {
|
||||
this.adminTokenPromise = undefined;
|
||||
return super.restart(options);
|
||||
}
|
||||
|
||||
public setRequest(request: APIRequestContext): void {
|
||||
@@ -252,10 +276,6 @@ export class StartedSynapseContainer extends AbstractStartedContainer implements
|
||||
this.adminApi.setRequest(request);
|
||||
}
|
||||
|
||||
public setMatrixAuthenticationService(mas?: StartedMatrixAuthenticationServiceContainer): void {
|
||||
this._mas = mas;
|
||||
}
|
||||
|
||||
public async onTestFinished(testInfo: TestInfo): Promise<void> {
|
||||
// Clean up the server to prevent rooms leaking between tests
|
||||
await this.deletePublicRooms();
|
||||
@@ -313,10 +333,6 @@ 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",
|
||||
@@ -335,9 +351,6 @@ export class StartedSynapseContainer extends AbstractStartedContainer implements
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -346,9 +359,6 @@ export class StartedSynapseContainer extends AbstractStartedContainer implements
|
||||
}
|
||||
|
||||
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: [
|
||||
{
|
||||
@@ -359,3 +369,29 @@ export class StartedSynapseContainer extends AbstractStartedContainer implements
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class StartedSynapseWithMasContainer extends StartedSynapseContainer {
|
||||
constructor(
|
||||
container: StartedTestContainer,
|
||||
baseUrl: string,
|
||||
registrationSharedSecret: string,
|
||||
private readonly mas: StartedMatrixAuthenticationServiceContainer,
|
||||
) {
|
||||
super(container, baseUrl, registrationSharedSecret);
|
||||
}
|
||||
|
||||
protected async getAdminToken(): Promise<string> {
|
||||
if (this.adminTokenPromise === undefined) {
|
||||
this.adminTokenPromise = this.mas.getAdminToken();
|
||||
}
|
||||
return this.adminTokenPromise;
|
||||
}
|
||||
|
||||
public registerUser(username: string, password: string, displayName?: string): Promise<Credentials> {
|
||||
return this.mas.registerUser(username, password, displayName);
|
||||
}
|
||||
|
||||
public async setThreepid(userId: string, medium: string, address: string): Promise<void> {
|
||||
return this.mas.setThreepid(userId, medium, address);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,9 @@ export class Api {
|
||||
});
|
||||
|
||||
if (!res.ok()) {
|
||||
throw await res.json();
|
||||
throw new Error(
|
||||
`Request to ${url} failed with status ${res.status()}: ${JSON.stringify(await res.json())}`,
|
||||
);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
|
||||
Reference in New Issue
Block a user