File size: 7,560 Bytes
34810d2 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 | // Core Canvas document HTTP response coverage.
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import type { IncomingMessage, ServerResponse } from "node:http";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createCanvasDocument, resolveCanvasDocumentsDir } from "./documents.js";
import { handleCanvasDocumentHttpRequest } from "./serve.runtime.js";
import { buildWidgetDocument } from "./wrap.js";
const tempDirs: string[] = [];
afterEach(async () => {
vi.unstubAllEnvs();
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
async function createStateDir(): Promise<string> {
const stateDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-serve-"));
tempDirs.push(stateDir);
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
return stateDir;
}
async function capture(url: string, method = "GET") {
const response = {
statusCode: 200,
headers: {} as Record<string, number | string | string[]>,
body: Buffer.alloc(0) as Buffer,
setHeader(name: string, value: number | string | readonly string[]) {
this.headers[name.toLowerCase()] = typeof value === "object" ? [...value] : value;
return this;
},
end(chunk?: string | Buffer) {
this.body = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk ?? "");
return this;
},
};
const handled = await handleCanvasDocumentHttpRequest(
{ method, url } as IncomingMessage,
response as unknown as ServerResponse,
);
return { handled, ...response, text: response.body.toString("utf8") };
}
describe("core canvas document host", () => {
it.each([
[
"stored",
`<!doctype html><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline' 'self'"><script src="/__openclaw__/a2ui/a2ui.bundle.js"></script>`,
],
[
"new",
buildWidgetDocument(
"Registered renderer",
'<script src="/__openclaw__/a2ui/a2ui-v0.9.bundle.js"></script>',
{ scriptOrigins: ["'self'"] },
),
],
])("preserves document-approved renderer sources for %s documents", async (_, html) => {
const stateDir = await createStateDir();
const document = await createCanvasDocument(
{ kind: "html_bundle", entrypoint: { type: "html", value: html }, cspSandbox: "scripts" },
{ stateDir },
);
const response = await capture(document.entryUrl);
const policy = String(response.headers["content-security-policy"]);
const scripts = policy
.split(";")
.find((directive) => directive.trim().startsWith("script-src "));
expect(scripts?.split(/\s+/)).toContain("'self'");
expect(policy).toContain("connect-src 'none'");
expect(policy).toContain("sandbox allow-scripts");
expect(response.text).toBe(html);
});
it("serves sandbox-marked HTML with the stable CSP header and no mutation", async () => {
const stateDir = await createStateDir();
const html = "<html><body>widget</body></html>";
const document = await createCanvasDocument(
{
id: "widget-1",
kind: "html_bundle",
entrypoint: { type: "html", value: html },
cspSandbox: "scripts",
},
{ stateDir },
);
const response = await capture(document.entryUrl);
expect(response.handled).toBe(true);
expect(response.statusCode).toBe(200);
expect(response.headers["content-type"]).toBe("text/html; charset=utf-8");
expect(response.headers["cache-control"]).toBe("no-store");
const csp = String(response.headers["content-security-policy"]);
expect(csp).toContain("sandbox allow-scripts");
expect(csp).toContain("default-src 'none'");
expect(csp).toContain("https://cdn.jsdelivr.net");
expect(csp).toContain("font-src data:");
expect(csp).toContain("connect-src 'none'");
expect(response.headers["referrer-policy"]).toBe("no-referrer");
expect(response.text).toBe(html);
});
it("omits the sandbox response header for unmarked documents", async () => {
const stateDir = await createStateDir();
const document = await createCanvasDocument(
{
id: "plain-1",
kind: "html_bundle",
entrypoint: { type: "html", value: "<html><body>plain</body></html>" },
},
{ stateDir },
);
const response = await capture(document.entryUrl);
expect(response.statusCode).toBe(200);
expect(response.headers["content-security-policy"]).toBeUndefined();
});
it("serves Content-Length on HEAD responses", async () => {
const stateDir = await createStateDir();
const html = "<html><body>widget</body></html>";
const document = await createCanvasDocument(
{
id: "widget-1",
kind: "html_bundle",
entrypoint: { type: "html", value: html },
cspSandbox: "scripts",
},
{ stateDir },
);
const css = "body { color: red; }";
await writeFile(
path.join(resolveCanvasDocumentsDir(stateDir), "widget-1", "style.css"),
css,
"utf8",
);
const cssUrl = document.entryUrl.replace(/index\.html$/, "style.css");
const getHtml = await capture(document.entryUrl);
const headHtml = await capture(document.entryUrl, "HEAD");
expect(headHtml.statusCode).toBe(200);
expect(headHtml.headers["content-length"]).toBe(String(getHtml.body.byteLength));
expect(headHtml.body.byteLength).toBe(0);
expect(headHtml.headers["content-type"]).toBe("text/html; charset=utf-8");
expect(headHtml.headers["content-security-policy"]).toBe(
getHtml.headers["content-security-policy"],
);
const getCss = await capture(cssUrl);
const headCss = await capture(cssUrl, "HEAD");
expect(getCss.statusCode).toBe(200);
expect(headCss.statusCode).toBe(200);
expect(headCss.headers["content-length"]).toBe(String(getCss.body.byteLength));
expect(headCss.body.byteLength).toBe(0);
// Invalid UTF-8 in a copied HTML file expands to U+FFFD when served, so the
// header must be measured from the decoded representation, not raw bytes.
const brokenBytes = Buffer.from([
0x3c, 0x68, 0x31, 0x3e, 0xff, 0xfe, 0x3c, 0x2f, 0x68, 0x31, 0x3e,
]);
await writeFile(
path.join(resolveCanvasDocumentsDir(stateDir), "widget-1", "broken.html"),
brokenBytes,
);
const brokenUrl = document.entryUrl.replace(/index\.html$/, "broken.html");
const getBroken = await capture(brokenUrl);
const headBroken = await capture(brokenUrl, "HEAD");
expect(getBroken.statusCode).toBe(200);
expect(getBroken.body.byteLength).toBeGreaterThan(brokenBytes.byteLength);
expect(headBroken.headers["content-length"]).toBe(String(getBroken.body.byteLength));
expect(headBroken.body.byteLength).toBe(0);
});
it("rejects unsupported methods and traversal paths", async () => {
await createStateDir();
const methodResponse = await capture(
"/__openclaw__/canvas/documents/widget-1/index.html",
"POST",
);
expect(methodResponse.statusCode).toBe(405);
expect(methodResponse.text).toBe("Method Not Allowed");
const traversalResponse = await capture(
"/__openclaw__/canvas/documents/../widget-1/index.html",
);
expect(traversalResponse.handled).toBe(false);
const missingResponse = await capture("/__openclaw__/canvas/documents/widget-1/index.html");
expect(missingResponse.statusCode).toBe(404);
expect(missingResponse.text).toBe("not found");
});
});
|