Skip to content

Commit a3ece09

Browse files
committed
refactor: share control ui hardlink asset setup
1 parent 6a1ba52 commit a3ece09

File tree

1 file changed

+103
-123
lines changed

1 file changed

+103
-123
lines changed

src/gateway/control-ui.http.test.ts

Lines changed: 103 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -40,25 +40,6 @@ describe("handleControlUiHttpRequest", () => {
4040
expect(params.end).toHaveBeenCalledWith("Not Found");
4141
}
4242

43-
function expectUnhandledRoutes(params: {
44-
urls: string[];
45-
method: "GET" | "POST";
46-
rootPath: string;
47-
basePath?: string;
48-
expectationLabel: string;
49-
}) {
50-
for (const url of params.urls) {
51-
const { handled, end } = runControlUiRequest({
52-
url,
53-
method: params.method,
54-
rootPath: params.rootPath,
55-
...(params.basePath ? { basePath: params.basePath } : {}),
56-
});
57-
expect(handled, `${params.expectationLabel}: ${url}`).toBe(false);
58-
expect(end, `${params.expectationLabel}: ${url}`).not.toHaveBeenCalled();
59-
}
60-
}
61-
6243
function runControlUiRequest(params: {
6344
url: string;
6445
method: "GET" | "HEAD" | "POST";
@@ -104,6 +85,13 @@ describe("handleControlUiHttpRequest", () => {
10485
return { assetsDir, filePath };
10586
}
10687

88+
async function createHardlinkedAssetFile(rootPath: string) {
89+
const { filePath } = await writeAssetFile(rootPath, "app.js", "console.log('hi');");
90+
const hardlinkPath = path.join(path.dirname(filePath), "app.hl.js");
91+
await fs.link(filePath, hardlinkPath);
92+
return hardlinkPath;
93+
}
94+
10795
async function withBasePathRootFixture<T>(params: {
10896
siblingDir: string;
10997
fn: (paths: { root: string; sibling: string }) => Promise<T>;
@@ -166,80 +154,53 @@ describe("handleControlUiHttpRequest", () => {
166154
});
167155
});
168156

169-
it.each([
170-
{
171-
name: "at root",
172-
url: CONTROL_UI_BOOTSTRAP_CONFIG_PATH,
173-
expectedBasePath: "",
174-
assistantName: "</script><script>alert(1)//",
175-
assistantAvatar: "</script>.png",
176-
expectedAvatarUrl: "/avatar/main",
177-
},
178-
{
179-
name: "under basePath",
180-
url: `/openclaw${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`,
181-
basePath: "/openclaw",
182-
expectedBasePath: "/openclaw",
183-
assistantName: "Ops",
184-
assistantAvatar: "ops.png",
185-
expectedAvatarUrl: "/openclaw/avatar/main",
186-
},
187-
])("serves bootstrap config JSON $name", async (testCase) => {
157+
it("serves bootstrap config JSON", async () => {
188158
await withControlUiRoot({
189159
fn: async (tmp) => {
190160
const { res, end } = makeMockHttpResponse();
191161
const handled = handleControlUiHttpRequest(
192-
{ url: testCase.url, method: "GET" } as IncomingMessage,
162+
{ url: CONTROL_UI_BOOTSTRAP_CONFIG_PATH, method: "GET" } as IncomingMessage,
193163
res,
194164
{
195-
...(testCase.basePath ? { basePath: testCase.basePath } : {}),
196165
root: { kind: "resolved", path: tmp },
197166
config: {
198167
agents: { defaults: { workspace: tmp } },
199-
ui: {
200-
assistant: {
201-
name: testCase.assistantName,
202-
avatar: testCase.assistantAvatar,
203-
},
204-
},
168+
ui: { assistant: { name: "</script><script>alert(1)//", avatar: "</script>.png" } },
205169
},
206170
},
207171
);
208172
expect(handled).toBe(true);
209173
const parsed = parseBootstrapPayload(end);
210-
expect(parsed.basePath).toBe(testCase.expectedBasePath);
211-
expect(parsed.assistantName).toBe(testCase.assistantName);
212-
expect(parsed.assistantAvatar).toBe(testCase.expectedAvatarUrl);
174+
expect(parsed.basePath).toBe("");
175+
expect(parsed.assistantName).toBe("</script><script>alert(1)//");
176+
expect(parsed.assistantAvatar).toBe("/avatar/main");
213177
expect(parsed.assistantAgentId).toBe("main");
214178
},
215179
});
216180
});
217181

218-
it.each([
219-
{
220-
name: "at root",
221-
url: CONTROL_UI_BOOTSTRAP_CONFIG_PATH,
222-
},
223-
{
224-
name: "under basePath",
225-
url: `/openclaw${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`,
226-
basePath: "/openclaw",
227-
},
228-
])("serves bootstrap config HEAD $name without writing a body", async (testCase) => {
182+
it("serves bootstrap config JSON under basePath", async () => {
229183
await withControlUiRoot({
230184
fn: async (tmp) => {
231185
const { res, end } = makeMockHttpResponse();
232186
const handled = handleControlUiHttpRequest(
233-
{ url: testCase.url, method: "HEAD" } as IncomingMessage,
187+
{ url: `/openclaw${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`, method: "GET" } as IncomingMessage,
234188
res,
235189
{
236-
...(testCase.basePath ? { basePath: testCase.basePath } : {}),
190+
basePath: "/openclaw",
237191
root: { kind: "resolved", path: tmp },
192+
config: {
193+
agents: { defaults: { workspace: tmp } },
194+
ui: { assistant: { name: "Ops", avatar: "ops.png" } },
195+
},
238196
},
239197
);
240198
expect(handled).toBe(true);
241-
expect(res.statusCode).toBe(200);
242-
expect(end.mock.calls[0]?.length ?? -1).toBe(0);
199+
const parsed = parseBootstrapPayload(end);
200+
expect(parsed.basePath).toBe("/openclaw");
201+
expect(parsed.assistantName).toBe("Ops");
202+
expect(parsed.assistantAvatar).toBe("/openclaw/avatar/main");
203+
expect(parsed.assistantAgentId).toBe("main");
243204
},
244205
});
245206
});
@@ -396,113 +357,132 @@ describe("handleControlUiHttpRequest", () => {
396357
});
397358
});
398359

399-
it.each([
400-
{
401-
name: "rejects hardlinked asset files for custom/resolved roots",
402-
rootKind: "resolved" as const,
403-
expectedStatus: 404,
404-
expectedBody: "Not Found",
405-
},
406-
{
407-
name: "serves hardlinked asset files for bundled roots",
408-
rootKind: "bundled" as const,
409-
expectedStatus: 200,
410-
expectedBody: "console.log('hi');",
411-
},
412-
])("$name", async (testCase) => {
360+
it("rejects hardlinked asset files for custom/resolved roots (security boundary)", async () => {
413361
await withControlUiRoot({
414362
fn: async (tmp) => {
415-
const assetsDir = path.join(tmp, "assets");
416-
await fs.mkdir(assetsDir, { recursive: true });
417-
await fs.writeFile(path.join(assetsDir, "app.js"), "console.log('hi');");
418-
await fs.link(path.join(assetsDir, "app.js"), path.join(assetsDir, "app.hl.js"));
363+
await createHardlinkedAssetFile(tmp);
419364

420365
const { res, end, handled } = runControlUiRequest({
421366
url: "/assets/app.hl.js",
422367
method: "GET",
423368
rootPath: tmp,
424-
rootKind: testCase.rootKind,
425369
});
426370

427371
expect(handled).toBe(true);
428-
expect(res.statusCode).toBe(testCase.expectedStatus);
429-
expect(String(end.mock.calls[0]?.[0] ?? "")).toBe(testCase.expectedBody);
372+
expect(res.statusCode).toBe(404);
373+
expect(end).toHaveBeenCalledWith("Not Found");
430374
},
431375
});
432376
});
433377

434-
it("does not handle POST to root-mounted paths (plugin webhook passthrough)", async () => {
378+
it("serves hardlinked asset files for bundled roots (pnpm global install)", async () => {
435379
await withControlUiRoot({
436380
fn: async (tmp) => {
437-
expectUnhandledRoutes({
438-
urls: ["/bluebubbles-webhook", "/custom-webhook", "/callback"],
439-
method: "POST",
381+
await createHardlinkedAssetFile(tmp);
382+
383+
const { res, end, handled } = runControlUiRequest({
384+
url: "/assets/app.hl.js",
385+
method: "GET",
440386
rootPath: tmp,
441-
expectationLabel: "POST should pass through to plugin handlers",
387+
rootKind: "bundled",
442388
});
389+
390+
expect(handled).toBe(true);
391+
expect(res.statusCode).toBe(200);
392+
expect(String(end.mock.calls[0]?.[0] ?? "")).toBe("console.log('hi');");
393+
},
394+
});
395+
});
396+
397+
it("does not handle POST to root-mounted paths (plugin webhook passthrough)", async () => {
398+
await withControlUiRoot({
399+
fn: async (tmp) => {
400+
for (const webhookPath of ["/bluebubbles-webhook", "/custom-webhook", "/callback"]) {
401+
const { res } = makeMockHttpResponse();
402+
const handled = handleControlUiHttpRequest(
403+
{ url: webhookPath, method: "POST" } as IncomingMessage,
404+
res,
405+
{ root: { kind: "resolved", path: tmp } },
406+
);
407+
expect(handled, `POST to ${webhookPath} should pass through to plugin handlers`).toBe(
408+
false,
409+
);
410+
}
443411
},
444412
});
445413
});
446414

447415
it("does not handle POST to paths outside basePath", async () => {
448416
await withControlUiRoot({
449417
fn: async (tmp) => {
450-
expectUnhandledRoutes({
451-
urls: ["/bluebubbles-webhook"],
452-
method: "POST",
453-
rootPath: tmp,
454-
basePath: "/openclaw",
455-
expectationLabel: "POST outside basePath should pass through",
456-
});
418+
const { res } = makeMockHttpResponse();
419+
const handled = handleControlUiHttpRequest(
420+
{ url: "/bluebubbles-webhook", method: "POST" } as IncomingMessage,
421+
res,
422+
{ basePath: "/openclaw", root: { kind: "resolved", path: tmp } },
423+
);
424+
expect(handled).toBe(false);
457425
},
458426
});
459427
});
460428

461-
it.each([
462-
{
463-
name: "does not handle /api paths when basePath is empty",
464-
urls: ["/api", "/api/sessions", "/api/channels/nostr"],
465-
},
466-
{
467-
name: "does not handle /plugins paths when basePath is empty",
468-
urls: ["/plugins", "/plugins/diffs/view/abc/def"],
469-
},
470-
])("$name", async (testCase) => {
429+
it("does not handle /api paths when basePath is empty", async () => {
471430
await withControlUiRoot({
472431
fn: async (tmp) => {
473-
expectUnhandledRoutes({
474-
urls: testCase.urls,
475-
method: "GET",
476-
rootPath: tmp,
477-
expectationLabel: "expected route to not be handled",
478-
});
432+
for (const apiPath of ["/api", "/api/sessions", "/api/channels/nostr"]) {
433+
const { handled } = runControlUiRequest({
434+
url: apiPath,
435+
method: "GET",
436+
rootPath: tmp,
437+
});
438+
expect(handled, `expected ${apiPath} to not be handled`).toBe(false);
439+
}
440+
},
441+
});
442+
});
443+
444+
it("does not handle /plugins paths when basePath is empty", async () => {
445+
await withControlUiRoot({
446+
fn: async (tmp) => {
447+
for (const pluginPath of ["/plugins", "/plugins/diffs/view/abc/def"]) {
448+
const { handled } = runControlUiRequest({
449+
url: pluginPath,
450+
method: "GET",
451+
rootPath: tmp,
452+
});
453+
expect(handled, `expected ${pluginPath} to not be handled`).toBe(false);
454+
}
479455
},
480456
});
481457
});
482458

483459
it("falls through POST requests when basePath is empty", async () => {
484460
await withControlUiRoot({
485461
fn: async (tmp) => {
486-
expectUnhandledRoutes({
487-
urls: ["/webhook/bluebubbles"],
462+
const { handled, end } = runControlUiRequest({
463+
url: "/webhook/bluebubbles",
488464
method: "POST",
489465
rootPath: tmp,
490-
expectationLabel: "POST webhook should fall through",
491466
});
467+
expect(handled).toBe(false);
468+
expect(end).not.toHaveBeenCalled();
492469
},
493470
});
494471
});
495472

496473
it("falls through POST requests under configured basePath (plugin webhook passthrough)", async () => {
497474
await withControlUiRoot({
498475
fn: async (tmp) => {
499-
expectUnhandledRoutes({
500-
urls: ["/openclaw", "/openclaw/", "/openclaw/some-page"],
501-
method: "POST",
502-
rootPath: tmp,
503-
basePath: "/openclaw",
504-
expectationLabel: "POST under basePath should pass through to plugin handlers",
505-
});
476+
for (const route of ["/openclaw", "/openclaw/", "/openclaw/some-page"]) {
477+
const { handled, end } = runControlUiRequest({
478+
url: route,
479+
method: "POST",
480+
rootPath: tmp,
481+
basePath: "/openclaw",
482+
});
483+
expect(handled, `POST to ${route} should pass through to plugin handlers`).toBe(false);
484+
expect(end, `POST to ${route} should not write a response`).not.toHaveBeenCalled();
485+
}
506486
},
507487
});
508488
});

0 commit comments

Comments
 (0)