mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-04 17:12:10 +00:00
test(ui): cover Control UI auth transports (#118866)
* test(ui): cover control ui auth transports * test(qa): register control ui auth transport scenarios * test(ui): allow loaded control ui startup * test(ui): allow loaded screenshot capture * test(ui): scrub loopback proof URLs * test(ui): keep transport proof assertion-focused * test(ui): capture auth transport proof directly * test(ui): gate auth transport proof media * test(ui): scope auth transport evidence * test(ui): fix auth transport frame typing * test(ui): stabilize auth transport QA * test(ui): separate auth UI navigation readiness
This commit is contained in:
36
qa/scenarios/ui/control-ui-allowed-origins-gatewayurl.yaml
Normal file
36
qa/scenarios/ui/control-ui-allowed-origins-gatewayurl.yaml
Normal file
@@ -0,0 +1,36 @@
|
||||
title: Control UI allowed origins and explicit gateway URL
|
||||
|
||||
scenario:
|
||||
id: control-ui-allowed-origins-gatewayurl
|
||||
surface: control-ui
|
||||
category: control-ui.browser-access-and-trust
|
||||
coverage:
|
||||
primary:
|
||||
- control-ui.allowed-origins-gatewayurl
|
||||
objective: >-
|
||||
Prove real Chromium explicitly confirms a gatewayUrl, connects from the
|
||||
configured browser origin, and receives deterministic visible recovery when
|
||||
the real Gateway rejects an unlisted origin.
|
||||
successCriteria:
|
||||
- The Control UI visibly presents and confirms the explicit gatewayUrl before connecting.
|
||||
- A browser served from the configured allowed origin reaches the connected shell through the real proxy and Gateway.
|
||||
- A browser served from a distinct unlisted origin is rejected by Gateway origin enforcement and renders origin-specific recovery without mounting the shell.
|
||||
- Captured browser Origin headers and Gateway responses or handshake status distinguish UI behavior from server-side enforcement.
|
||||
- The run always writes redacted transport and process-cleanup evidence; when OPENCLAW_CAPTURE_UI_PROOF=1, it also writes screenshots and video under the configured artifact directory.
|
||||
docsRefs:
|
||||
- docs/web/control-ui.md
|
||||
- docs/web/urls.md
|
||||
- docs/gateway/trusted-proxy-auth.md
|
||||
codeRefs:
|
||||
- ui/src/e2e/control-ui-auth-transports.e2e.test.ts
|
||||
- ui/src/components/gateway-url-confirmation.ts
|
||||
- ui/src/components/login-gate.ts
|
||||
- src/gateway/origin-check.ts
|
||||
- src/gateway/server/ws-connection/connect-admission.ts
|
||||
execution:
|
||||
kind: playwright
|
||||
path: ui/src/e2e/control-ui-auth-transports.e2e.test.ts
|
||||
testNamePattern: confirms gatewayUrl, accepts the allowed origin, and rejects an unlisted origin
|
||||
summary: >-
|
||||
Real Chromium and Gateway proof for explicit target confirmation,
|
||||
allowed-origin success, and deterministic rejected-origin recovery.
|
||||
33
qa/scenarios/ui/control-ui-trusted-proxy-auth.yaml
Normal file
33
qa/scenarios/ui/control-ui-trusted-proxy-auth.yaml
Normal file
@@ -0,0 +1,33 @@
|
||||
title: Control UI trusted-proxy authentication
|
||||
|
||||
scenario:
|
||||
id: control-ui-trusted-proxy-auth
|
||||
surface: control-ui
|
||||
category: control-ui.browser-access-and-trust
|
||||
coverage:
|
||||
primary:
|
||||
- control-ui.trusted-proxy-auth
|
||||
objective: >-
|
||||
Prove real Chromium connects through a task-owned reverse proxy whose
|
||||
trusted path supplies the configured identity, while the same proxy's
|
||||
untrusted path is rejected by the real Gateway with visible recovery.
|
||||
successCriteria:
|
||||
- A task-owned real Gateway runs with temporary home, state, and trusted-proxy configuration.
|
||||
- Real Chromium reaches the connected Control UI shell through the proxy path that injects the configured identity and required header.
|
||||
- The proxy path that omits proxy-owned auth headers receives a Gateway unauthorized rejection with the exact required-header reason captured from the protocol frame.
|
||||
- Captured browser connect frames and Gateway responses distinguish UI behavior from server-side enforcement.
|
||||
- The run always writes redacted transport and process-cleanup evidence; when OPENCLAW_CAPTURE_UI_PROOF=1, it also writes screenshots and video under the configured artifact directory.
|
||||
docsRefs:
|
||||
- docs/gateway/trusted-proxy-auth.md
|
||||
- docs/web/control-ui.md
|
||||
codeRefs:
|
||||
- ui/src/e2e/control-ui-auth-transports.e2e.test.ts
|
||||
- src/gateway/auth.ts
|
||||
- src/gateway/server/ws-connection/connect-admission.ts
|
||||
execution:
|
||||
kind: playwright
|
||||
path: ui/src/e2e/control-ui-auth-transports.e2e.test.ts
|
||||
testNamePattern: connects through the trusted path and rejects the untrusted proxy path
|
||||
summary: >-
|
||||
Real Chromium, reverse-proxy, and Gateway proof for successful
|
||||
trusted-proxy identity admission and required-header rejection.
|
||||
687
ui/src/e2e/control-ui-auth-transports.e2e.test.ts
Normal file
687
ui/src/e2e/control-ui-auth-transports.e2e.test.ts
Normal file
@@ -0,0 +1,687 @@
|
||||
// Control UI tests prove trusted-proxy and browser-origin auth through real transports.
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { createServer, type IncomingMessage } from "node:http";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
import { chromium, type Browser, type BrowserContext, type Page } from "playwright";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { WebSocket, WebSocketServer, type RawData } from "ws";
|
||||
import { ConnectErrorDetailCodes } from "../../../packages/gateway-protocol/src/connect-error-details.js";
|
||||
import type { GatewayServer } from "../../../src/gateway/server.js";
|
||||
import {
|
||||
createOpenClawTestState,
|
||||
type OpenClawTestState,
|
||||
} from "../../../src/test-utils/openclaw-test-state.js";
|
||||
import {
|
||||
canRunPlaywrightChromium,
|
||||
resolvePlaywrightChromiumExecutablePath,
|
||||
startControlUiE2eServer,
|
||||
type ControlUiE2eServer,
|
||||
} from "../test-helpers/control-ui-e2e.ts";
|
||||
|
||||
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
|
||||
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
|
||||
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
|
||||
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
|
||||
const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
|
||||
const artifactDir = path.resolve(
|
||||
process.cwd(),
|
||||
process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim() ||
|
||||
".artifacts/control-ui-e2e/control-ui-auth-transports",
|
||||
);
|
||||
const viewport = { height: 900, width: 1280 };
|
||||
const trustedProxyUser = "qa-operator";
|
||||
const controlUiSettleTimeoutMs = 60_000;
|
||||
|
||||
type ProxyRoute = "trusted" | "untrusted";
|
||||
|
||||
type BrowserConnectEvidence = {
|
||||
authFields: string[];
|
||||
clientId: string | null;
|
||||
clientMode: string | null;
|
||||
hasDevice: boolean;
|
||||
scopes: string[];
|
||||
};
|
||||
|
||||
type GatewayResultEvidence = {
|
||||
errorCode: string | null;
|
||||
errorReason: string | null;
|
||||
helloType: string | null;
|
||||
message: string | null;
|
||||
ok: boolean;
|
||||
};
|
||||
|
||||
type ProxyConnectionEvidence = {
|
||||
browserConnect?: BrowserConnectEvidence;
|
||||
browserOrigin: string | null;
|
||||
gatewayResult?: GatewayResultEvidence;
|
||||
identityInjected: boolean;
|
||||
requiredHeaderInjected: boolean;
|
||||
route: ProxyRoute;
|
||||
upstreamHandshakeStatus?: number;
|
||||
};
|
||||
|
||||
type RealTransportProxy = {
|
||||
close: () => Promise<void>;
|
||||
evidence: ProxyConnectionEvidence[];
|
||||
port: number;
|
||||
trustedUrl: string;
|
||||
untrustedUrl: string;
|
||||
};
|
||||
|
||||
type RealGateway = {
|
||||
cleanup: () => Promise<void>;
|
||||
port: number;
|
||||
server: GatewayServer;
|
||||
state: OpenClawTestState;
|
||||
url: string;
|
||||
};
|
||||
|
||||
let browser: Browser;
|
||||
let allowedUi: ControlUiE2eServer;
|
||||
let rejectedUi: ControlUiE2eServer;
|
||||
let gateway: RealGateway;
|
||||
let proxy: RealTransportProxy;
|
||||
const openContexts = new Set<BrowserContext>();
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | null {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
function stringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((entry): entry is string => typeof entry === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
function parseJsonFrame(data: RawData): Record<string, unknown> | null {
|
||||
try {
|
||||
const text = Array.isArray(data)
|
||||
? Buffer.concat(data).toString("utf8")
|
||||
: data instanceof ArrayBuffer
|
||||
? Buffer.from(data).toString("utf8")
|
||||
: data.toString("utf8");
|
||||
return asRecord(JSON.parse(text));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function captureBrowserConnect(
|
||||
evidence: ProxyConnectionEvidence,
|
||||
frame: Record<string, unknown>,
|
||||
): string | null {
|
||||
if (frame.type !== "req" || frame.method !== "connect") {
|
||||
return null;
|
||||
}
|
||||
const params = asRecord(frame.params);
|
||||
const client = asRecord(params?.client);
|
||||
const auth = asRecord(params?.auth);
|
||||
evidence.browserConnect = {
|
||||
authFields: auth ? Object.keys(auth).toSorted() : [],
|
||||
clientId: stringValue(client?.id),
|
||||
clientMode: stringValue(client?.mode),
|
||||
hasDevice: asRecord(params?.device) !== null,
|
||||
scopes: stringArray(params?.scopes).toSorted(),
|
||||
};
|
||||
return stringValue(frame.id);
|
||||
}
|
||||
|
||||
function captureGatewayResult(
|
||||
evidence: ProxyConnectionEvidence,
|
||||
frame: Record<string, unknown>,
|
||||
connectRequestId: string | null,
|
||||
): void {
|
||||
if (frame.type !== "res" || stringValue(frame.id) !== connectRequestId) {
|
||||
return;
|
||||
}
|
||||
const error = asRecord(frame.error);
|
||||
const details = asRecord(error?.details);
|
||||
const payload = asRecord(frame.payload);
|
||||
evidence.gatewayResult = {
|
||||
errorCode: stringValue(details?.code) ?? stringValue(error?.code),
|
||||
errorReason: stringValue(details?.authReason) ?? stringValue(details?.reason),
|
||||
helloType: stringValue(payload?.type),
|
||||
message: stringValue(error?.message),
|
||||
ok: frame.ok === true,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeProxyEvidence(evidence: ProxyConnectionEvidence) {
|
||||
return {
|
||||
browserConnect: evidence.browserConnect,
|
||||
browserOriginPresent: Boolean(evidence.browserOrigin),
|
||||
gatewayResult: evidence.gatewayResult,
|
||||
identityInjected: evidence.identityInjected,
|
||||
requiredHeaderInjected: evidence.requiredHeaderInjected,
|
||||
route: evidence.route,
|
||||
upstreamHandshakeStatus: evidence.upstreamHandshakeStatus,
|
||||
};
|
||||
}
|
||||
|
||||
function startProxyConnection(
|
||||
request: IncomingMessage,
|
||||
browserSocket: WebSocket,
|
||||
gatewayUrl: string,
|
||||
evidence: ProxyConnectionEvidence,
|
||||
activeSockets: Set<WebSocket>,
|
||||
): void {
|
||||
const headers =
|
||||
evidence.route === "trusted"
|
||||
? {
|
||||
"x-forwarded-for": "192.0.2.10",
|
||||
"x-forwarded-proto": "http",
|
||||
"x-forwarded-user": trustedProxyUser,
|
||||
}
|
||||
: {};
|
||||
const upstream = new WebSocket(gatewayUrl, {
|
||||
headers,
|
||||
origin: evidence.browserOrigin ?? undefined,
|
||||
});
|
||||
activeSockets.add(browserSocket);
|
||||
activeSockets.add(upstream);
|
||||
const pendingBrowserFrames: Array<{ data: RawData; isBinary: boolean }> = [];
|
||||
let connectRequestId: string | null = null;
|
||||
|
||||
browserSocket.on("message", (data, isBinary) => {
|
||||
const frame = parseJsonFrame(data);
|
||||
if (frame) {
|
||||
connectRequestId = captureBrowserConnect(evidence, frame) ?? connectRequestId;
|
||||
}
|
||||
if (upstream.readyState === WebSocket.OPEN) {
|
||||
upstream.send(data, { binary: isBinary });
|
||||
return;
|
||||
}
|
||||
pendingBrowserFrames.push({ data, isBinary });
|
||||
});
|
||||
upstream.on("open", () => {
|
||||
for (const frame of pendingBrowserFrames.splice(0)) {
|
||||
upstream.send(frame.data, { binary: frame.isBinary });
|
||||
}
|
||||
});
|
||||
upstream.on("message", (data, isBinary) => {
|
||||
const frame = parseJsonFrame(data);
|
||||
if (frame) {
|
||||
captureGatewayResult(evidence, frame, connectRequestId);
|
||||
}
|
||||
if (browserSocket.readyState === WebSocket.OPEN) {
|
||||
browserSocket.send(data, { binary: isBinary });
|
||||
}
|
||||
});
|
||||
upstream.on("unexpected-response", (_upstreamRequest, response) => {
|
||||
evidence.upstreamHandshakeStatus = response.statusCode;
|
||||
const body: Buffer[] = [];
|
||||
response.on("data", (chunk) => body.push(Buffer.from(chunk)));
|
||||
response.on("end", () => {
|
||||
const reason = Buffer.concat(body).toString("utf8").trim() || "gateway rejected websocket";
|
||||
if (browserSocket.readyState === WebSocket.OPEN) {
|
||||
browserSocket.close(1008, reason.slice(0, 120));
|
||||
}
|
||||
});
|
||||
});
|
||||
upstream.on("close", (code, reason) => {
|
||||
activeSockets.delete(upstream);
|
||||
if (browserSocket.readyState === WebSocket.OPEN) {
|
||||
browserSocket.close(code, reason.toString().slice(0, 120));
|
||||
}
|
||||
});
|
||||
upstream.on("error", () => {
|
||||
if (browserSocket.readyState === WebSocket.OPEN) {
|
||||
browserSocket.close(1011, "gateway transport error");
|
||||
}
|
||||
});
|
||||
browserSocket.on("close", () => {
|
||||
activeSockets.delete(browserSocket);
|
||||
if (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING) {
|
||||
upstream.close();
|
||||
}
|
||||
});
|
||||
browserSocket.on("error", () => {
|
||||
if (upstream.readyState === WebSocket.OPEN) {
|
||||
upstream.close();
|
||||
}
|
||||
});
|
||||
|
||||
request.socket.once("error", () => {
|
||||
browserSocket.terminate();
|
||||
upstream.terminate();
|
||||
});
|
||||
}
|
||||
|
||||
async function startRealTransportProxy(gatewayUrl: string): Promise<RealTransportProxy> {
|
||||
const evidence: ProxyConnectionEvidence[] = [];
|
||||
const activeSockets = new Set<WebSocket>();
|
||||
const websocketServer = new WebSocketServer({ noServer: true });
|
||||
const server = createServer((_request, response) => {
|
||||
response.writeHead(404).end();
|
||||
});
|
||||
|
||||
server.on("upgrade", (request, socket, head) => {
|
||||
const pathname = new URL(request.url ?? "/", "http://localhost").pathname;
|
||||
const route =
|
||||
pathname === "/trusted" ? "trusted" : pathname === "/untrusted" ? "untrusted" : null;
|
||||
if (!route) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
websocketServer.handleUpgrade(request, socket, head, (browserSocket) => {
|
||||
const connectionEvidence: ProxyConnectionEvidence = {
|
||||
browserOrigin: stringValue(request.headers.origin),
|
||||
identityInjected: route === "trusted",
|
||||
requiredHeaderInjected: route === "trusted",
|
||||
route,
|
||||
};
|
||||
evidence.push(connectionEvidence);
|
||||
startProxyConnection(request, browserSocket, gatewayUrl, connectionEvidence, activeSockets);
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("real-transport proxy did not bind a TCP port");
|
||||
}
|
||||
const baseUrl = `ws://localhost:${address.port}`;
|
||||
return {
|
||||
close: async () => {
|
||||
for (const socket of activeSockets) {
|
||||
socket.terminate();
|
||||
}
|
||||
activeSockets.clear();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
websocketServer.close(() => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
});
|
||||
},
|
||||
evidence,
|
||||
port: address.port,
|
||||
trustedUrl: `${baseUrl}/trusted`,
|
||||
untrustedUrl: `${baseUrl}/untrusted`,
|
||||
};
|
||||
}
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
const server = net.createServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close();
|
||||
throw new Error("failed to allocate a Gateway port");
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
return address.port;
|
||||
}
|
||||
|
||||
async function startRealGateway(allowedOrigin: string): Promise<RealGateway> {
|
||||
const port = await getFreePort();
|
||||
const state = await createOpenClawTestState({
|
||||
label: "control-ui-auth-transports",
|
||||
layout: "home",
|
||||
env: {
|
||||
OPENCLAW_GATEWAY_PASSWORD: undefined,
|
||||
OPENCLAW_GATEWAY_TOKEN: undefined,
|
||||
OPENCLAW_SKIP_BROWSER_CONTROL_SERVER: "1",
|
||||
OPENCLAW_SKIP_CANVAS_HOST: "1",
|
||||
OPENCLAW_SKIP_CHANNELS: "1",
|
||||
OPENCLAW_SKIP_CRON: "1",
|
||||
OPENCLAW_SKIP_GMAIL_WATCHER: "1",
|
||||
OPENCLAW_SKIP_PROVIDERS: "1",
|
||||
OPENCLAW_TEST_MINIMAL_GATEWAY: "1",
|
||||
VITEST: "1",
|
||||
},
|
||||
});
|
||||
const trustedProxy = {
|
||||
allowLoopback: true,
|
||||
allowUsers: [trustedProxyUser],
|
||||
deviceAutoApprove: {
|
||||
enabled: true,
|
||||
scopes: ["operator.approvals", "operator.questions", "operator.read", "operator.write"],
|
||||
},
|
||||
requiredHeaders: ["x-forwarded-proto"],
|
||||
userHeader: "x-forwarded-user",
|
||||
};
|
||||
await state.writeConfig({
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
trustedProxy,
|
||||
},
|
||||
controlUi: {
|
||||
allowedOrigins: [allowedOrigin],
|
||||
enabled: false,
|
||||
},
|
||||
port,
|
||||
trustedProxies: ["127.0.0.1", "::1"],
|
||||
},
|
||||
});
|
||||
state.applyEnv();
|
||||
try {
|
||||
const { startGatewayServer } = await import("../../../src/gateway/server.js");
|
||||
const server = await startGatewayServer(port, {
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
trustedProxy,
|
||||
},
|
||||
bind: "loopback",
|
||||
controlUiEnabled: false,
|
||||
sidecarStartup: "defer",
|
||||
});
|
||||
return {
|
||||
cleanup: async () => {
|
||||
await server.close({ reason: "control ui auth transports test cleanup" });
|
||||
await state.cleanup();
|
||||
},
|
||||
port,
|
||||
server,
|
||||
state,
|
||||
url: `ws://127.0.0.1:${port}`,
|
||||
};
|
||||
} catch (error) {
|
||||
await state.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function withGatewayUrl(baseUrl: string, gatewayUrl: string): string {
|
||||
const url = new URL("settings/connection", baseUrl);
|
||||
url.searchParams.set("gatewayUrl", gatewayUrl);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function createBrowserPage(
|
||||
baseUrl: string,
|
||||
gatewayUrl: string,
|
||||
): Promise<{
|
||||
context: BrowserContext;
|
||||
evidenceStartIndex: number;
|
||||
page: Page;
|
||||
}> {
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
const context = await browser.newContext({
|
||||
locale: "en-US",
|
||||
recordVideo: captureUiProofEnabled ? { dir: artifactDir, size: viewport } : undefined,
|
||||
serviceWorkers: "block",
|
||||
viewport,
|
||||
});
|
||||
openContexts.add(context);
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(15_000);
|
||||
const evidenceStartIndex = proxy.evidence.length;
|
||||
const response = await page.goto(withGatewayUrl(baseUrl, gatewayUrl), {
|
||||
timeout: controlUiSettleTimeoutMs,
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
expect(response?.status()).toBe(200);
|
||||
// Source-served UI startup shares CI shard CPU. Bound navigation and the
|
||||
// first rendered interaction separately; transport assertions stay narrow.
|
||||
const confirmation = page.locator("openclaw-gateway-url-confirmation");
|
||||
await confirmation.waitFor({ timeout: controlUiSettleTimeoutMs });
|
||||
expect(await confirmation.textContent()).toContain(gatewayUrl);
|
||||
expect(proxy.evidence).toHaveLength(evidenceStartIndex);
|
||||
await confirmation
|
||||
.getByRole("button", { name: "Confirm", exact: true })
|
||||
.click({ timeout: controlUiSettleTimeoutMs });
|
||||
await expect
|
||||
.poll(() => proxy.evidence.length, { timeout: 15_000 })
|
||||
.toBeGreaterThan(evidenceStartIndex);
|
||||
return { context, evidenceStartIndex, page };
|
||||
}
|
||||
|
||||
async function warmControlUiSource(baseUrl: string): Promise<void> {
|
||||
const context = await browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport,
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(60_000);
|
||||
const response = await page.goto(withGatewayUrl(baseUrl, proxy.trustedUrl));
|
||||
expect(response?.status()).toBe(200);
|
||||
await page.locator("openclaw-gateway-url-confirmation").waitFor();
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function closeContext(context: BrowserContext): Promise<void> {
|
||||
openContexts.delete(context);
|
||||
await context.close();
|
||||
}
|
||||
|
||||
async function captureChromiumScreenshot(page: Page, fileName: string): Promise<void> {
|
||||
if (!captureUiProofEnabled) {
|
||||
return;
|
||||
}
|
||||
const session = await page.context().newCDPSession(page);
|
||||
try {
|
||||
// The live dashboard keeps rendering while RPCs settle. Capture the current
|
||||
// Chromium surface directly so proof does not wait on unrelated UI activity.
|
||||
const result = await session.send("Page.captureScreenshot", {
|
||||
captureBeyondViewport: false,
|
||||
format: "png",
|
||||
fromSurface: true,
|
||||
});
|
||||
await writeFile(path.join(artifactDir, fileName), Buffer.from(result.data, "base64"));
|
||||
} finally {
|
||||
await session.detach();
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForConnectionEvidence(
|
||||
predicate: (entry: ProxyConnectionEvidence) => boolean,
|
||||
evidenceStartIndex: number,
|
||||
): Promise<ProxyConnectionEvidence> {
|
||||
const currentPageEvidence = () => proxy.evidence.slice(evidenceStartIndex);
|
||||
await expect.poll(() => currentPageEvidence().some(predicate), { timeout: 15_000 }).toBe(true);
|
||||
const entry = currentPageEvidence().find(predicate);
|
||||
if (!entry) {
|
||||
throw new Error("expected reverse-proxy connection evidence");
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
async function waitForVisibleFailure(page: Page, expectedText: string): Promise<string> {
|
||||
const failure = page.locator(".login-gate__failure");
|
||||
await failure.waitFor();
|
||||
expect(await failure.getAttribute("role")).toBe("alert");
|
||||
const raw = (await failure.locator(".login-gate__failure-raw").textContent()) ?? "";
|
||||
expect(raw.toLowerCase()).toContain(expectedText.toLowerCase());
|
||||
expect(await failure.locator(".login-gate__failure-steps").isVisible()).toBe(true);
|
||||
expect(await page.locator("openclaw-app-shell").count()).toBe(0);
|
||||
return raw;
|
||||
}
|
||||
|
||||
async function isPortClosed(host: string, port: number): Promise<boolean> {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const socket = net.createConnection({ host, port });
|
||||
const finish = (closed: boolean) => {
|
||||
socket.destroy();
|
||||
resolve(closed);
|
||||
};
|
||||
socket.once("connect", () => finish(false));
|
||||
socket.once("error", () => finish(true));
|
||||
socket.setTimeout(1_000, () => finish(true));
|
||||
});
|
||||
}
|
||||
|
||||
describeControlUiE2e("Control UI real auth transports E2E", () => {
|
||||
beforeAll(async () => {
|
||||
if (!chromiumAvailable) {
|
||||
throw new Error(
|
||||
`Playwright Chromium is not installed or cannot start at ${chromiumExecutablePath}.`,
|
||||
);
|
||||
}
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
allowedUi = await startControlUiE2eServer();
|
||||
rejectedUi = await startControlUiE2eServer(undefined, { source: true });
|
||||
gateway = await startRealGateway(new URL(allowedUi.baseUrl).origin);
|
||||
proxy = await startRealTransportProxy(gateway.url);
|
||||
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
|
||||
await warmControlUiSource(allowedUi.baseUrl);
|
||||
await warmControlUiSource(rejectedUi.baseUrl);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await Promise.all([...openContexts].map((context) => context.close().catch(() => {})));
|
||||
openContexts.clear();
|
||||
const cleanupResults = await Promise.allSettled([
|
||||
browser?.close(),
|
||||
proxy?.close(),
|
||||
gateway?.cleanup(),
|
||||
allowedUi?.close(),
|
||||
rejectedUi?.close(),
|
||||
]);
|
||||
expect(
|
||||
cleanupResults
|
||||
.filter((result) => result.status === "rejected")
|
||||
.map((result) => String(result.reason)),
|
||||
).toEqual([]);
|
||||
|
||||
const cleanup = {
|
||||
gatewayPortClosed: gateway ? await isPortClosed("127.0.0.1", gateway.port) : true,
|
||||
proxyPortClosed: proxy ? await isPortClosed("127.0.0.1", proxy.port) : true,
|
||||
};
|
||||
await writeFile(
|
||||
path.join(artifactDir, "cleanup-summary.json"),
|
||||
`${JSON.stringify(cleanup, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
expect(cleanup).toEqual({
|
||||
gatewayPortClosed: true,
|
||||
proxyPortClosed: true,
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all([...openContexts].map((context) => context.close().catch(() => {})));
|
||||
openContexts.clear();
|
||||
});
|
||||
|
||||
it("connects through the trusted path and rejects the untrusted proxy path", async () => {
|
||||
const connected = await createBrowserPage(allowedUi.baseUrl, proxy.trustedUrl);
|
||||
const connectedErrors: string[] = [];
|
||||
connected.page.on("pageerror", (error) => connectedErrors.push(String(error)));
|
||||
await connected.page
|
||||
.locator("openclaw-app-shell")
|
||||
.waitFor({ timeout: controlUiSettleTimeoutMs });
|
||||
const trustedEvidence = await waitForConnectionEvidence(
|
||||
(entry) =>
|
||||
entry.route === "trusted" &&
|
||||
entry.gatewayResult?.ok === true &&
|
||||
entry.gatewayResult.helloType === "hello-ok",
|
||||
connected.evidenceStartIndex,
|
||||
);
|
||||
expect(trustedEvidence.browserConnect).toMatchObject({
|
||||
authFields: [],
|
||||
clientId: "openclaw-control-ui",
|
||||
clientMode: "webchat",
|
||||
hasDevice: true,
|
||||
});
|
||||
expect(trustedEvidence.identityInjected).toBe(true);
|
||||
expect(trustedEvidence.requiredHeaderInjected).toBe(true);
|
||||
await captureChromiumScreenshot(connected.page, "01-trusted-proxy-connected.png");
|
||||
expect(connectedErrors).toEqual([]);
|
||||
await closeContext(connected.context);
|
||||
|
||||
const rejected = await createBrowserPage(allowedUi.baseUrl, proxy.untrustedUrl);
|
||||
const rejectedErrors: string[] = [];
|
||||
rejected.page.on("pageerror", (error) => rejectedErrors.push(String(error)));
|
||||
const expectedReason = "trusted_proxy_missing_header_x-forwarded-proto";
|
||||
await waitForVisibleFailure(rejected.page, "unauthorized");
|
||||
const untrustedEvidence = await waitForConnectionEvidence(
|
||||
(entry) => entry.route === "untrusted" && entry.gatewayResult?.ok === false,
|
||||
rejected.evidenceStartIndex,
|
||||
);
|
||||
expect(untrustedEvidence.gatewayResult?.message).toContain("unauthorized");
|
||||
expect(untrustedEvidence.gatewayResult?.errorReason).toBe(expectedReason);
|
||||
expect(untrustedEvidence.identityInjected).toBe(false);
|
||||
expect(untrustedEvidence.requiredHeaderInjected).toBe(false);
|
||||
await captureChromiumScreenshot(rejected.page, "02-untrusted-proxy-rejected.png");
|
||||
expect(rejectedErrors).toEqual([]);
|
||||
|
||||
await writeFile(
|
||||
path.join(artifactDir, "trusted-proxy-behavior.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
connected: sanitizeProxyEvidence(trustedEvidence),
|
||||
rejected: sanitizeProxyEvidence(untrustedEvidence),
|
||||
visibleOutcomes: {
|
||||
connectedShell: true,
|
||||
rejectedRecovery: true,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
});
|
||||
|
||||
it("confirms gatewayUrl, accepts the allowed origin, and rejects an unlisted origin", async () => {
|
||||
const allowed = await createBrowserPage(allowedUi.baseUrl, proxy.trustedUrl);
|
||||
const allowedErrors: string[] = [];
|
||||
allowed.page.on("pageerror", (error) => allowedErrors.push(String(error)));
|
||||
await allowed.page.locator("openclaw-app-shell").waitFor({ timeout: controlUiSettleTimeoutMs });
|
||||
const allowedOrigin = new URL(allowedUi.baseUrl).origin;
|
||||
const allowedEvidence = await waitForConnectionEvidence(
|
||||
(entry) =>
|
||||
entry.route === "trusted" &&
|
||||
entry.browserOrigin === allowedOrigin &&
|
||||
entry.gatewayResult?.ok === true,
|
||||
allowed.evidenceStartIndex,
|
||||
);
|
||||
await captureChromiumScreenshot(allowed.page, "03-allowed-origin-connected.png");
|
||||
expect(allowedErrors).toEqual([]);
|
||||
await closeContext(allowed.context);
|
||||
|
||||
const rejected = await createBrowserPage(rejectedUi.baseUrl, proxy.trustedUrl);
|
||||
const rejectedErrors: string[] = [];
|
||||
rejected.page.on("pageerror", (error) => rejectedErrors.push(String(error)));
|
||||
await waitForVisibleFailure(rejected.page, "origin not allowed");
|
||||
const rejectedOrigin = new URL(rejectedUi.baseUrl).origin;
|
||||
const rejectedEvidence = await waitForConnectionEvidence(
|
||||
(entry) =>
|
||||
entry.route === "trusted" &&
|
||||
entry.browserOrigin === rejectedOrigin &&
|
||||
(entry.gatewayResult?.errorCode === ConnectErrorDetailCodes.CONTROL_UI_ORIGIN_NOT_ALLOWED ||
|
||||
entry.upstreamHandshakeStatus === 403),
|
||||
rejected.evidenceStartIndex,
|
||||
);
|
||||
await captureChromiumScreenshot(rejected.page, "04-rejected-origin-recovery.png");
|
||||
expect(rejectedErrors).toEqual([]);
|
||||
|
||||
await writeFile(
|
||||
path.join(artifactDir, "allowed-origins-behavior.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
allowed: sanitizeProxyEvidence(allowedEvidence),
|
||||
rejected: sanitizeProxyEvidence(rejectedEvidence),
|
||||
visibleOutcomes: {
|
||||
explicitGatewayUrlConfirmed: true,
|
||||
allowedOriginConnected: true,
|
||||
rejectedOriginRecovery: true,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user