Files
openclaw/src/cli/plugins-cli.marketplace-refresh.test.ts
2026-06-28 15:44:27 -07:00

329 lines
11 KiB
TypeScript

// Covers the hosted OpenClaw marketplace feed refresh command.
import { mkdtemp, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
const defaultRuntime = {
error: vi.fn(),
exit: vi.fn((code: number) => {
throw new Error(`exit ${code}`);
}),
log: vi.fn(),
writeJson: vi.fn(),
};
return {
defaultRuntime,
getRuntimeConfig: vi.fn(),
loadConfiguredHostedOfficialExternalPluginCatalogEntries: vi.fn(),
};
});
vi.mock("../config/config.js", () => ({
assertConfigWriteAllowedInCurrentMode: vi.fn(),
getRuntimeConfig: mocks.getRuntimeConfig,
readConfigFileSnapshot: vi.fn(),
replaceConfigFile: vi.fn(),
}));
vi.mock("../runtime.js", () => ({
defaultRuntime: mocks.defaultRuntime,
}));
vi.mock("../plugins/official-external-plugin-catalog.js", () => ({
loadConfiguredHostedOfficialExternalPluginCatalogEntries:
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries,
}));
async function createTimelinePath(): Promise<string> {
const dir = await mkdtemp(path.join(tmpdir(), "openclaw-marketplace-refresh-"));
return path.join(dir, "timeline.jsonl");
}
async function readTimeline(pathname: string): Promise<Record<string, unknown>[]> {
const content = await readFile(pathname, "utf8");
return content
.trim()
.split("\n")
.map((line) => JSON.parse(line) as Record<string, unknown>);
}
describe("plugins marketplace refresh", () => {
beforeEach(() => {
mocks.defaultRuntime.error.mockClear();
mocks.defaultRuntime.exit.mockClear();
mocks.defaultRuntime.log.mockClear();
mocks.defaultRuntime.writeJson.mockClear();
mocks.getRuntimeConfig.mockReset();
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockReset();
vi.unstubAllEnvs();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("refreshes the configured marketplace feed and prints JSON", async () => {
const config = {
marketplaces: {
feeds: { acme: { url: "https://packages.acme.example/openclaw/feed" } },
},
};
mocks.getRuntimeConfig.mockReturnValue(config);
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({
source: "hosted",
entries: [{ name: "@acme/calendar" }, { name: "@acme/docs" }],
feed: {
schemaVersion: 1,
id: "acme-marketplace",
generatedAt: "2026-06-23T00:00:00.000Z",
sequence: 7,
entries: [],
},
metadata: {
url: "https://packages.acme.example/openclaw/feed",
status: 200,
checksum: "feed-sha",
etag: '"abc"',
},
});
const { runPluginMarketplaceRefreshCommand } = await import("./plugins-cli.runtime.js");
await runPluginMarketplaceRefreshCommand({
feedProfile: "acme",
expectedSha256: "feed-sha",
json: true,
});
expect(mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries).toHaveBeenCalledWith(
config,
{ feedProfile: "acme", expectedSha256: "feed-sha", requireSnapshotWrite: true },
);
expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith({
source: "hosted",
entries: 2,
feed: {
id: "acme-marketplace",
generatedAt: "2026-06-23T00:00:00.000Z",
sequence: 7,
},
metadata: {
url: "https://packages.acme.example/openclaw/feed",
status: 200,
checksum: "feed-sha",
etag: '"abc"',
},
});
});
it("normalizes bare SHA-256 pins before refreshing", async () => {
const config = {
marketplaces: {
feeds: { acme: { url: "https://packages.acme.example/openclaw/feed" } },
},
};
mocks.getRuntimeConfig.mockReturnValue(config);
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({
source: "hosted",
entries: [{ name: "@acme/calendar" }],
feed: {
schemaVersion: 1,
id: "acme-marketplace",
generatedAt: "2026-06-23T00:00:00.000Z",
sequence: 7,
entries: [],
},
metadata: {
url: "https://packages.acme.example/openclaw/feed",
status: 200,
checksum: "sha256:abcdef",
},
});
const { runPluginMarketplaceRefreshCommand } = await import("./plugins-cli.runtime.js");
await runPluginMarketplaceRefreshCommand({
feedProfile: "acme",
expectedSha256: "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789",
json: true,
});
expect(mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries).toHaveBeenCalledWith(
config,
{
feedProfile: "acme",
expectedSha256: "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
requireSnapshotWrite: true,
},
);
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockClear();
await runPluginMarketplaceRefreshCommand({
feedProfile: "acme",
expectedSha256: "sha256:ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789",
json: true,
});
expect(mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries).toHaveBeenCalledWith(
config,
{
feedProfile: "acme",
expectedSha256: "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
requireSnapshotWrite: true,
},
);
});
it("reports bundled fallback without failing the command", async () => {
mocks.getRuntimeConfig.mockReturnValue({});
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({
source: "bundled-fallback",
entries: [{ name: "@openclaw/acpx" }],
error: "hosted catalog feed returned HTTP 503",
metadata: {
url: "https://clawhub.ai/v1/feeds/plugins",
status: 503,
},
});
const { runPluginMarketplaceRefreshCommand } = await import("./plugins-cli.runtime.js");
await runPluginMarketplaceRefreshCommand({});
const output = mocks.defaultRuntime.log.mock.calls.map(([value]) => String(value)).join("\n");
expect(output).toContain("bundled fallback");
expect(output).toContain("hosted catalog feed returned HTTP 503");
expect(mocks.defaultRuntime.exit).not.toHaveBeenCalled();
});
it("redacts query-bearing feed URLs from refresh output", async () => {
mocks.getRuntimeConfig.mockReturnValue({});
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({
source: "bundled-fallback",
entries: [{ name: "@openclaw/acpx" }],
error:
"hosted catalog feed fetch failed for https://clawhub.ai/v1/feeds/plugins?token=secret#frag",
metadata: {
url: "https://clawhub.ai/v1/feeds/plugins?token=secret#frag",
status: 503,
},
});
const { runPluginMarketplaceRefreshCommand } = await import("./plugins-cli.runtime.js");
await runPluginMarketplaceRefreshCommand({
feedUrl: "https://clawhub.ai/v1/feeds/plugins?token=secret#frag",
json: true,
});
expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({ url: "https://clawhub.ai/v1/feeds/plugins" }),
error: "hosted catalog feed fetch failed for https://clawhub.ai/v1/feeds/plugins",
}),
);
mocks.defaultRuntime.writeJson.mockClear();
mocks.defaultRuntime.log.mockClear();
await runPluginMarketplaceRefreshCommand({
feedUrl: "https://clawhub.ai/v1/feeds/plugins?token=secret#frag",
});
const output = mocks.defaultRuntime.log.mock.calls.map(([value]) => String(value)).join("\n");
expect(output).toContain("https://clawhub.ai/v1/feeds/plugins");
expect(output).not.toContain("token=secret");
expect(output).not.toContain("#frag");
});
it("fails checksum-pinned refreshes that fall back", async () => {
mocks.getRuntimeConfig.mockReturnValue({});
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({
source: "bundled-fallback",
entries: [{ name: "@openclaw/acpx" }],
error: "hosted catalog feed checksum mismatch: expected sha256:expected",
metadata: {
url: "https://clawhub.ai/v1/feeds/plugins",
status: 200,
checksum: "sha256:actual",
},
});
const { runPluginMarketplaceRefreshCommand } = await import("./plugins-cli.runtime.js");
await expect(
runPluginMarketplaceRefreshCommand({ expectedSha256: "sha256:expected", json: true }),
).rejects.toThrow("exit 1");
expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith(
expect.objectContaining({ source: "bundled-fallback" }),
);
expect(mocks.defaultRuntime.error).toHaveBeenCalledWith(
"Pinned marketplace feed refresh did not accept a fresh hosted payload (source: bundled-fallback).",
);
expect(mocks.defaultRuntime.exit).toHaveBeenCalledWith(1);
});
it("emits bounded diagnostics for refresh without raw feed URLs", async () => {
const timelinePath = await createTimelinePath();
vi.stubEnv("OPENCLAW_DIAGNOSTICS_TIMELINE_PATH", timelinePath);
const config = {
diagnostics: { flags: ["timeline"] },
marketplaces: {
feeds: { acme: { url: "https://packages.acme.example/openclaw/feed" } },
},
};
mocks.getRuntimeConfig.mockReturnValue(config);
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({
source: "hosted",
entries: [{ name: "@acme/calendar" }, { name: "@acme/docs" }],
feed: {
schemaVersion: 1,
id: "acme-marketplace",
generatedAt: "2026-06-23T00:00:00.000Z",
sequence: 7,
entries: [],
},
metadata: {
url: "https://user:secret@packages.acme.example/openclaw/feed?token=leak#frag",
status: 200,
checksum: "feed-sha",
etag: '"abc"',
},
});
const { runPluginMarketplaceRefreshCommand } = await import("./plugins-cli.runtime.js");
await runPluginMarketplaceRefreshCommand({
expectedSha256: "feed-sha",
feedProfile: "acme",
feedUrl: "https://override.example/openclaw/feed?token=override-leak",
});
const [event] = await readTimeline(timelinePath);
expect(mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries).toHaveBeenCalledWith(
config,
expect.objectContaining({
feedUrl: "https://override.example/openclaw/feed?token=override-leak",
}),
);
expect(event?.name).toBe("plugins.marketplace.feed.refresh");
expect(event?.phase).toBe("plugin-marketplace");
expect(event?.attributes).toMatchObject({
command: "refresh",
entries: 2,
expectedSha256Provided: true,
feedIdPresent: true,
feedProfileProvided: true,
feedSequence: 7,
feedUrlOverride: true,
hasEtag: true,
payloadChecksumPresent: true,
source: "hosted",
});
expect(JSON.stringify(event)).not.toContain("packages.acme.example");
expect(JSON.stringify(event)).not.toContain("acme-marketplace");
expect(JSON.stringify(event)).not.toContain("feed-sha");
expect(JSON.stringify(event)).not.toContain("secret");
expect(JSON.stringify(event)).not.toContain("token=leak");
expect(JSON.stringify(event)).not.toContain("override-leak");
});
});