mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: consent check, developer mode file installs, install counts and issue links for plugins
This commit is contained in:
1 parent
26a474bb3d
commit
08db1fcdde
28 files changed
+2248
-72
No files matched your search
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import * as tar from "tar";
|
||||
import {
|
||||
readArtifactFile,
|
||||
readArtifactManifest,
|
||||
sameCapabilities,
|
||||
} from "../../plugins/artifact-manifest.js";
|
||||
|
||||
function pack(files: Record<string, string>, prefix = ""): Buffer {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-artifact-"));
|
||||
try {
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
fs.mkdirSync(path.dirname(path.join(dir, name)), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, name), content);
|
||||
}
|
||||
const file = path.join(dir, "out.tmxplug");
|
||||
tar.c(
|
||||
{ gzip: true, sync: true, file, cwd: dir },
|
||||
Object.keys(files).map((name) => `${prefix}${name}`),
|
||||
);
|
||||
return fs.readFileSync(file);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe("readArtifactFile", () => {
|
||||
it("finds a file by name, with or without a ./ prefix", () => {
|
||||
const plain = pack({ "manifest.json": "{}", "dist/a.js": "x" });
|
||||
expect(readArtifactFile(plain, "dist/a.js")?.toString()).toBe("x");
|
||||
const dotted = pack({ "manifest.json": '{"id":"a"}' }, "./");
|
||||
expect(readArtifactFile(dotted, "manifest.json")?.toString()).toBe(
|
||||
'{"id":"a"}',
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for a missing file", () => {
|
||||
expect(readArtifactFile(pack({ "a.txt": "a" }), "manifest.json")).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readArtifactManifest", () => {
|
||||
it("parses the manifest", () => {
|
||||
expect(
|
||||
readArtifactManifest(pack({ "manifest.json": '{"id":"notes"}' })),
|
||||
).toEqual({ id: "notes" });
|
||||
});
|
||||
|
||||
it("refuses a file that is not an archive", () => {
|
||||
expect(() => readArtifactManifest(Buffer.from("nope"))).toThrow(
|
||||
/not a valid .tmxplug/,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses an archive with no manifest or a broken one", () => {
|
||||
expect(() => readArtifactManifest(pack({ "a.txt": "a" }))).toThrow(
|
||||
/no manifest.json/,
|
||||
);
|
||||
expect(() =>
|
||||
readArtifactManifest(pack({ "manifest.json": "{oops" })),
|
||||
).toThrow(/not valid JSON/);
|
||||
expect(() =>
|
||||
readArtifactManifest(pack({ "manifest.json": "[1]" })),
|
||||
).toThrow(/not an object/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sameCapabilities", () => {
|
||||
it("compares as sets", () => {
|
||||
expect(sameCapabilities(["a", "b"], ["b", "a"])).toBe(true);
|
||||
expect(sameCapabilities(["a"], ["a", "b"])).toBe(false);
|
||||
expect(sameCapabilities(["a", "c"], ["a", "b"])).toBe(false);
|
||||
expect(sameCapabilities([], [])).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
stats: null as null | {
|
||||
plugins: Map<string, { downloads: number; activeInstalls: number | null }>;
|
||||
},
|
||||
fail: false,
|
||||
upserted: [] as Array<{ registryId: string; entries: unknown[] }>,
|
||||
rows: [] as Array<{ pluginId: string; count: number; source: string }>,
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/registry-index.js", () => ({
|
||||
OFFICIAL_REGISTRY_ID: "official",
|
||||
fetchRegistryStats: async () => {
|
||||
if (state.fail) throw new Error("offline");
|
||||
return state.stats;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginInstallCountRepository: () => ({
|
||||
upsertMany: async (registryId: string, entries: unknown[]) => {
|
||||
state.upserted.push({ registryId, entries });
|
||||
return entries.length;
|
||||
},
|
||||
listByRegistry: async () => state.rows,
|
||||
}),
|
||||
}));
|
||||
|
||||
const {
|
||||
DOWNLOADS_SOURCE,
|
||||
TELEMETRY_SOURCE,
|
||||
preferredCount,
|
||||
readInstallCounts,
|
||||
syncInstallCounts,
|
||||
} = await import("../../plugins/install-counts.js");
|
||||
|
||||
beforeEach(() => {
|
||||
state.stats = null;
|
||||
state.fail = false;
|
||||
state.upserted = [];
|
||||
state.rows = [];
|
||||
});
|
||||
|
||||
describe("preferredCount", () => {
|
||||
it("prefers active installs and falls back to downloads", () => {
|
||||
expect(preferredCount({ downloads: 90, activeInstalls: 12 })).toEqual({
|
||||
count: 12,
|
||||
source: TELEMETRY_SOURCE,
|
||||
});
|
||||
expect(preferredCount({ downloads: 90, activeInstalls: null })).toEqual({
|
||||
count: 90,
|
||||
source: DOWNLOADS_SOURCE,
|
||||
});
|
||||
expect(preferredCount({ downloads: 90, activeInstalls: 0 })).toEqual({
|
||||
count: 90,
|
||||
source: DOWNLOADS_SOURCE,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncInstallCounts", () => {
|
||||
it("writes one row per plugin with its preferred source", async () => {
|
||||
state.stats = {
|
||||
plugins: new Map([
|
||||
["docker", { downloads: 50, activeInstalls: 20 }],
|
||||
["tunnels", { downloads: 8, activeInstalls: null }],
|
||||
]),
|
||||
};
|
||||
expect(await syncInstallCounts()).toBe(2);
|
||||
expect(state.upserted).toEqual([
|
||||
{
|
||||
registryId: "official",
|
||||
entries: [
|
||||
{ pluginId: "docker", count: 20, source: TELEMETRY_SOURCE },
|
||||
{ pluginId: "tunnels", count: 8, source: DOWNLOADS_SOURCE },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the old numbers when the registry is unreachable", async () => {
|
||||
state.fail = true;
|
||||
expect(await syncInstallCounts()).toBe(0);
|
||||
expect(state.upserted).toEqual([]);
|
||||
});
|
||||
|
||||
it("does nothing for a registry with no stats file", async () => {
|
||||
expect(await syncInstallCounts()).toBe(0);
|
||||
expect(state.upserted).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readInstallCounts", () => {
|
||||
it("maps the stored rows by plugin id", async () => {
|
||||
state.rows = [{ pluginId: "docker", count: 20, source: TELEMETRY_SOURCE }];
|
||||
const counts = await readInstallCounts();
|
||||
expect(counts.get("docker")).toEqual({
|
||||
count: 20,
|
||||
source: TELEMETRY_SOURCE,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -155,6 +155,8 @@ function tempRoot(prefix: string): string {
|
||||
return root;
|
||||
}
|
||||
|
||||
const DEFAULT_CAPS = ["hosts:read", "kv:own"];
|
||||
|
||||
function registryEntry(
|
||||
id: string,
|
||||
versions: Array<{ version: string; capabilities?: string[] }>,
|
||||
@@ -248,7 +250,10 @@ describe("uninstall and reinstall of a bundled plugin", () => {
|
||||
expect(loader.get("docker")).toBeUndefined();
|
||||
|
||||
registry.download.mockImplementation(() => stageArtifact("docker"));
|
||||
const result = await manage.installPlugin("docker", { userId: "admin" });
|
||||
const result = await manage.installPlugin("docker", {
|
||||
userId: "admin",
|
||||
capabilities: DEFAULT_CAPS,
|
||||
});
|
||||
expect(result.state).toBe("active");
|
||||
expect(registry.download).toHaveBeenCalledOnce();
|
||||
expect(loader.get("docker")?.source).toBe("user");
|
||||
@@ -299,7 +304,10 @@ describe("installing from the registry", () => {
|
||||
);
|
||||
const loader = await boot();
|
||||
|
||||
const result = await manage.installPlugin("notes", { userId: "admin" });
|
||||
const result = await manage.installPlugin("notes", {
|
||||
userId: "admin",
|
||||
capabilities: DEFAULT_CAPS,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ id: "notes", state: "active" });
|
||||
expect(fs.existsSync(path.join(data, "plugins", "notes.tmxplug"))).toBe(
|
||||
@@ -326,7 +334,11 @@ describe("installing from the registry", () => {
|
||||
registry.download.mockImplementation(() => stageArtifact("notes"));
|
||||
await boot();
|
||||
|
||||
await manage.installPlugin("notes", { userId: "admin", version: "1.0.0" });
|
||||
await manage.installPlugin("notes", {
|
||||
userId: "admin",
|
||||
version: "1.0.0",
|
||||
capabilities: DEFAULT_CAPS,
|
||||
});
|
||||
expect(db.rows.get("notes")?.pinnedVersion).toBe("1.0.0");
|
||||
});
|
||||
|
||||
@@ -334,7 +346,10 @@ describe("installing from the registry", () => {
|
||||
registry.index.plugins = [registryEntry("notes", [{ version: "1.0.0" }])];
|
||||
registry.download.mockImplementation(() => stageArtifact("notes"));
|
||||
await boot();
|
||||
await manage.installPlugin("notes", { userId: "admin" });
|
||||
await manage.installPlugin("notes", {
|
||||
userId: "admin",
|
||||
capabilities: DEFAULT_CAPS,
|
||||
});
|
||||
|
||||
await manage.uninstallPlugin("notes");
|
||||
expect(fs.existsSync(path.join(data, "plugins", "notes.tmxplug"))).toBe(
|
||||
@@ -429,3 +444,199 @@ describe("dependencies", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("consent", () => {
|
||||
it("refuses an install without the consented list, before any download", async () => {
|
||||
registry.index.plugins = [registryEntry("notes", [{ version: "1.0.0" }])];
|
||||
await boot();
|
||||
await expect(
|
||||
manage.installPlugin("notes", { userId: "admin" }),
|
||||
).rejects.toMatchObject({ code: "CONSENT_REQUIRED" });
|
||||
expect(registry.download).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses when the consent does not match what the release asks for", async () => {
|
||||
registry.index.plugins = [
|
||||
registryEntry("notes", [
|
||||
{ version: "1.0.0", capabilities: ["kv:own", "credentials:read"] },
|
||||
]),
|
||||
];
|
||||
await boot();
|
||||
await expect(
|
||||
manage.installPlugin("notes", {
|
||||
userId: "admin",
|
||||
capabilities: ["kv:own"],
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "CONSENT_MISMATCH",
|
||||
details: { capabilities: ["kv:own", "credentials:read"] },
|
||||
});
|
||||
expect(registry.download).not.toHaveBeenCalled();
|
||||
expect(db.rows.has("notes")).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts the same list in another order", async () => {
|
||||
registry.index.plugins = [registryEntry("notes", [{ version: "1.0.0" }])];
|
||||
registry.download.mockImplementation(() => stageArtifact("notes"));
|
||||
await boot();
|
||||
const result = await manage.installPlugin("notes", {
|
||||
userId: "admin",
|
||||
capabilities: ["kv:own", "hosts:read"],
|
||||
});
|
||||
expect(result.state).toBe("active");
|
||||
});
|
||||
|
||||
it("needs the added capabilities listed to accept an update", async () => {
|
||||
createFixturePlugin({ id: "docker", root: bundled });
|
||||
registry.index.plugins = [
|
||||
registryEntry("docker", [
|
||||
{
|
||||
version: "1.1.0",
|
||||
capabilities: ["hosts:read", "kv:own", "notify:send"],
|
||||
},
|
||||
{ version: "1.0.0" },
|
||||
]),
|
||||
];
|
||||
await boot();
|
||||
|
||||
await expect(
|
||||
manage.updatePlugin("docker", {
|
||||
userId: "admin",
|
||||
acceptCapabilities: true,
|
||||
capabilities: ["ssh:connect"],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "CONSENT_MISMATCH" });
|
||||
await expect(
|
||||
manage.updatePlugin("docker", {
|
||||
userId: "admin",
|
||||
acceptCapabilities: true,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "CONSENT_REQUIRED" });
|
||||
expect(registry.download).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("installing from a file", () => {
|
||||
async function artifactBuffer(
|
||||
id: string,
|
||||
manifestOverrides: Record<string, unknown> = {},
|
||||
) {
|
||||
const staged = await stageArtifact(id, manifestOverrides);
|
||||
const buffer = fs.readFileSync(staged.file);
|
||||
fs.rmSync(staged.file);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
manage.resetUploads();
|
||||
delete process.env.TERMIX_REQUIRE_SIGNED_PLUGINS;
|
||||
});
|
||||
|
||||
it("is refused while developer mode is off", async () => {
|
||||
await boot();
|
||||
await expect(
|
||||
manage.stageUpload(await artifactBuffer("notes")),
|
||||
).rejects.toMatchObject({ code: "DEVELOPER_MODE_OFF" });
|
||||
});
|
||||
|
||||
it("is refused when signed plugins are required", async () => {
|
||||
await boot();
|
||||
await manage.setDeveloperMode(true);
|
||||
process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = "true";
|
||||
await expect(
|
||||
manage.stageUpload(await artifactBuffer("notes")),
|
||||
).rejects.toMatchObject({ code: "SIGNED_ONLY" });
|
||||
});
|
||||
|
||||
it("stages, asks for consent, then installs it marked unverified", async () => {
|
||||
const loader = await boot();
|
||||
await manage.setDeveloperMode(true);
|
||||
|
||||
const preview = await manage.stageUpload(
|
||||
await artifactBuffer("notes", { capabilities: ["kv:own"] }),
|
||||
);
|
||||
expect(preview).toMatchObject({
|
||||
id: "notes",
|
||||
capabilities: ["kv:own"],
|
||||
replaces: null,
|
||||
});
|
||||
expect(loader.get("notes")).toBeUndefined();
|
||||
|
||||
await expect(
|
||||
manage.installUpload(preview.token, {
|
||||
userId: "admin",
|
||||
capabilities: ["kv:own", "hosts:read"],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "CONSENT_MISMATCH" });
|
||||
|
||||
const result = await manage.installUpload(preview.token, {
|
||||
userId: "admin",
|
||||
capabilities: ["kv:own"],
|
||||
});
|
||||
expect(result.state).toBe("active");
|
||||
expect(db.rows.get("notes")).toMatchObject({
|
||||
tier: manage.UNVERIFIED_TIER,
|
||||
registryId: null,
|
||||
});
|
||||
expect(loader.get("notes")?.signedBy).toBeUndefined();
|
||||
expect(fs.existsSync(path.join(data, "plugins", "notes.tmxplug"))).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// A token is used once.
|
||||
await expect(
|
||||
manage.installUpload(preview.token, {
|
||||
userId: "admin",
|
||||
capabilities: ["kv:own"],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "UPLOAD_EXPIRED" });
|
||||
});
|
||||
|
||||
it("replaces an earlier upload but never a bundled plugin", async () => {
|
||||
createFixturePlugin({ id: "docker", root: bundled });
|
||||
const loader = await boot();
|
||||
await manage.setDeveloperMode(true);
|
||||
|
||||
await expect(
|
||||
manage.stageUpload(await artifactBuffer("docker")),
|
||||
).rejects.toMatchObject({ code: "BUNDLED_ID" });
|
||||
|
||||
const first = await manage.stageUpload(await artifactBuffer("notes"));
|
||||
await manage.installUpload(first.token, {
|
||||
userId: "admin",
|
||||
capabilities: first.capabilities,
|
||||
});
|
||||
const second = await manage.stageUpload(
|
||||
await artifactBuffer("notes", { version: "1.1.0" }),
|
||||
);
|
||||
expect(second.replaces).toBe("1.0.0");
|
||||
await manage.installUpload(second.token, {
|
||||
userId: "admin",
|
||||
capabilities: second.capabilities,
|
||||
});
|
||||
expect(loader.get("notes")?.manifest.version).toBe("1.1.0");
|
||||
expect(db.rows.get("notes")?.tier).toBe(manage.UNVERIFIED_TIER);
|
||||
});
|
||||
|
||||
it("refuses a file that is not a plugin archive", async () => {
|
||||
await boot();
|
||||
await manage.setDeveloperMode(true);
|
||||
await expect(
|
||||
manage.stageUpload(Buffer.from("not a tarball")),
|
||||
).rejects.toMatchObject({ code: "INVALID_ARTIFACT" });
|
||||
});
|
||||
|
||||
it("drops staged files when developer mode is turned off", async () => {
|
||||
await boot();
|
||||
await manage.setDeveloperMode(true);
|
||||
const preview = await manage.stageUpload(await artifactBuffer("notes"));
|
||||
await manage.setDeveloperMode(false);
|
||||
await manage.setDeveloperMode(true);
|
||||
await expect(
|
||||
manage.installUpload(preview.token, {
|
||||
userId: "admin",
|
||||
capabilities: preview.capabilities,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "UPLOAD_EXPIRED" });
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import * as tar from "tar";
|
||||
|
||||
const fetchMock = vi.hoisted(() => vi.fn());
|
||||
const testKey = vi.hoisted(() => ({ value: null as null | object }));
|
||||
@@ -26,9 +27,12 @@ vi.mock("../../plugins/trust.js", async (importOriginal) => {
|
||||
import {
|
||||
downloadRelease,
|
||||
fetchRegistryIndex,
|
||||
fetchRegistryStats,
|
||||
findRelease,
|
||||
getStatsUrl,
|
||||
isApiCompatible,
|
||||
parseRegistryIndex,
|
||||
parseRegistryStats,
|
||||
resetRegistryCache,
|
||||
type RegistryVersion,
|
||||
} from "../../plugins/registry-index.js";
|
||||
@@ -186,8 +190,25 @@ describe("fetchRegistryIndex", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** A real .tmxplug holding only a manifest, enough for the checks. */
|
||||
function pack(manifest: Record<string, unknown>): Buffer {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-pack-"));
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, "manifest.json"), JSON.stringify(manifest));
|
||||
const file = path.join(dir, "out.tmxplug");
|
||||
tar.c({ gzip: true, sync: true, file, cwd: dir }, ["manifest.json"]);
|
||||
return fs.readFileSync(file);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe("downloadRelease", () => {
|
||||
const artifact = Buffer.from("plugin archive bytes");
|
||||
const artifact = pack({
|
||||
id: "docker",
|
||||
version: "1.0.0",
|
||||
capabilities: ["kv:own"],
|
||||
});
|
||||
|
||||
it("writes a verified artifact and its signature to staging", async () => {
|
||||
const signed = sign(artifact);
|
||||
@@ -206,6 +227,47 @@ describe("downloadRelease", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a file signed by a key this build does not trust", async () => {
|
||||
const other = crypto.generateKeyPairSync("ed25519").privateKey;
|
||||
const digest = crypto.createHash("sha256").update(artifact).digest();
|
||||
fetchMock.mockResolvedValueOnce(response(artifact));
|
||||
await expect(
|
||||
downloadRelease(
|
||||
"docker",
|
||||
version({
|
||||
sha256: digest.toString("hex"),
|
||||
signature: crypto.sign(null, digest, other).toString("base64"),
|
||||
size: artifact.length,
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(/trusted key/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["another plugin id", { id: "evil" }, /archive is for/],
|
||||
["another version", { version: "9.9.9" }, /archive is version/],
|
||||
[
|
||||
"more capabilities than the index lists",
|
||||
{ capabilities: ["kv:own", "credentials:read"] },
|
||||
/different capabilities/,
|
||||
],
|
||||
])("refuses a signed file with %s", async (_label, override, error) => {
|
||||
const tampered = pack({
|
||||
id: "docker",
|
||||
version: "1.0.0",
|
||||
capabilities: ["kv:own"],
|
||||
...override,
|
||||
});
|
||||
const signed = sign(tampered);
|
||||
fetchMock.mockResolvedValueOnce(response(tampered));
|
||||
await expect(
|
||||
downloadRelease("docker", version({ ...signed, size: tampered.length })),
|
||||
).rejects.toThrow(error);
|
||||
expect(fs.existsSync(path.join(dataDir, "plugins", ".downloads"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a file that does not match its signature", async () => {
|
||||
const signed = sign(artifact);
|
||||
fetchMock.mockResolvedValueOnce(response(Buffer.from("something else!!!")));
|
||||
@@ -229,3 +291,38 @@ describe("downloadRelease", () => {
|
||||
).rejects.toThrow(/larger than allowed/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("registry stats", () => {
|
||||
it("sits next to the index", () => {
|
||||
expect(getStatsUrl()).toBe(
|
||||
"https://raw.githubusercontent.com/Termix-SSH/Termix-Registry/main/official/stats.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps valid counts and drops the rest", () => {
|
||||
const stats = parseRegistryStats({
|
||||
plugins: {
|
||||
docker: { downloads: 120, activeInstalls: 40 },
|
||||
tunnels: { downloads: 7 },
|
||||
"../bad": { downloads: 1 },
|
||||
broken: { downloads: -3, activeInstalls: "many" },
|
||||
},
|
||||
});
|
||||
expect(Object.fromEntries(stats.plugins)).toEqual({
|
||||
docker: { downloads: 120, activeInstalls: 40 },
|
||||
tunnels: { downloads: 7, activeInstalls: null },
|
||||
broken: { downloads: 0, activeInstalls: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches and parses stats.json", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
response(JSON.stringify({ plugins: { docker: { downloads: 3 } } })),
|
||||
);
|
||||
const stats = await fetchRegistryStats();
|
||||
expect(stats?.plugins.get("docker")).toEqual({
|
||||
downloads: 3,
|
||||
activeInstalls: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user