test: add outbound and hardlink helper coverage

This commit is contained in:
Peter Steinberger
2026-03-13 19:59:11 +00:00
parent 7c95a25df7
commit 6d159a45a8
5 changed files with 229 additions and 32 deletions

View File

@@ -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);
});
});

View File

@@ -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();
}
});
});
});

View File

@@ -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();

View File

@@ -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();
});
});

View File

@@ -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",
});
});
});