270 lines
9.3 KiB
TypeScript
270 lines
9.3 KiB
TypeScript
// @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 type { IntegrationTestingInput } from "../../base/integration";
|
|
import { IntegrationParseError } from "../../base/errors/parse/integration-parse-error";
|
|
import type { IntegrationSecret } from "../../base/types";
|
|
import { WudIntegration } from "../wud-integration";
|
|
|
|
vi.mock("@homarr/core/infrastructure/http", () => ({
|
|
fetchWithTrustedCertificatesAsync: vi.fn(),
|
|
}));
|
|
|
|
const TEST_URL = "https://wud.example.com";
|
|
const TEST_USERNAME = "user";
|
|
const TEST_PASSWORD = "p@ss";
|
|
|
|
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
|
|
|
const sampleContainersResponse = [
|
|
{
|
|
id: "1",
|
|
name: "homeassistant",
|
|
displayName: "Home Assistant",
|
|
updateAvailable: true,
|
|
link: "https://github.com/home-assistant/core/releases/tag/2024.1.0",
|
|
image: { tag: { value: "2023.12.0" } },
|
|
updateKind: { remoteValue: "2024.1.0" },
|
|
},
|
|
{ id: "2", name: "traefik", updateAvailable: false },
|
|
{
|
|
id: "3",
|
|
name: "grafana",
|
|
updateAvailable: true,
|
|
image: { tag: { value: "10.0.0" } },
|
|
result: { tag: "10.1.0" },
|
|
},
|
|
];
|
|
|
|
const sampleUpdates = [
|
|
{
|
|
id: "1",
|
|
name: "Home Assistant",
|
|
currentVersion: "2023.12.0",
|
|
newVersion: "2024.1.0",
|
|
link: "https://github.com/home-assistant/core/releases/tag/2024.1.0",
|
|
},
|
|
{
|
|
id: "3",
|
|
name: "grafana",
|
|
currentVersion: "10.0.0",
|
|
newVersion: "10.1.0",
|
|
link: null,
|
|
},
|
|
];
|
|
|
|
const createIntegration = (decryptedSecrets: IntegrationSecret[] = []) =>
|
|
new WudIntegration({
|
|
id: "test-wud",
|
|
name: "Test WUD",
|
|
url: TEST_URL,
|
|
externalUrl: null,
|
|
decryptedSecrets,
|
|
});
|
|
|
|
const createIntegrationWithBasicAuth = () =>
|
|
createIntegration([
|
|
{ kind: "username", value: TEST_USERNAME },
|
|
{ kind: "password", value: TEST_PASSWORD },
|
|
]);
|
|
|
|
beforeEach(() => {
|
|
mockFetch.mockReset();
|
|
});
|
|
|
|
describe("WudIntegration getStatsAsync", () => {
|
|
test("computes total and available-update counts from the containers list", async () => {
|
|
mockFetch.mockResolvedValue(
|
|
new Response(JSON.stringify(sampleContainersResponse), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
|
);
|
|
|
|
const stats = await createIntegration().getStatsAsync();
|
|
|
|
expect(stats).toStrictEqual({ totalContainers: 3, updatesAvailable: 2, updates: sampleUpdates });
|
|
});
|
|
|
|
test("returns zero counts for an empty containers list", async () => {
|
|
mockFetch.mockResolvedValue(
|
|
new Response(JSON.stringify([]), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
|
);
|
|
|
|
const stats = await createIntegration().getStatsAsync();
|
|
|
|
expect(stats).toStrictEqual({ totalContainers: 0, updatesAvailable: 0, updates: [] });
|
|
});
|
|
|
|
test("maps container updates using displayName, falling back to result.tag and null link", async () => {
|
|
mockFetch.mockResolvedValue(
|
|
new Response(JSON.stringify(sampleContainersResponse), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
|
);
|
|
|
|
const stats = await createIntegration().getStatsAsync();
|
|
|
|
expect(stats.updates).toStrictEqual(sampleUpdates);
|
|
});
|
|
|
|
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 WUD containers 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 WUD containers 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("WudIntegration authentication", () => {
|
|
test("sends no Authorization header when no credentials are configured", async () => {
|
|
mockFetch.mockResolvedValue(
|
|
new Response(JSON.stringify(sampleContainersResponse), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
|
);
|
|
|
|
await createIntegration().getStatsAsync();
|
|
|
|
const [, requestInit] = mockFetch.mock.calls[0] ?? [];
|
|
const headers = (requestInit?.headers ?? {}) as Record<string, string>;
|
|
expect(headers.Authorization).toBeUndefined();
|
|
});
|
|
|
|
test("sends a Basic auth header when username and password are configured", async () => {
|
|
mockFetch.mockResolvedValue(
|
|
new Response(JSON.stringify(sampleContainersResponse), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
|
);
|
|
|
|
await createIntegrationWithBasicAuth().getStatsAsync();
|
|
|
|
const expected = `Basic ${Buffer.from(`${TEST_USERNAME}:${TEST_PASSWORD}`).toString("base64")}`;
|
|
const [url, requestInit] = mockFetch.mock.calls[0] ?? [];
|
|
expect(String(url)).toContain("/api/containers");
|
|
expect(requestInit?.headers).toMatchObject({ Authorization: expected });
|
|
expect(requestInit?.timeout).toBe(10_000);
|
|
});
|
|
|
|
test("does not send credentials over a non-HTTPS URL", async () => {
|
|
mockFetch.mockResolvedValue(
|
|
new Response(JSON.stringify(sampleContainersResponse), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
|
);
|
|
|
|
const integration = new WudIntegration({
|
|
id: "test-wud-http",
|
|
name: "Test WUD HTTP",
|
|
url: "http://wud.example.com",
|
|
externalUrl: null,
|
|
decryptedSecrets: [
|
|
{ kind: "username", value: TEST_USERNAME },
|
|
{ kind: "password", value: TEST_PASSWORD },
|
|
],
|
|
});
|
|
|
|
await integration.getStatsAsync();
|
|
|
|
const [, requestInit] = mockFetch.mock.calls[0] ?? [];
|
|
const headers = (requestInit?.headers ?? {}) as Record<string, string>;
|
|
expect(headers.Authorization).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("WudIntegration testing endpoint", () => {
|
|
type TestingFn = (input: IntegrationTestingInput) => Promise<{ success: boolean }>;
|
|
|
|
const invokeTesting = (integration: WudIntegration, input: IntegrationTestingInput) => {
|
|
const testing = (integration as unknown as { testingAsync: TestingFn }).testingAsync.bind(integration);
|
|
return testing(input);
|
|
};
|
|
|
|
const makeInput = (response: Response): IntegrationTestingInput =>
|
|
({
|
|
fetchAsync: vi.fn().mockResolvedValue(response),
|
|
}) as unknown as IntegrationTestingInput;
|
|
|
|
test("returns success when /api/app responds 200", async () => {
|
|
const input = makeInput(new Response(JSON.stringify({ name: "wud", version: "5.0.0" }), { status: 200 }));
|
|
|
|
const result = await invokeTesting(createIntegration(), input);
|
|
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
test("returns a status-code failure when /api/app responds non-OK", async () => {
|
|
const input = makeInput(new Response("unauthorized", { status: 401 }));
|
|
|
|
const result = await invokeTesting(createIntegration(), input);
|
|
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
test("passes the Basic auth header through to fetchAsync when configured", async () => {
|
|
const response = new Response(JSON.stringify({ name: "wud", version: "5.0.0" }), { status: 200 });
|
|
const input = makeInput(response);
|
|
|
|
await invokeTesting(createIntegrationWithBasicAuth(), input);
|
|
|
|
const fetchAsyncMock = vi.mocked(input.fetchAsync);
|
|
const init = fetchAsyncMock.mock.calls[0]?.[1];
|
|
const headers = (init?.headers ?? {}) as Record<string, string>;
|
|
const expected = `Basic ${Buffer.from(`${TEST_USERNAME}:${TEST_PASSWORD}`).toString("base64")}`;
|
|
expect(headers.Authorization).toBe(expected);
|
|
});
|
|
});
|