Merge remote-tracking branch 'origin/dev' into feat/pr-6510-polish
This commit is contained in:
commit
3f9941f9c7
153 files changed
+31523
-27879
No files matched your search
@@ -54,6 +54,7 @@ import { UmamiIntegration } from "../umami/umami-integration";
|
||||
import { UptimeKumaIntegration } from "../uptime-kuma/uptime-kuma-integration";
|
||||
import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration";
|
||||
import { UnraidIntegration } from "../unraid/unraid-integration";
|
||||
import { WudIntegration } from "../wud/wud-integration";
|
||||
import type { Integration, IntegrationInput } from "./integration";
|
||||
|
||||
export const createIntegrationAsync = async <TKind extends keyof typeof integrationCreators>(
|
||||
@@ -133,6 +134,7 @@ export const integrationCreators = {
|
||||
beszel: BeszelIntegration,
|
||||
bazarr: BazarrIntegration,
|
||||
traefik: TraefikIntegration,
|
||||
wud: WudIntegration,
|
||||
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
|
||||
|
||||
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
|
||||
|
||||
@@ -134,7 +134,10 @@ export class HomeAssistantIntegration extends Integration implements ISmartHomeI
|
||||
*/
|
||||
private async postAsync(path: `/api/${string}`, body: Record<string, string>) {
|
||||
return await fetchWithTrustedCertificatesAsync(this.url(path), {
|
||||
headers: this.getAuthHeaders(),
|
||||
headers: {
|
||||
...this.getAuthHeaders(),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// General integrations
|
||||
export { ArchiveTeamWarriorIntegration } from "./archive-team-warrior/archive-team-warrior-integration";
|
||||
export { WudIntegration } from "./wud/wud-integration";
|
||||
export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration";
|
||||
export { TechnitiumDnsIntegration } from "./technitium/technitium-integration";
|
||||
export { AnchorIntegration } from "./anchor/anchor-integration";
|
||||
@@ -91,6 +92,7 @@ export type {
|
||||
ArchiveTeamWarriorItem,
|
||||
ArchiveTeamWarriorStatus,
|
||||
} from "./archive-team-warrior/archive-team-warrior-types";
|
||||
export type { WudStats, WudContainerUpdate } from "./wud/wud-types";
|
||||
|
||||
// Schemas
|
||||
export { anchorNotesListInputSchema } from "./anchor/anchor-types";
|
||||
|
||||
@@ -85,6 +85,11 @@ export class SynologyClient {
|
||||
if (volumesFromV2.length > 0) {
|
||||
return volumesFromV2;
|
||||
}
|
||||
|
||||
const legacyVolumesFromV2 = this.mapLegacyVolumes(storageV2.vol_info);
|
||||
if (legacyVolumesFromV2.length > 0) {
|
||||
return legacyVolumesFromV2;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug("storage_v2 unavailable, falling back to legacy storage info", {
|
||||
integrationId: this.integrationId,
|
||||
|
||||
@@ -78,6 +78,7 @@ export const synologyStorageV2VolumeSchema = z
|
||||
|
||||
export const synologyStorageV2DataSchema = z.object({
|
||||
volumes: z.array(synologyStorageV2VolumeSchema).optional(),
|
||||
vol_info: z.array(synologyVolumeInfoSchema).optional(),
|
||||
});
|
||||
|
||||
export const synologyUtilizationCpuSchema = z.object({
|
||||
|
||||
@@ -426,6 +426,60 @@ describe("SynologyIntegration.getSystemInfoAsync", () => {
|
||||
expect(result.fileSystem[0]?.deviceName).toBe("volume_1");
|
||||
});
|
||||
|
||||
test("uses vol_info from storage_v2 without falling back to legacy storage", async () => {
|
||||
mockSuccessfulFlow({
|
||||
storageV2: {
|
||||
success: true,
|
||||
data: {
|
||||
vol_info: [
|
||||
{
|
||||
name: "volume_1",
|
||||
used_size: "4000000000",
|
||||
total_size: "10000000000",
|
||||
status: "normal",
|
||||
},
|
||||
{
|
||||
name: "volume_2",
|
||||
used_size: "5000000000",
|
||||
total_size: "20000000000",
|
||||
status: "attention",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const integration = createIntegration();
|
||||
const result = await integration.getSystemInfoAsync();
|
||||
|
||||
expect(result.fileSystem).toEqual([
|
||||
{
|
||||
deviceName: "volume_1",
|
||||
used: "4000000000",
|
||||
available: "6000000000",
|
||||
percentage: 40,
|
||||
},
|
||||
{
|
||||
deviceName: "volume_2",
|
||||
used: "5000000000",
|
||||
available: "15000000000",
|
||||
percentage: 25,
|
||||
},
|
||||
]);
|
||||
|
||||
const legacyStorageRequests = mockFetch.mock.calls.filter(([url]) => {
|
||||
const urlString = toUrlString(url);
|
||||
|
||||
return (
|
||||
getApiFromUrl(urlString) === "SYNO.Core.System" &&
|
||||
getMethodFromUrl(urlString) === "info" &&
|
||||
getTypeFromUrl(urlString) === "storage"
|
||||
);
|
||||
});
|
||||
|
||||
expect(legacyStorageRequests).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("continues when optional tier-2 calls fail", async () => {
|
||||
mockSuccessfulFlow({ skipOptional: true });
|
||||
const integration = createIntegration();
|
||||
|
||||
@@ -59,10 +59,12 @@ const ws = vi.hoisted(() => {
|
||||
}
|
||||
|
||||
const result = responder(payload.method as string, (payload.params as unknown[] | undefined) ?? []);
|
||||
// A responder returning an Error mimics a method the server rejects, e.g. an unsupported filter.
|
||||
const body = result instanceof Error ? { error: { message: result.message } } : { result };
|
||||
const envelope =
|
||||
payload.jsonrpc === "2.0"
|
||||
? { jsonrpc: "2.0", id: payload.id, result }
|
||||
: { msg: "result", id: payload.id, result };
|
||||
? { jsonrpc: "2.0", id: payload.id, ...body }
|
||||
: { msg: "result", id: payload.id, ...body };
|
||||
this.respond(JSON.stringify(envelope));
|
||||
}
|
||||
|
||||
@@ -91,7 +93,10 @@ const ws = vi.hoisted(() => {
|
||||
};
|
||||
});
|
||||
|
||||
const logs = vi.hoisted(() => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }));
|
||||
|
||||
vi.mock("ws", () => ({ WebSocket: ws.FakeWebSocket }));
|
||||
vi.mock("@homarr/core/infrastructure/logs", () => ({ createLogger: () => logs }));
|
||||
vi.mock("@homarr/core/infrastructure/certificates", () => ({
|
||||
getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]),
|
||||
getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]),
|
||||
@@ -148,6 +153,14 @@ const happyResponder = (method: string) => {
|
||||
];
|
||||
case "pool.query":
|
||||
return [{ name: "tank", status: "ONLINE", healthy: true, free: 500, size: 1000, allocated: 500 }];
|
||||
case "pool.dataset.query":
|
||||
return [
|
||||
{
|
||||
id: "tank",
|
||||
used: { parsed: 200 },
|
||||
available: { parsed: 300 },
|
||||
},
|
||||
];
|
||||
case "interface.query":
|
||||
return [{ id: "eth0", name: "eth0" }];
|
||||
case "reporting.netdata_get_data":
|
||||
@@ -159,6 +172,11 @@ const happyResponder = (method: string) => {
|
||||
|
||||
const emptyAggregations = () => ({ min: {}, mean: {}, max: {} });
|
||||
|
||||
// `pool.query` reports 500 allocated of a 1000 byte pool, leaving 500 free.
|
||||
const physicalFallbackFileSystem = [{ deviceName: "tank", available: "500", used: "500", percentage: 50 }];
|
||||
|
||||
const ERROR_MESSAGE_LIMIT = 200;
|
||||
|
||||
let integrationCounter = 0;
|
||||
const createIntegration = (decryptedSecrets: IntegrationSecret[]) =>
|
||||
new TrueNasIntegration({
|
||||
@@ -180,6 +198,7 @@ describe("TrueNasIntegration", () => {
|
||||
ws.constructedUrls.length = 0;
|
||||
ws.failures.length = 0;
|
||||
ws.setResponder(happyResponder);
|
||||
logs.warn.mockClear();
|
||||
});
|
||||
|
||||
test("fetches and maps system info over the JSON-RPC API", async () => {
|
||||
@@ -191,6 +210,18 @@ describe("TrueNasIntegration", () => {
|
||||
// JSON-RPC does not use the legacy "connect" session handshake.
|
||||
expect(ws.sentPayloads.some((payload) => payload.msg === "connect")).toBe(false);
|
||||
|
||||
const datasetPayload = ws.sentPayloads.find((payload) => payload.method === "pool.dataset.query");
|
||||
expect(datasetPayload).toMatchObject({
|
||||
params: [
|
||||
[["id", "in", ["tank"]]],
|
||||
{
|
||||
extra: {
|
||||
properties: ["used", "available"],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
version: "TrueNAS-SCALE-24.10",
|
||||
cpuModelName: "Intel Xeon",
|
||||
@@ -204,12 +235,131 @@ describe("TrueNasIntegration", () => {
|
||||
loadAverage: null,
|
||||
gpu: [],
|
||||
network: { up: 20_000, down: 10_000 },
|
||||
// `available` is the free space left on the pool (free = 500), not its total size.
|
||||
fileSystem: [{ deviceName: "tank", available: "500", used: "500", percentage: 50 }],
|
||||
// `available` and `used` come from the root dataset, not the physical pool.
|
||||
fileSystem: [{ deviceName: "tank", available: "300", used: "200", percentage: 40 }],
|
||||
smart: [{ deviceName: "tank", healthy: true, overallStatus: "ONLINE", temperature: null }],
|
||||
});
|
||||
});
|
||||
|
||||
test("falls back to physical pool space when the root dataset is not returned", async () => {
|
||||
ws.setResponder((method) => (method === "pool.dataset.query" ? [] : happyResponder(method)));
|
||||
const integration = createIntegration(credentials);
|
||||
|
||||
const result = await integration.getSystemInfoAsync();
|
||||
|
||||
expect(result.fileSystem).toMatchObject(physicalFallbackFileSystem);
|
||||
// Dropping the pool would also hide its health status.
|
||||
expect(result.smart).toMatchObject([{ deviceName: "tank", healthy: true, overallStatus: "ONLINE" }]);
|
||||
});
|
||||
|
||||
test("falls back to physical pool space when the root dataset reports no usable values", async () => {
|
||||
ws.setResponder((method) =>
|
||||
method === "pool.dataset.query"
|
||||
? [{ id: "tank", used: { parsed: null }, available: null }]
|
||||
: happyResponder(method),
|
||||
);
|
||||
const integration = createIntegration(credentials);
|
||||
|
||||
const result = await integration.getSystemInfoAsync();
|
||||
|
||||
expect(result.fileSystem).toMatchObject(physicalFallbackFileSystem);
|
||||
});
|
||||
|
||||
test("falls back to physical pool space when the root dataset omits the requested properties", async () => {
|
||||
// Datasets that cannot report space, e.g. while locked, come back without `used` and `available`.
|
||||
ws.setResponder((method) =>
|
||||
method === "pool.dataset.query" ? [{ id: "tank", locked: true }] : happyResponder(method),
|
||||
);
|
||||
const integration = createIntegration(credentials);
|
||||
|
||||
const result = await integration.getSystemInfoAsync();
|
||||
|
||||
expect(result.fileSystem).toMatchObject(physicalFallbackFileSystem);
|
||||
});
|
||||
|
||||
test("falls back to physical pool space when the dataset response fails schema validation", async () => {
|
||||
ws.setResponder((method) =>
|
||||
method === "pool.dataset.query" ? { unexpected: "not an array" } : happyResponder(method),
|
||||
);
|
||||
const integration = createIntegration(credentials);
|
||||
|
||||
const result = await integration.getSystemInfoAsync();
|
||||
|
||||
expect(result.fileSystem).toMatchObject(physicalFallbackFileSystem);
|
||||
// A malformed payload must not take the rest of the system info down with it.
|
||||
expect(result).toMatchObject({ cpuTemp: 45, memAvailableInBytes: 6_000_000_000, uptime: 3600 });
|
||||
});
|
||||
|
||||
test("falls back to physical pool space when the dataset query is rejected", async () => {
|
||||
ws.setResponder((method) =>
|
||||
method === "pool.dataset.query"
|
||||
? new Error("[EINVAL] filters: Value error, Invalid operation: in")
|
||||
: happyResponder(method),
|
||||
);
|
||||
const integration = createIntegration(credentials);
|
||||
|
||||
const result = await integration.getSystemInfoAsync();
|
||||
|
||||
expect(result.fileSystem).toMatchObject(physicalFallbackFileSystem);
|
||||
// A rejected dataset query must not take the rest of the system info down with it.
|
||||
expect(result).toMatchObject({ cpuTemp: 45, memAvailableInBytes: 6_000_000_000, uptime: 3600 });
|
||||
});
|
||||
|
||||
test("logs the truncated rejection reason rather than the generic wrapper message", async () => {
|
||||
// TrueNAS answers a rejected method with its Python traceback, which runs to several kilobytes.
|
||||
ws.setResponder((method) =>
|
||||
method === "pool.dataset.query"
|
||||
? new Error(`[EINVAL] query-filters ${"x".repeat(5000)}`)
|
||||
: happyResponder(method),
|
||||
);
|
||||
const integration = createIntegration(credentials);
|
||||
|
||||
await integration.getSystemInfoAsync();
|
||||
|
||||
const logged = logs.warn.mock.calls.find(([message]) => String(message).includes("Could not retrieve"));
|
||||
const reason = String((logged?.[1] as { error?: string } | undefined)?.error);
|
||||
// The decorator would otherwise surface only "An unknown error occured while executing Integration method".
|
||||
expect(reason).toContain("[EINVAL] query-filters");
|
||||
expect(reason).toHaveLength(ERROR_MESSAGE_LIMIT + 1); // truncated, plus the ellipsis
|
||||
});
|
||||
|
||||
test("keeps reporting a pool that is unhealthy but has no usable dataset space", async () => {
|
||||
ws.setResponder((method) => {
|
||||
if (method === "pool.query")
|
||||
return [
|
||||
{ name: "tank", status: "ONLINE", healthy: true, free: 500, size: 1000, allocated: 500 },
|
||||
{ name: "backup", status: "DEGRADED", healthy: false, free: 10, size: 100, allocated: 90 },
|
||||
];
|
||||
if (method === "pool.dataset.query") return [{ id: "tank", used: { parsed: 200 }, available: { parsed: 300 } }];
|
||||
return happyResponder(method);
|
||||
});
|
||||
const integration = createIntegration(credentials);
|
||||
|
||||
const result = await integration.getSystemInfoAsync();
|
||||
|
||||
expect(result.smart).toMatchObject([
|
||||
{ deviceName: "tank", healthy: true, overallStatus: "ONLINE" },
|
||||
{ deviceName: "backup", healthy: false, overallStatus: "DEGRADED" },
|
||||
]);
|
||||
expect(result.fileSystem).toMatchObject([
|
||||
{ deviceName: "tank", available: "300", used: "200", percentage: 40 },
|
||||
{ deviceName: "backup", available: "10", used: "90", percentage: 90 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("reports zero usage for an empty root dataset", async () => {
|
||||
ws.setResponder((method) =>
|
||||
method === "pool.dataset.query"
|
||||
? [{ id: "tank", used: { parsed: 0 }, available: { parsed: 0 } }]
|
||||
: happyResponder(method),
|
||||
);
|
||||
const integration = createIntegration(credentials);
|
||||
|
||||
const result = await integration.getSystemInfoAsync();
|
||||
|
||||
expect(result.fileSystem[0]?.percentage).toBe(0);
|
||||
});
|
||||
|
||||
test("authenticates with an API key when one is configured", async () => {
|
||||
const integration = createIntegration([{ kind: "apiKey", value: "api-key-123" }]);
|
||||
|
||||
|
||||
@@ -15,6 +15,20 @@ const logger = createLogger({ module: "trueNasIntegration" });
|
||||
|
||||
const NETWORK_MULTIPLIER = 100;
|
||||
|
||||
const ERROR_MESSAGE_LIMIT = 200;
|
||||
|
||||
/**
|
||||
* The error decorator replaces a rejected request with a generic message and moves the actionable
|
||||
* reason to `cause`. TrueNAS puts its middleware traceback in there, several kilobytes of it, so the
|
||||
* reason is truncated before it reaches a log line that repeats on every poll.
|
||||
*/
|
||||
const describeRequestError = (error: unknown) => {
|
||||
const cause = error instanceof Error ? error.cause : undefined;
|
||||
const reason = cause instanceof Error ? cause.message : error instanceof Error ? error.message : String(error);
|
||||
|
||||
return reason.length > ERROR_MESSAGE_LIMIT ? `${reason.slice(0, ERROR_MESSAGE_LIMIT)}…` : reason;
|
||||
};
|
||||
|
||||
export class TrueNasIntegration extends Integration implements ISystemHealthMonitoringIntegration {
|
||||
private client?: TrueNasClient;
|
||||
|
||||
@@ -49,9 +63,10 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
|
||||
memUsedInBytes,
|
||||
fileSystem: datasets.map((dataset) => ({
|
||||
deviceName: dataset.name,
|
||||
available: `${dataset.free}`, // free space left on the pool
|
||||
used: `${dataset.allocated}`,
|
||||
percentage: (dataset.allocated / dataset.size) * 100,
|
||||
available: `${dataset.available}`,
|
||||
used: `${dataset.used}`,
|
||||
percentage:
|
||||
dataset.used + dataset.available === 0 ? 0 : (dataset.used / (dataset.used + dataset.available)) * 100,
|
||||
})),
|
||||
availablePkgUpdates: 0,
|
||||
network: {
|
||||
@@ -107,9 +122,10 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
|
||||
.map((pool) => {
|
||||
if (pool.allocated !== null && pool.size !== null && pool.free !== null) {
|
||||
return {
|
||||
...pool,
|
||||
name: pool.name,
|
||||
status: pool.status,
|
||||
healthy: pool.healthy,
|
||||
allocated: pool.allocated,
|
||||
size: pool.size,
|
||||
free: pool.free,
|
||||
};
|
||||
}
|
||||
@@ -117,12 +133,76 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
|
||||
return null;
|
||||
})
|
||||
.filter((pool) => pool !== null);
|
||||
|
||||
logger.debug("Retrieved pools", {
|
||||
url: this.integration.url,
|
||||
totalCount: result.length,
|
||||
activeCount: activePools.length,
|
||||
});
|
||||
return activePools;
|
||||
|
||||
if (activePools.length === 0) return [];
|
||||
|
||||
const usableSpaceByPool = await this.getUsableSpaceByPoolAsync(activePools.map((pool) => pool.name));
|
||||
|
||||
return activePools.map((pool) => {
|
||||
const usableSpace = usableSpaceByPool.get(pool.name);
|
||||
if (!usableSpace) {
|
||||
logger.warn("Falling back to physical pool space, the root dataset reported no usable space", {
|
||||
url: this.integration.url,
|
||||
pool: pool.name,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
name: pool.name,
|
||||
status: pool.status,
|
||||
healthy: pool.healthy,
|
||||
used: usableSpace?.used ?? pool.allocated,
|
||||
available: usableSpace?.available ?? pool.free,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the usable space of each pool's root ZFS dataset, keyed by pool name. Unlike the physical
|
||||
* `pool.query` values, these account for RAIDZ parity overhead and dataset limits, matching `zfs list`.
|
||||
* See https://github.com/homarr-labs/homarr/issues/6566
|
||||
*
|
||||
* A pool is left out when its usable space is unavailable, so the caller keeps reporting the pool with
|
||||
* its physical values instead of dropping it, which would also hide its health status:
|
||||
* - request rejected → method or `extra.properties` unsupported, or the API key lacks the dataset role
|
||||
* - response rejected → the payload does not match the expected shape
|
||||
* - dataset missing → the pool's root dataset was not returned by the query
|
||||
* - property absent → the dataset does not report `used` / `available`, e.g. while locked
|
||||
*/
|
||||
private async getUsableSpaceByPoolAsync(poolNames: string[]) {
|
||||
const usableSpaceByPool = new Map<string, { used: number; available: number }>();
|
||||
|
||||
try {
|
||||
const datasets = await this.requestAsync("pool.dataset.query", [
|
||||
[["id", "in", poolNames]],
|
||||
{
|
||||
extra: {
|
||||
properties: ["used", "available"],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
for (const dataset of await poolDatasetSchema.parseAsync(datasets)) {
|
||||
const used = dataset.used?.parsed;
|
||||
const available = dataset.available?.parsed;
|
||||
if (typeof used !== "number" || typeof available !== "number") continue;
|
||||
|
||||
usableSpaceByPool.set(dataset.id, { used, available });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn("Could not retrieve root dataset space, continuing with physical pool space", {
|
||||
url: this.integration.url,
|
||||
error: describeRequestError(error),
|
||||
});
|
||||
}
|
||||
|
||||
return usableSpaceByPool;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -263,6 +343,19 @@ const poolSchema = z.array(
|
||||
}),
|
||||
);
|
||||
|
||||
// Properties are absent or unparsed for datasets that do not report them, e.g. while locked,
|
||||
// so they are optional here and the pool falls back to its physical values instead of failing
|
||||
// the whole request, which would also drop cpu, memory and network data
|
||||
const poolDatasetPropertySchema = z.object({ parsed: z.number().min(0).nullish() }).nullish();
|
||||
|
||||
const poolDatasetSchema = z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
used: poolDatasetPropertySchema,
|
||||
available: poolDatasetPropertySchema,
|
||||
}),
|
||||
);
|
||||
|
||||
const reportingNetDataSchema = z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
// @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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { ParseError, ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
import type { WudStats } from "./wud-types";
|
||||
import { mapWudStats, parseWudContainersResponseAsync } from "./wud-types";
|
||||
|
||||
const CONTAINERS_REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
export class WudIntegration extends Integration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await input.fetchAsync(this.url("/api/app"), {
|
||||
headers: this.getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return TestConnectionError.StatusResult(response);
|
||||
}
|
||||
|
||||
try {
|
||||
await response.json();
|
||||
} catch (error) {
|
||||
return TestConnectionError.ParseResult(
|
||||
new ParseError("Invalid WUD app response", {
|
||||
cause: error instanceof Error ? error : new Error(String(error)),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
public async getStatsAsync(): Promise<WudStats> {
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/containers"), {
|
||||
headers: this.getAuthHeaders(),
|
||||
timeout: CONTAINERS_REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
|
||||
const containers = await parseWudContainersResponseAsync(response);
|
||||
|
||||
return mapWudStats(containers);
|
||||
}
|
||||
|
||||
private getAuthHeaders(): Record<string, string> | undefined {
|
||||
if (!this.hasSecretValue("username") || !this.hasSecretValue("password")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (this.url("/api/app").protocol !== "https:") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const credentials = Buffer.from(`${this.getSecretValue("username")}:${this.getSecretValue("password")}`).toString(
|
||||
"base64",
|
||||
);
|
||||
|
||||
return { Authorization: `Basic ${credentials}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ParseError } from "@homarr/common/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const WUD_CONTAINERS_PARSE_ERROR_MESSAGE = "Invalid WUD containers response";
|
||||
|
||||
export const wudContainerSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
displayName: z.string().nullable().optional(),
|
||||
updateAvailable: z.boolean(),
|
||||
link: z.string().nullable().optional(),
|
||||
image: z
|
||||
.object({
|
||||
tag: z
|
||||
.object({
|
||||
value: z.string().nullable().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
updateKind: z
|
||||
.object({
|
||||
remoteValue: z.string().nullable().optional(),
|
||||
})
|
||||
.optional(),
|
||||
result: z
|
||||
.object({
|
||||
tag: z.string().nullable().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const wudContainersResponseSchema = z.array(wudContainerSchema);
|
||||
|
||||
export interface WudContainerUpdate {
|
||||
id: string;
|
||||
name: string;
|
||||
currentVersion: string | null;
|
||||
newVersion: string | null;
|
||||
link: string | null;
|
||||
}
|
||||
|
||||
export interface WudStats {
|
||||
totalContainers: number;
|
||||
updatesAvailable: number;
|
||||
updates: WudContainerUpdate[];
|
||||
}
|
||||
|
||||
export const parseWudContainersResponseAsync = async (response: {
|
||||
json: () => Promise<unknown>;
|
||||
}): Promise<z.infer<typeof wudContainersResponseSchema>> => {
|
||||
let json: unknown;
|
||||
try {
|
||||
json = await response.json();
|
||||
} catch (error) {
|
||||
throw new ParseError(WUD_CONTAINERS_PARSE_ERROR_MESSAGE, {
|
||||
cause: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
}
|
||||
|
||||
const parseResult = await wudContainersResponseSchema.safeParseAsync(json);
|
||||
if (!parseResult.success) {
|
||||
throw new ParseError(WUD_CONTAINERS_PARSE_ERROR_MESSAGE, { cause: parseResult.error });
|
||||
}
|
||||
|
||||
return parseResult.data;
|
||||
};
|
||||
|
||||
const mapContainerUpdate = (container: z.infer<typeof wudContainerSchema>): WudContainerUpdate => ({
|
||||
id: container.id,
|
||||
name: container.displayName ?? container.name,
|
||||
currentVersion: container.image?.tag?.value ?? null,
|
||||
newVersion: container.updateKind?.remoteValue ?? container.result?.tag ?? null,
|
||||
link: container.link ?? null,
|
||||
});
|
||||
|
||||
export const mapWudStats = (containers: z.infer<typeof wudContainersResponseSchema>): WudStats => ({
|
||||
totalContainers: containers.length,
|
||||
updatesAvailable: containers.filter((container) => container.updateAvailable).length,
|
||||
updates: containers.filter((container) => container.updateAvailable).map(mapContainerUpdate),
|
||||
});
|
||||
Reference in new issue
Block a user