From 6d159a45a83350293f1638fe7395a78baeec0a35 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 19:59:11 +0000 Subject: [PATCH] test: add outbound and hardlink helper coverage --- src/infra/binaries.test.ts | 38 ++++++++++++ src/infra/hardlink-guards.test.ts | 67 +++++++++++++++++++++ src/infra/infra-runtime.test.ts | 32 ---------- src/infra/outbound/identity.test.ts | 69 ++++++++++++++++++++++ src/infra/outbound/session-context.test.ts | 55 +++++++++++++++++ 5 files changed, 229 insertions(+), 32 deletions(-) create mode 100644 src/infra/binaries.test.ts create mode 100644 src/infra/hardlink-guards.test.ts create mode 100644 src/infra/outbound/identity.test.ts create mode 100644 src/infra/outbound/session-context.test.ts diff --git a/src/infra/binaries.test.ts b/src/infra/binaries.test.ts new file mode 100644 index 00000000000..425a2696fbf --- /dev/null +++ b/src/infra/binaries.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from "vitest"; +import type { runExec } from "../process/exec.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { ensureBinary } from "./binaries.js"; + +describe("ensureBinary", () => { + it("passes through when the binary exists", async () => { + const exec: typeof runExec = vi.fn().mockResolvedValue({ + stdout: "", + stderr: "", + }); + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await ensureBinary("node", exec, runtime); + + expect(exec).toHaveBeenCalledWith("which", ["node"]); + expect(runtime.error).not.toHaveBeenCalled(); + expect(runtime.exit).not.toHaveBeenCalled(); + }); + + it("logs and exits when the binary is missing", async () => { + const exec: typeof runExec = vi.fn().mockRejectedValue(new Error("missing")); + const error = vi.fn(); + const exit = vi.fn(() => { + throw new Error("exit"); + }); + + await expect(ensureBinary("ghost", exec, { log: vi.fn(), error, exit })).rejects.toThrow( + "exit", + ); + expect(error).toHaveBeenCalledWith("Missing required binary: ghost. Please install it."); + expect(exit).toHaveBeenCalledWith(1); + }); +}); diff --git a/src/infra/hardlink-guards.test.ts b/src/infra/hardlink-guards.test.ts new file mode 100644 index 00000000000..e96d826c1d8 --- /dev/null +++ b/src/infra/hardlink-guards.test.ts @@ -0,0 +1,67 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { withTempDir } from "../test-helpers/temp-dir.js"; +import { assertNoHardlinkedFinalPath } from "./hardlink-guards.js"; + +describe("assertNoHardlinkedFinalPath", () => { + it("allows missing paths, directories, and explicit unlink opt-in", async () => { + await withTempDir({ prefix: "openclaw-hardlink-guards-" }, async (root) => { + const dirPath = path.join(root, "dir"); + await fs.mkdir(dirPath); + + await expect( + assertNoHardlinkedFinalPath({ + filePath: path.join(root, "missing.txt"), + root, + boundaryLabel: "workspace", + }), + ).resolves.toBeUndefined(); + + await expect( + assertNoHardlinkedFinalPath({ + filePath: dirPath, + root, + boundaryLabel: "workspace", + }), + ).resolves.toBeUndefined(); + + const source = path.join(root, "source.txt"); + const linked = path.join(root, "linked.txt"); + await fs.writeFile(source, "hello", "utf8"); + await fs.link(source, linked); + + await expect( + assertNoHardlinkedFinalPath({ + filePath: linked, + root, + boundaryLabel: "workspace", + allowFinalHardlinkForUnlink: true, + }), + ).resolves.toBeUndefined(); + }); + }); + + it("rejects hardlinked files and shortens home-relative paths in the error", async () => { + await withTempDir({ prefix: "openclaw-hardlink-guards-" }, async (root) => { + const source = path.join(root, "source.txt"); + const linked = path.join(root, "linked.txt"); + await fs.writeFile(source, "hello", "utf8"); + await fs.link(source, linked); + const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue(root); + + try { + await expect( + assertNoHardlinkedFinalPath({ + filePath: linked, + root, + boundaryLabel: "workspace", + }), + ).rejects.toThrow("Hardlinked path is not allowed under workspace (~): ~/linked.txt"); + } finally { + homedirSpy.mockRestore(); + } + }); + }); +}); diff --git a/src/infra/infra-runtime.test.ts b/src/infra/infra-runtime.test.ts index 1596b73bbe8..2072f8f2da3 100644 --- a/src/infra/infra-runtime.test.ts +++ b/src/infra/infra-runtime.test.ts @@ -1,8 +1,5 @@ import os from "node:os"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { runExec } from "../process/exec.js"; -import type { RuntimeEnv } from "../runtime.js"; -import { ensureBinary } from "./binaries.js"; import { __testing, consumeGatewaySigusr1RestartAuthorization, @@ -31,35 +28,6 @@ describe("infra runtime", () => { }); } - describe("ensureBinary", () => { - it("passes through when binary exists", async () => { - const exec: typeof runExec = vi.fn().mockResolvedValue({ - stdout: "", - stderr: "", - }); - const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - }; - await ensureBinary("node", exec, runtime); - expect(exec).toHaveBeenCalledWith("which", ["node"]); - }); - - it("logs and exits when missing", async () => { - const exec: typeof runExec = vi.fn().mockRejectedValue(new Error("missing")); - const error = vi.fn(); - const exit = vi.fn(() => { - throw new Error("exit"); - }); - await expect(ensureBinary("ghost", exec, { log: vi.fn(), error, exit })).rejects.toThrow( - "exit", - ); - expect(error).toHaveBeenCalledWith("Missing required binary: ghost. Please install it."); - expect(exit).toHaveBeenCalledWith(1); - }); - }); - describe("restart authorization", () => { setupRestartSignalSuite(); diff --git a/src/infra/outbound/identity.test.ts b/src/infra/outbound/identity.test.ts new file mode 100644 index 00000000000..ea1c3623fbc --- /dev/null +++ b/src/infra/outbound/identity.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from "vitest"; + +const resolveAgentIdentityMock = vi.hoisted(() => vi.fn()); +const resolveAgentAvatarMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../agents/identity.js", () => ({ + resolveAgentIdentity: (...args: unknown[]) => resolveAgentIdentityMock(...args), +})); + +vi.mock("../../agents/identity-avatar.js", () => ({ + resolveAgentAvatar: (...args: unknown[]) => resolveAgentAvatarMock(...args), +})); + +import { normalizeOutboundIdentity, resolveAgentOutboundIdentity } from "./identity.js"; + +describe("normalizeOutboundIdentity", () => { + it("trims fields and drops empty identities", () => { + expect( + normalizeOutboundIdentity({ + name: " Demo Bot ", + avatarUrl: " https://example.com/a.png ", + emoji: " 🤖 ", + }), + ).toEqual({ + name: "Demo Bot", + avatarUrl: "https://example.com/a.png", + emoji: "🤖", + }); + expect( + normalizeOutboundIdentity({ + name: " ", + avatarUrl: "\n", + emoji: "", + }), + ).toBeUndefined(); + }); +}); + +describe("resolveAgentOutboundIdentity", () => { + it("builds normalized identity data and keeps only remote avatars", () => { + resolveAgentIdentityMock.mockReturnValueOnce({ + name: " Agent Smith ", + emoji: " 🕶️ ", + }); + resolveAgentAvatarMock.mockReturnValueOnce({ + kind: "remote", + url: "https://example.com/avatar.png", + }); + + expect(resolveAgentOutboundIdentity({} as never, "main")).toEqual({ + name: "Agent Smith", + emoji: "🕶️", + avatarUrl: "https://example.com/avatar.png", + }); + }); + + it("drops blank and non-remote avatar values after normalization", () => { + resolveAgentIdentityMock.mockReturnValueOnce({ + name: " ", + emoji: "", + }); + resolveAgentAvatarMock.mockReturnValueOnce({ + kind: "data", + dataUrl: "data:image/png;base64,abc", + }); + + expect(resolveAgentOutboundIdentity({} as never, "main")).toBeUndefined(); + }); +}); diff --git a/src/infra/outbound/session-context.test.ts b/src/infra/outbound/session-context.test.ts new file mode 100644 index 00000000000..c24ede1f3e8 --- /dev/null +++ b/src/infra/outbound/session-context.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from "vitest"; + +const resolveSessionAgentIdMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../agents/agent-scope.js", () => ({ + resolveSessionAgentId: (...args: unknown[]) => resolveSessionAgentIdMock(...args), +})); + +import { buildOutboundSessionContext } from "./session-context.js"; + +describe("buildOutboundSessionContext", () => { + it("returns undefined when both session key and agent id are blank", () => { + expect( + buildOutboundSessionContext({ + cfg: {} as never, + sessionKey: " ", + agentId: null, + }), + ).toBeUndefined(); + expect(resolveSessionAgentIdMock).not.toHaveBeenCalled(); + }); + + it("derives the agent id from the trimmed session key when no explicit agent is given", () => { + resolveSessionAgentIdMock.mockReturnValueOnce("derived-agent"); + + expect( + buildOutboundSessionContext({ + cfg: { agents: {} } as never, + sessionKey: " session:main:123 ", + }), + ).toEqual({ + key: "session:main:123", + agentId: "derived-agent", + }); + expect(resolveSessionAgentIdMock).toHaveBeenCalledWith({ + sessionKey: "session:main:123", + config: { agents: {} }, + }); + }); + + it("prefers an explicit trimmed agent id over the derived one", () => { + resolveSessionAgentIdMock.mockReturnValueOnce("derived-agent"); + + expect( + buildOutboundSessionContext({ + cfg: {} as never, + sessionKey: "session:main:123", + agentId: " explicit-agent ", + }), + ).toEqual({ + key: "session:main:123", + agentId: "explicit-agent", + }); + }); +});