mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 11:08:37 +00:00
refactor(browser): unify navigation guard path and error typing
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
||||||
import { appendCdpPath, fetchJson, isLoopbackHost, withCdpSocket } from "./cdp.helpers.js";
|
import { appendCdpPath, fetchJson, isLoopbackHost, withCdpSocket } from "./cdp.helpers.js";
|
||||||
import { assertBrowserNavigationAllowed } from "./navigation-guard.js";
|
import { assertBrowserNavigationAllowed, withBrowserNavigationPolicy } from "./navigation-guard.js";
|
||||||
|
|
||||||
export { appendCdpPath, fetchJson, fetchOk, getHeadersWithAuth } from "./cdp.helpers.js";
|
export { appendCdpPath, fetchJson, fetchOk, getHeadersWithAuth } from "./cdp.helpers.js";
|
||||||
|
|
||||||
@@ -88,11 +88,14 @@ export async function createTargetViaCdp(opts: {
|
|||||||
cdpUrl: string;
|
cdpUrl: string;
|
||||||
url: string;
|
url: string;
|
||||||
ssrfPolicy?: SsrFPolicy;
|
ssrfPolicy?: SsrFPolicy;
|
||||||
|
navigationChecked?: boolean;
|
||||||
}): Promise<{ targetId: string }> {
|
}): Promise<{ targetId: string }> {
|
||||||
await assertBrowserNavigationAllowed({
|
if (!opts.navigationChecked) {
|
||||||
url: opts.url,
|
await assertBrowserNavigationAllowed({
|
||||||
ssrfPolicy: opts.ssrfPolicy,
|
url: opts.url,
|
||||||
});
|
...withBrowserNavigationPolicy(opts.ssrfPolicy),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
|
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
|
||||||
appendCdpPath(opts.cdpUrl, "/json/version"),
|
appendCdpPath(opts.cdpUrl, "/json/version"),
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { SsrFBlockedError } from "../infra/net/ssrf.js";
|
import { SsrFBlockedError, type LookupFn } from "../infra/net/ssrf.js";
|
||||||
import { assertBrowserNavigationAllowed } from "./navigation-guard.js";
|
import {
|
||||||
|
assertBrowserNavigationAllowed,
|
||||||
|
InvalidBrowserNavigationUrlError,
|
||||||
|
} from "./navigation-guard.js";
|
||||||
|
|
||||||
|
function createLookupFn(address: string): LookupFn {
|
||||||
|
const family = address.includes(":") ? 6 : 4;
|
||||||
|
return vi.fn(async () => [{ address, family }]) as unknown as LookupFn;
|
||||||
|
}
|
||||||
|
|
||||||
describe("browser navigation guard", () => {
|
describe("browser navigation guard", () => {
|
||||||
it("blocks private loopback URLs by default", async () => {
|
it("blocks private loopback URLs by default", async () => {
|
||||||
@@ -19,15 +27,39 @@ describe("browser navigation guard", () => {
|
|||||||
).resolves.toBeUndefined();
|
).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows localhost when explicitly allowed", async () => {
|
it("allows blocked hostnames when explicitly allowed", async () => {
|
||||||
|
const lookupFn = createLookupFn("127.0.0.1");
|
||||||
await expect(
|
await expect(
|
||||||
assertBrowserNavigationAllowed({
|
assertBrowserNavigationAllowed({
|
||||||
url: "http://localhost:3000",
|
url: "http://agent.internal:3000",
|
||||||
ssrfPolicy: {
|
ssrfPolicy: {
|
||||||
allowedHostnames: ["localhost"],
|
allowedHostnames: ["agent.internal"],
|
||||||
},
|
},
|
||||||
|
lookupFn,
|
||||||
}),
|
}),
|
||||||
).resolves.toBeUndefined();
|
).resolves.toBeUndefined();
|
||||||
|
expect(lookupFn).toHaveBeenCalledWith("agent.internal", { all: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks hostnames that resolve to private addresses by default", async () => {
|
||||||
|
const lookupFn = createLookupFn("127.0.0.1");
|
||||||
|
await expect(
|
||||||
|
assertBrowserNavigationAllowed({
|
||||||
|
url: "https://example.com",
|
||||||
|
lookupFn,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(SsrFBlockedError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows hostnames that resolve to public addresses", async () => {
|
||||||
|
const lookupFn = createLookupFn("93.184.216.34");
|
||||||
|
await expect(
|
||||||
|
assertBrowserNavigationAllowed({
|
||||||
|
url: "https://example.com",
|
||||||
|
lookupFn,
|
||||||
|
}),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
expect(lookupFn).toHaveBeenCalledWith("example.com", { all: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects invalid URLs", async () => {
|
it("rejects invalid URLs", async () => {
|
||||||
@@ -35,6 +67,6 @@ describe("browser navigation guard", () => {
|
|||||||
assertBrowserNavigationAllowed({
|
assertBrowserNavigationAllowed({
|
||||||
url: "not a url",
|
url: "not a url",
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow(/Invalid URL/);
|
).rejects.toBeInstanceOf(InvalidBrowserNavigationUrlError);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,21 +1,44 @@
|
|||||||
import { resolvePinnedHostnameWithPolicy, type SsrFPolicy } from "../infra/net/ssrf.js";
|
import {
|
||||||
|
resolvePinnedHostnameWithPolicy,
|
||||||
|
type LookupFn,
|
||||||
|
type SsrFPolicy,
|
||||||
|
} from "../infra/net/ssrf.js";
|
||||||
|
|
||||||
const NETWORK_NAVIGATION_PROTOCOLS = new Set(["http:", "https:"]);
|
const NETWORK_NAVIGATION_PROTOCOLS = new Set(["http:", "https:"]);
|
||||||
|
|
||||||
export async function assertBrowserNavigationAllowed(opts: {
|
export class InvalidBrowserNavigationUrlError extends Error {
|
||||||
url: string;
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "InvalidBrowserNavigationUrlError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BrowserNavigationPolicyOptions = {
|
||||||
ssrfPolicy?: SsrFPolicy;
|
ssrfPolicy?: SsrFPolicy;
|
||||||
}): Promise<void> {
|
};
|
||||||
|
|
||||||
|
export function withBrowserNavigationPolicy(
|
||||||
|
ssrfPolicy?: SsrFPolicy,
|
||||||
|
): BrowserNavigationPolicyOptions {
|
||||||
|
return ssrfPolicy ? { ssrfPolicy } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertBrowserNavigationAllowed(
|
||||||
|
opts: {
|
||||||
|
url: string;
|
||||||
|
lookupFn?: LookupFn;
|
||||||
|
} & BrowserNavigationPolicyOptions,
|
||||||
|
): Promise<void> {
|
||||||
const rawUrl = String(opts.url ?? "").trim();
|
const rawUrl = String(opts.url ?? "").trim();
|
||||||
if (!rawUrl) {
|
if (!rawUrl) {
|
||||||
throw new Error("url is required");
|
throw new InvalidBrowserNavigationUrlError("url is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
let parsed: URL;
|
let parsed: URL;
|
||||||
try {
|
try {
|
||||||
parsed = new URL(rawUrl);
|
parsed = new URL(rawUrl);
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(`Invalid URL: ${rawUrl}`);
|
throw new InvalidBrowserNavigationUrlError(`Invalid URL: ${rawUrl}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!NETWORK_NAVIGATION_PROTOCOLS.has(parsed.protocol)) {
|
if (!NETWORK_NAVIGATION_PROTOCOLS.has(parsed.protocol)) {
|
||||||
@@ -23,6 +46,7 @@ export async function assertBrowserNavigationAllowed(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await resolvePinnedHostnameWithPolicy(parsed.hostname, {
|
await resolvePinnedHostnameWithPolicy(parsed.hostname, {
|
||||||
|
lookupFn: opts.lookupFn,
|
||||||
policy: opts.ssrfPolicy,
|
policy: opts.ssrfPolicy,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
|||||||
import { appendCdpPath, fetchJson, getHeadersWithAuth, withCdpSocket } from "./cdp.helpers.js";
|
import { appendCdpPath, fetchJson, getHeadersWithAuth, withCdpSocket } from "./cdp.helpers.js";
|
||||||
import { normalizeCdpWsUrl } from "./cdp.js";
|
import { normalizeCdpWsUrl } from "./cdp.js";
|
||||||
import { getChromeWebSocketUrl } from "./chrome.js";
|
import { getChromeWebSocketUrl } from "./chrome.js";
|
||||||
import { assertBrowserNavigationAllowed } from "./navigation-guard.js";
|
import { assertBrowserNavigationAllowed, withBrowserNavigationPolicy } from "./navigation-guard.js";
|
||||||
|
|
||||||
export type BrowserConsoleMessage = {
|
export type BrowserConsoleMessage = {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -722,6 +722,7 @@ export async function createPageViaPlaywright(opts: {
|
|||||||
cdpUrl: string;
|
cdpUrl: string;
|
||||||
url: string;
|
url: string;
|
||||||
ssrfPolicy?: SsrFPolicy;
|
ssrfPolicy?: SsrFPolicy;
|
||||||
|
navigationChecked?: boolean;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
targetId: string;
|
targetId: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -738,10 +739,12 @@ export async function createPageViaPlaywright(opts: {
|
|||||||
// Navigate to the URL
|
// Navigate to the URL
|
||||||
const targetUrl = opts.url.trim() || "about:blank";
|
const targetUrl = opts.url.trim() || "about:blank";
|
||||||
if (targetUrl !== "about:blank") {
|
if (targetUrl !== "about:blank") {
|
||||||
await assertBrowserNavigationAllowed({
|
if (!opts.navigationChecked) {
|
||||||
url: targetUrl,
|
await assertBrowserNavigationAllowed({
|
||||||
ssrfPolicy: opts.ssrfPolicy,
|
url: targetUrl,
|
||||||
});
|
...withBrowserNavigationPolicy(opts.ssrfPolicy),
|
||||||
|
});
|
||||||
|
}
|
||||||
await page.goto(targetUrl, { timeout: 30_000 }).catch(() => {
|
await page.goto(targetUrl, { timeout: 30_000 }).catch(() => {
|
||||||
// Navigation might fail for some URLs, but page is still created
|
// Navigation might fail for some URLs, but page is still created
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
||||||
import { type AriaSnapshotNode, formatAriaSnapshot, type RawAXNode } from "./cdp.js";
|
import { type AriaSnapshotNode, formatAriaSnapshot, type RawAXNode } from "./cdp.js";
|
||||||
import { assertBrowserNavigationAllowed } from "./navigation-guard.js";
|
import { assertBrowserNavigationAllowed, withBrowserNavigationPolicy } from "./navigation-guard.js";
|
||||||
import {
|
import {
|
||||||
buildRoleSnapshotFromAiSnapshot,
|
buildRoleSnapshotFromAiSnapshot,
|
||||||
buildRoleSnapshotFromAriaSnapshot,
|
buildRoleSnapshotFromAriaSnapshot,
|
||||||
@@ -168,7 +168,7 @@ export async function navigateViaPlaywright(opts: {
|
|||||||
}
|
}
|
||||||
await assertBrowserNavigationAllowed({
|
await assertBrowserNavigationAllowed({
|
||||||
url,
|
url,
|
||||||
ssrfPolicy: opts.ssrfPolicy,
|
...withBrowserNavigationPolicy(opts.ssrfPolicy),
|
||||||
});
|
});
|
||||||
const page = await getPageForTargetId(opts);
|
const page = await getPageForTargetId(opts);
|
||||||
ensurePageState(page);
|
ensurePageState(page);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS,
|
DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS,
|
||||||
DEFAULT_AI_SNAPSHOT_MAX_CHARS,
|
DEFAULT_AI_SNAPSHOT_MAX_CHARS,
|
||||||
} from "../constants.js";
|
} from "../constants.js";
|
||||||
|
import { withBrowserNavigationPolicy } from "../navigation-guard.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
|
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
|
||||||
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
|
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
|
||||||
@@ -65,12 +66,11 @@ export function registerBrowserAgentSnapshotRoutes(
|
|||||||
targetId,
|
targetId,
|
||||||
feature: "navigate",
|
feature: "navigate",
|
||||||
run: async ({ cdpUrl, tab, pw }) => {
|
run: async ({ cdpUrl, tab, pw }) => {
|
||||||
const ssrfPolicy = ctx.state().resolved.ssrfPolicy;
|
|
||||||
const result = await pw.navigateViaPlaywright({
|
const result = await pw.navigateViaPlaywright({
|
||||||
cdpUrl,
|
cdpUrl,
|
||||||
targetId: tab.targetId,
|
targetId: tab.targetId,
|
||||||
url,
|
url,
|
||||||
...(ssrfPolicy ? { ssrfPolicy } : {}),
|
...withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy),
|
||||||
});
|
});
|
||||||
res.json({ ok: true, targetId: tab.targetId, ...result });
|
res.json({ ok: true, targetId: tab.targetId, ...result });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
|
|||||||
req,
|
req,
|
||||||
res,
|
res,
|
||||||
ctx,
|
ctx,
|
||||||
|
mapTabError: true,
|
||||||
run: async (profileCtx) => {
|
run: async (profileCtx) => {
|
||||||
await profileCtx.ensureBrowserAvailable();
|
await profileCtx.ensureBrowserAvailable();
|
||||||
const tab = await profileCtx.openTab(url);
|
const tab = await profileCtx.openTab(url);
|
||||||
|
|||||||
@@ -90,6 +90,12 @@ describe("browser server-context remote profile tab operations", () => {
|
|||||||
const opened = await remote.openTab("http://127.0.0.1:3000");
|
const opened = await remote.openTab("http://127.0.0.1:3000");
|
||||||
expect(opened.targetId).toBe("T2");
|
expect(opened.targetId).toBe("T2");
|
||||||
expect(state.profiles.get("remote")?.lastTargetId).toBe("T2");
|
expect(state.profiles.get("remote")?.lastTargetId).toBe("T2");
|
||||||
|
expect(createPageViaPlaywright).toHaveBeenCalledWith({
|
||||||
|
cdpUrl: "https://browserless.example/chrome?token=abc",
|
||||||
|
url: "http://127.0.0.1:3000",
|
||||||
|
ssrfPolicy: { allowPrivateNetwork: true },
|
||||||
|
navigationChecked: true,
|
||||||
|
});
|
||||||
|
|
||||||
await remote.closeTab("T1");
|
await remote.closeTab("T1");
|
||||||
expect(closePageByTargetIdViaPlaywright).toHaveBeenCalledWith({
|
expect(closePageByTargetIdViaPlaywright).toHaveBeenCalledWith({
|
||||||
@@ -214,7 +220,9 @@ describe("browser server-context remote profile tab operations", () => {
|
|||||||
|
|
||||||
describe("browser server-context tab selection state", () => {
|
describe("browser server-context tab selection state", () => {
|
||||||
it("updates lastTargetId when openTab is created via CDP", async () => {
|
it("updates lastTargetId when openTab is created via CDP", async () => {
|
||||||
vi.spyOn(cdpModule, "createTargetViaCdp").mockResolvedValue({ targetId: "CREATED" });
|
const createTargetViaCdp = vi
|
||||||
|
.spyOn(cdpModule, "createTargetViaCdp")
|
||||||
|
.mockResolvedValue({ targetId: "CREATED" });
|
||||||
|
|
||||||
const fetchMock = vi.fn(async (url: unknown) => {
|
const fetchMock = vi.fn(async (url: unknown) => {
|
||||||
const u = String(url);
|
const u = String(url);
|
||||||
@@ -244,5 +252,11 @@ describe("browser server-context tab selection state", () => {
|
|||||||
const opened = await openclaw.openTab("http://127.0.0.1:8080");
|
const opened = await openclaw.openTab("http://127.0.0.1:8080");
|
||||||
expect(opened.targetId).toBe("CREATED");
|
expect(opened.targetId).toBe("CREATED");
|
||||||
expect(state.profiles.get("openclaw")?.lastTargetId).toBe("CREATED");
|
expect(state.profiles.get("openclaw")?.lastTargetId).toBe("CREATED");
|
||||||
|
expect(createTargetViaCdp).toHaveBeenCalledWith({
|
||||||
|
cdpUrl: "http://127.0.0.1:18800",
|
||||||
|
url: "http://127.0.0.1:8080",
|
||||||
|
ssrfPolicy: { allowPrivateNetwork: true },
|
||||||
|
navigationChecked: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ import {
|
|||||||
ensureChromeExtensionRelayServer,
|
ensureChromeExtensionRelayServer,
|
||||||
stopChromeExtensionRelayServer,
|
stopChromeExtensionRelayServer,
|
||||||
} from "./extension-relay.js";
|
} from "./extension-relay.js";
|
||||||
import { assertBrowserNavigationAllowed } from "./navigation-guard.js";
|
import {
|
||||||
|
assertBrowserNavigationAllowed,
|
||||||
|
InvalidBrowserNavigationUrlError,
|
||||||
|
withBrowserNavigationPolicy,
|
||||||
|
} from "./navigation-guard.js";
|
||||||
import type { PwAiModule } from "./pw-ai-module.js";
|
import type { PwAiModule } from "./pw-ai-module.js";
|
||||||
import { getPwAiModule } from "./pw-ai-module.js";
|
import { getPwAiModule } from "./pw-ai-module.js";
|
||||||
import {
|
import {
|
||||||
@@ -132,8 +136,8 @@ function createProfileContext(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openTab = async (url: string): Promise<BrowserTab> => {
|
const openTab = async (url: string): Promise<BrowserTab> => {
|
||||||
const ssrfPolicy = state().resolved.ssrfPolicy;
|
const ssrfPolicyOpts = withBrowserNavigationPolicy(state().resolved.ssrfPolicy);
|
||||||
await assertBrowserNavigationAllowed({ url, ssrfPolicy });
|
await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts });
|
||||||
|
|
||||||
// For remote profiles, use Playwright's persistent connection to create tabs
|
// For remote profiles, use Playwright's persistent connection to create tabs
|
||||||
// This ensures the tab persists beyond a single request
|
// This ensures the tab persists beyond a single request
|
||||||
@@ -144,7 +148,8 @@ function createProfileContext(
|
|||||||
const page = await createPageViaPlaywright({
|
const page = await createPageViaPlaywright({
|
||||||
cdpUrl: profile.cdpUrl,
|
cdpUrl: profile.cdpUrl,
|
||||||
url,
|
url,
|
||||||
...(ssrfPolicy ? { ssrfPolicy } : {}),
|
...ssrfPolicyOpts,
|
||||||
|
navigationChecked: true,
|
||||||
});
|
});
|
||||||
const profileState = getProfileState();
|
const profileState = getProfileState();
|
||||||
profileState.lastTargetId = page.targetId;
|
profileState.lastTargetId = page.targetId;
|
||||||
@@ -160,7 +165,8 @@ function createProfileContext(
|
|||||||
const createdViaCdp = await createTargetViaCdp({
|
const createdViaCdp = await createTargetViaCdp({
|
||||||
cdpUrl: profile.cdpUrl,
|
cdpUrl: profile.cdpUrl,
|
||||||
url,
|
url,
|
||||||
...(ssrfPolicy ? { ssrfPolicy } : {}),
|
...ssrfPolicyOpts,
|
||||||
|
navigationChecked: true,
|
||||||
})
|
})
|
||||||
.then((r) => r.targetId)
|
.then((r) => r.targetId)
|
||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
@@ -645,10 +651,10 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
|
|||||||
if (err instanceof SsrFBlockedError) {
|
if (err instanceof SsrFBlockedError) {
|
||||||
return { status: 400, message: err.message };
|
return { status: 400, message: err.message };
|
||||||
}
|
}
|
||||||
const msg = String(err);
|
if (err instanceof InvalidBrowserNavigationUrlError) {
|
||||||
if (msg.includes("Invalid URL:")) {
|
return { status: 400, message: err.message };
|
||||||
return { status: 400, message: msg };
|
|
||||||
}
|
}
|
||||||
|
const msg = String(err);
|
||||||
if (msg.includes("ambiguous target id prefix")) {
|
if (msg.includes("ambiguous target id prefix")) {
|
||||||
return { status: 409, message: "ambiguous target id prefix" };
|
return { status: 409, message: "ambiguous target id prefix" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,20 @@ describe("browser control server", () => {
|
|||||||
const body = (await result.json()) as { error: string };
|
const body = (await result.json()) as { error: string };
|
||||||
expect(body.error).toContain("not found");
|
expect(body.error).toContain("not found");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("POST /tabs/open returns 400 for invalid URLs", async () => {
|
||||||
|
await startBrowserControlServerFromConfig();
|
||||||
|
const base = getBrowserControlServerBaseUrl();
|
||||||
|
|
||||||
|
const result = await realFetch(`${base}/tabs/open`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ url: "not a url" }),
|
||||||
|
});
|
||||||
|
expect(result.status).toBe(400);
|
||||||
|
const body = (await result.json()) as { error: string };
|
||||||
|
expect(body.error).toContain("Invalid URL:");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("profile CRUD endpoints", () => {
|
describe("profile CRUD endpoints", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user