From 1fefd4e67ffbd6457e6f8acaac1138e1f9bc96c6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 19:54:09 +0000 Subject: [PATCH] test: add install and pairing helper coverage --- src/infra/install-from-npm-spec.test.ts | 77 ++++++++++++++ src/infra/install-target.test.ts | 129 ++++++++++++++++++++++++ src/infra/json-file.test.ts | 33 ++++++ src/infra/pairing-pending.test.ts | 48 +++++++++ 4 files changed, 287 insertions(+) create mode 100644 src/infra/install-from-npm-spec.test.ts create mode 100644 src/infra/install-target.test.ts create mode 100644 src/infra/json-file.test.ts create mode 100644 src/infra/pairing-pending.test.ts diff --git a/src/infra/install-from-npm-spec.test.ts b/src/infra/install-from-npm-spec.test.ts new file mode 100644 index 00000000000..f2e5132f96f --- /dev/null +++ b/src/infra/install-from-npm-spec.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from "vitest"; + +const validateRegistryNpmSpecMock = vi.hoisted(() => vi.fn()); +const installFromNpmSpecArchiveWithInstallerMock = vi.hoisted(() => vi.fn()); +const finalizeNpmSpecArchiveInstallMock = vi.hoisted(() => vi.fn()); + +vi.mock("./npm-registry-spec.js", () => ({ + validateRegistryNpmSpec: (...args: unknown[]) => validateRegistryNpmSpecMock(...args), +})); + +vi.mock("./npm-pack-install.js", () => ({ + installFromNpmSpecArchiveWithInstaller: (...args: unknown[]) => + installFromNpmSpecArchiveWithInstallerMock(...args), + finalizeNpmSpecArchiveInstall: (...args: unknown[]) => finalizeNpmSpecArchiveInstallMock(...args), +})); + +import { installFromValidatedNpmSpecArchive } from "./install-from-npm-spec.js"; + +describe("installFromValidatedNpmSpecArchive", () => { + it("trims the spec and returns validation errors before running the installer", async () => { + validateRegistryNpmSpecMock.mockReturnValueOnce("unsupported npm spec"); + + await expect( + installFromValidatedNpmSpecArchive({ + spec: " nope ", + timeoutMs: 30_000, + tempDirPrefix: "openclaw-npm-", + installFromArchive: vi.fn(), + archiveInstallParams: {}, + }), + ).resolves.toEqual({ ok: false, error: "unsupported npm spec" }); + + expect(validateRegistryNpmSpecMock).toHaveBeenCalledWith("nope"); + expect(installFromNpmSpecArchiveWithInstallerMock).not.toHaveBeenCalled(); + expect(finalizeNpmSpecArchiveInstallMock).not.toHaveBeenCalled(); + }); + + it("passes the trimmed spec through the archive installer and finalizer", async () => { + const installFromArchive = vi.fn(); + const warn = vi.fn(); + const onIntegrityDrift = vi.fn(); + const flowResult = { + ok: true, + installResult: { ok: true }, + npmResolution: { version: "1.2.3" }, + }; + const finalized = { ok: true, archivePath: "/tmp/pkg.tgz" }; + validateRegistryNpmSpecMock.mockReturnValueOnce(null); + installFromNpmSpecArchiveWithInstallerMock.mockResolvedValueOnce(flowResult); + finalizeNpmSpecArchiveInstallMock.mockReturnValueOnce(finalized); + + await expect( + installFromValidatedNpmSpecArchive({ + spec: " @openclaw/demo@beta ", + timeoutMs: 45_000, + tempDirPrefix: "openclaw-npm-", + expectedIntegrity: "sha512-demo", + onIntegrityDrift, + warn, + installFromArchive, + archiveInstallParams: { destination: "/tmp/demo" }, + }), + ).resolves.toBe(finalized); + + expect(installFromNpmSpecArchiveWithInstallerMock).toHaveBeenCalledWith({ + tempDirPrefix: "openclaw-npm-", + spec: "@openclaw/demo@beta", + timeoutMs: 45_000, + expectedIntegrity: "sha512-demo", + onIntegrityDrift, + warn, + installFromArchive, + archiveInstallParams: { destination: "/tmp/demo" }, + }); + expect(finalizeNpmSpecArchiveInstallMock).toHaveBeenCalledWith(flowResult); + }); +}); diff --git a/src/infra/install-target.test.ts b/src/infra/install-target.test.ts new file mode 100644 index 00000000000..211d5c1a99d --- /dev/null +++ b/src/infra/install-target.test.ts @@ -0,0 +1,129 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { withTempDir } from "../test-helpers/temp-dir.js"; + +const fileExistsMock = vi.hoisted(() => vi.fn()); +const resolveSafeInstallDirMock = vi.hoisted(() => vi.fn()); +const assertCanonicalPathWithinBaseMock = vi.hoisted(() => vi.fn()); + +vi.mock("./archive.js", () => ({ + fileExists: (...args: unknown[]) => fileExistsMock(...args), +})); + +vi.mock("./install-safe-path.js", () => ({ + resolveSafeInstallDir: (...args: unknown[]) => resolveSafeInstallDirMock(...args), + assertCanonicalPathWithinBase: (...args: unknown[]) => assertCanonicalPathWithinBaseMock(...args), +})); + +import { ensureInstallTargetAvailable, resolveCanonicalInstallTarget } from "./install-target.js"; + +beforeEach(() => { + fileExistsMock.mockReset(); + resolveSafeInstallDirMock.mockReset(); + assertCanonicalPathWithinBaseMock.mockReset(); +}); + +describe("resolveCanonicalInstallTarget", () => { + it("creates the base dir and returns early for invalid install ids", async () => { + await withTempDir({ prefix: "openclaw-install-target-" }, async (root) => { + const baseDir = path.join(root, "plugins"); + resolveSafeInstallDirMock.mockReturnValueOnce({ + ok: false, + error: "bad id", + }); + + await expect( + resolveCanonicalInstallTarget({ + baseDir, + id: "../oops", + invalidNameMessage: "bad id", + boundaryLabel: "plugin dir", + }), + ).resolves.toEqual({ ok: false, error: "bad id" }); + + await expect(fs.stat(baseDir)).resolves.toMatchObject({ isDirectory: expect.any(Function) }); + expect(assertCanonicalPathWithinBaseMock).not.toHaveBeenCalled(); + }); + }); + + it("returns canonical boundary errors for Error and non-Error throws", async () => { + await withTempDir({ prefix: "openclaw-install-target-" }, async (baseDir) => { + const targetDir = path.join(baseDir, "demo"); + resolveSafeInstallDirMock.mockReturnValue({ + ok: true, + path: targetDir, + }); + assertCanonicalPathWithinBaseMock.mockRejectedValueOnce(new Error("escaped")); + assertCanonicalPathWithinBaseMock.mockRejectedValueOnce("boom"); + + await expect( + resolveCanonicalInstallTarget({ + baseDir, + id: "demo", + invalidNameMessage: "bad id", + boundaryLabel: "plugin dir", + }), + ).resolves.toEqual({ ok: false, error: "escaped" }); + + await expect( + resolveCanonicalInstallTarget({ + baseDir, + id: "demo", + invalidNameMessage: "bad id", + boundaryLabel: "plugin dir", + }), + ).resolves.toEqual({ ok: false, error: "boom" }); + }); + }); + + it("returns the resolved target path on success", async () => { + await withTempDir({ prefix: "openclaw-install-target-" }, async (baseDir) => { + const targetDir = path.join(baseDir, "demo"); + resolveSafeInstallDirMock.mockReturnValueOnce({ + ok: true, + path: targetDir, + }); + + await expect( + resolveCanonicalInstallTarget({ + baseDir, + id: "demo", + invalidNameMessage: "bad id", + boundaryLabel: "plugin dir", + }), + ).resolves.toEqual({ ok: true, targetDir }); + }); + }); +}); + +describe("ensureInstallTargetAvailable", () => { + it("blocks only install mode when the target already exists", async () => { + fileExistsMock.mockResolvedValueOnce(true); + fileExistsMock.mockResolvedValueOnce(false); + + await expect( + ensureInstallTargetAvailable({ + mode: "install", + targetDir: "/tmp/demo", + alreadyExistsError: "already there", + }), + ).resolves.toEqual({ ok: false, error: "already there" }); + + await expect( + ensureInstallTargetAvailable({ + mode: "update", + targetDir: "/tmp/demo", + alreadyExistsError: "already there", + }), + ).resolves.toEqual({ ok: true }); + + await expect( + ensureInstallTargetAvailable({ + mode: "install", + targetDir: "/tmp/demo", + alreadyExistsError: "already there", + }), + ).resolves.toEqual({ ok: true }); + }); +}); diff --git a/src/infra/json-file.test.ts b/src/infra/json-file.test.ts new file mode 100644 index 00000000000..95def5fa54a --- /dev/null +++ b/src/infra/json-file.test.ts @@ -0,0 +1,33 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { withTempDir } from "../test-helpers/temp-dir.js"; +import { loadJsonFile, saveJsonFile } from "./json-file.js"; + +describe("json-file helpers", () => { + it("returns undefined for missing and invalid JSON files", async () => { + await withTempDir({ prefix: "openclaw-json-file-" }, async (root) => { + const pathname = path.join(root, "config.json"); + expect(loadJsonFile(pathname)).toBeUndefined(); + + fs.writeFileSync(pathname, "{", "utf8"); + expect(loadJsonFile(pathname)).toBeUndefined(); + }); + }); + + it("creates parent dirs, writes a trailing newline, and loads the saved object", async () => { + await withTempDir({ prefix: "openclaw-json-file-" }, async (root) => { + const pathname = path.join(root, "nested", "config.json"); + saveJsonFile(pathname, { enabled: true, count: 2 }); + + const raw = fs.readFileSync(pathname, "utf8"); + expect(raw.endsWith("\n")).toBe(true); + expect(loadJsonFile(pathname)).toEqual({ enabled: true, count: 2 }); + + const fileMode = fs.statSync(pathname).mode & 0o777; + const dirMode = fs.statSync(path.dirname(pathname)).mode & 0o777; + expect(fileMode).toBe(0o600); + expect(dirMode).toBe(0o700); + }); + }); +}); diff --git a/src/infra/pairing-pending.test.ts b/src/infra/pairing-pending.test.ts new file mode 100644 index 00000000000..30c2551176b --- /dev/null +++ b/src/infra/pairing-pending.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest"; +import { rejectPendingPairingRequest } from "./pairing-pending.js"; + +describe("rejectPendingPairingRequest", () => { + it("returns null and skips persistence when the request is missing", async () => { + const persistState = vi.fn(); + + await expect( + rejectPendingPairingRequest({ + requestId: "missing", + idKey: "deviceId", + loadState: async () => ({ pendingById: {} }), + persistState, + getId: (pending: { id: string }) => pending.id, + }), + ).resolves.toBeNull(); + + expect(persistState).not.toHaveBeenCalled(); + }); + + it("removes the request, persists, and returns the dynamic id key", async () => { + const state = { + pendingById: { + keep: { accountId: "keep-me" }, + reject: { accountId: "acct-42" }, + }, + }; + const persistState = vi.fn(async () => undefined); + + await expect( + rejectPendingPairingRequest({ + requestId: "reject", + idKey: "accountId", + loadState: async () => state, + persistState, + getId: (pending) => pending.accountId, + }), + ).resolves.toEqual({ + requestId: "reject", + accountId: "acct-42", + }); + + expect(state.pendingById).toEqual({ + keep: { accountId: "keep-me" }, + }); + expect(persistState).toHaveBeenCalledWith(state); + }); +});