feat(integrations): add PatchMon integration and widget (#6119)

Co-authored-by: Thomas <thomas@ajnart.fr>
This commit is contained in:
Josh PrestonandThomas authored and GitHub committed 2026-07-10 17:48:43 +02:00
1 parent 174c5f98f8
commit 33395ae226
41 files changed
+2982 -17

No files matched your search

@@ -0,0 +1,167 @@
// @vitest-environment node
import { ParseError } from "@homarr/common/server";
import { Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.hoisted(() => {
process.env.SKIP_ENV_VALIDATION = "true";
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
});
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { IntegrationParseError } from "../../base/errors/parse/integration-parse-error";
import type { IntegrationSecret } from "../../base/types";
import { PatchMonIntegration } from "../patchmon-integration";
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const TEST_URL = "https://patchmon.example.com";
const TEST_API_KEY = "test-api-key";
const TEST_API_SECRET = "test-api-secret";
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const sampleStatsResponse = {
total_hosts: 42,
hosts_needing_updates: 15,
security_updates: 23,
up_to_date_hosts: 27,
hosts_with_security_updates: 8,
recent_updates_24h: 34,
total_outdated_packages: 156,
total_repos: 12,
last_updated: "2025-10-11T12:34:56.789Z",
os_distribution: [
{ name: "Debian", count: 12, os_type: "linux", os_version: "12" },
{ name: "Ubuntu", count: 20, os_type: "linux", os_version: "22.04" },
{ name: "Rocky Linux", count: 10, os_type: "linux", os_version: "9" },
],
};
const createIntegration = (decryptedSecrets: IntegrationSecret[] = []) =>
new PatchMonIntegration({
id: "test-patchmon",
name: "Test PatchMon",
url: TEST_URL,
externalUrl: null,
decryptedSecrets:
decryptedSecrets.length > 0
? decryptedSecrets
: [
{ kind: "patchmonApiKey", value: TEST_API_KEY },
{ kind: "patchmonApiSecret", value: TEST_API_SECRET },
],
});
beforeEach(() => {
mockFetch.mockReset();
});
describe("PatchMonIntegration getStatsAsync", () => {
test("maps API response fields to camelCase stats", async () => {
mockFetch.mockResolvedValue(
new Response(JSON.stringify(sampleStatsResponse), {
status: 200,
headers: { "content-type": "application/json" },
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
);
const stats = await createIntegration().getStatsAsync();
expect(stats).toStrictEqual({
totalHosts: 42,
hostsNeedingUpdates: 15,
securityUpdates: 23,
upToDateHosts: 27,
hostsWithSecurityUpdates: 8,
recentUpdates24h: 34,
totalOutdatedPackages: 156,
totalRepos: 12,
lastUpdated: "2025-10-11T12:34:56.789Z",
osDistribution: [
{ name: "Ubuntu", count: 20, osType: "linux", osVersion: "22.04" },
{ name: "Debian", count: 12, osType: "linux", osVersion: "12" },
{ name: "Rocky Linux", count: 10, osType: "linux", osVersion: "9" },
],
});
});
test("defaults os distribution to empty array when absent", async () => {
const { os_distribution: _osDistribution, ...responseWithoutOs } = sampleStatsResponse;
mockFetch.mockResolvedValue(
new Response(JSON.stringify(responseWithoutOs), {
status: 200,
headers: { "content-type": "application/json" },
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
);
const stats = await createIntegration().getStatsAsync();
expect(stats.osDistribution).toStrictEqual([]);
});
test("throws ParseError when API response is not valid JSON", async () => {
mockFetch.mockResolvedValue(
new Response("not-json", {
status: 200,
headers: { "content-type": "application/json" },
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
);
await expect(createIntegration().getStatsAsync()).rejects.toSatisfy((error) => {
if (!(error instanceof IntegrationParseError)) return false;
const cause = error.cause;
return cause instanceof ParseError && cause.message.includes("Invalid PatchMon stats response");
});
});
test("throws ParseError when API response does not match schema", async () => {
mockFetch.mockResolvedValue(
new Response(JSON.stringify({ invalid: true }), {
status: 200,
headers: { "content-type": "application/json" },
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
);
await expect(createIntegration().getStatsAsync()).rejects.toSatisfy((error) => {
if (!(error instanceof IntegrationParseError)) return false;
const cause = error.cause;
return cause instanceof ParseError && cause.message.includes("Invalid PatchMon stats response");
});
});
test("throws when API returns an error", async () => {
mockFetch.mockResolvedValue(
new Response("Unauthorized", {
status: 401,
headers: { "content-type": "text/plain" },
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
);
await expect(createIntegration().getStatsAsync()).rejects.toThrow();
});
});
describe("PatchMonIntegration authentication", () => {
test("sends a Basic auth header with api key and secret", async () => {
mockFetch.mockResolvedValue(
new Response(JSON.stringify(sampleStatsResponse), {
status: 200,
headers: { "content-type": "application/json" },
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
);
await createIntegration().getStatsAsync();
const expected = `Basic ${Buffer.from(`${TEST_API_KEY}:${TEST_API_SECRET}`).toString("base64")}`;
const [url, requestInit] = mockFetch.mock.calls[0] ?? [];
expect(String(url)).toContain("/api/v1/gethomepage/stats");
expect(requestInit?.headers).toMatchObject({ Authorization: expected });
expect(requestInit?.timeout).toBe(10_000);
});
});