Files
homarr/packages/integrations/src/synology/test/synology-integration.spec.ts
T

586 lines
18 KiB
TypeScript

// @vitest-environment node
import { Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.hoisted(() => {
process.env.CI = "true";
process.env.NODE_ENV = "test";
process.env.SECRET_ENCRYPTION_KEY = "0".repeat(64);
});
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
vi.mock("@homarr/core/infrastructure/logs", () => ({
createLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
}));
vi.mock("../../base/session-store", () => ({
createSessionStore: () => ({
getAsync: vi.fn().mockResolvedValue(null),
setAsync: vi.fn().mockResolvedValue(undefined),
clearAsync: vi.fn().mockResolvedValue(undefined),
}),
}));
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { IntegrationResponseError } from "../../base/errors/http/integration-response-error";
import type { IntegrationSecret } from "../../base/types";
import { SynologyIntegration } from "../synology-integration";
const TEST_URL = "https://synology.example.com:5001";
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const createIntegration = (secrets: IntegrationSecret[] = []) =>
new SynologyIntegration({
id: "test-synology",
name: "Test Synology",
url: TEST_URL,
externalUrl: null,
decryptedSecrets: secrets.length
? secrets
: [
{ kind: "username", value: "homarr" },
{ kind: "password", value: "secret-password" },
],
});
const apiDefinitions = {
"SYNO.API.Auth": { path: "auth.cgi", maxVersion: 7 },
"SYNO.API.Info": { path: "query.cgi", maxVersion: 1 },
"SYNO.Core.System": { path: "entry.cgi", maxVersion: 3 },
"SYNO.Core.System.Utilization": { path: "entry.cgi", maxVersion: 1 },
"SYNO.Storage.CGI.Storage": { path: "entry.cgi", maxVersion: 1 },
"SYNO.Storage.CGI.Smart": { path: "entry.cgi", maxVersion: 1 },
"SYNO.Core.System.Status": { path: "entry.cgi", maxVersion: 1 },
"SYNO.Core.Upgrade": { path: "entry.cgi", maxVersion: 1 },
"SYNO.Core.Upgrade.Server": { path: "entry.cgi", maxVersion: 1 },
};
const systemInfoResponse = {
success: true as const,
data: {
up_time: "48:30:15",
version_string: "DSM 7.3-12345",
model: "DS920+",
sys_temp: 42,
},
};
const utilizationResponse = {
success: true,
data: {
cpu: {
user_load: 12,
system_load: 8,
"1min_load": 10,
"5min_load": 14,
"15min_load": 9,
},
memory: {
real_usage: 35,
total_real: 8 * 1024 * 1024,
avail_real: Math.round(8 * 1024 * 1024 * 0.65),
cached: 900_000,
},
network: [
{ device: "total", rx: 4096, tx: 8192 },
{ device: "eth0", rx: 4096, tx: 8192 },
],
},
};
const storageV2Response = {
success: true,
data: {
volumes: [
{
id: "volume_1",
display_name: "Data Volume 1",
size: { total: 10_000_000_000, used: 4_000_000_000 },
status: "normal",
},
{
id: "volume_2",
display_name: "Data Volume 2",
size: { total: 20_000_000_000, used: 5_000_000_000 },
status: "degraded",
},
],
},
};
const storageLoadInfoResponse = {
success: true,
data: {
disks: [
{
id: "sda",
display_name: "Drive 1",
temp: 38,
status: "normal",
volume_id: "volume_1",
},
{
id: "sdb",
display_name: "Drive 2",
temp: 44,
status: "normal",
volume_id: "volume_2",
},
],
},
};
const smartInfoResponse = {
success: true,
data: {
disks: [
{
disk_id: "sda",
name: "Drive 1",
overall_status: "normal",
temp: 39,
},
],
},
};
const systemStatusResponse = {
success: true,
data: {
reboot_required: true,
},
};
const upgradeStatusResponse = {
success: true,
data: {
reboot_required: false,
},
};
const upgradeCheckResponse = {
success: true,
data: {
available: true,
update_count: 2,
},
};
const toUrlString = (url: unknown): string => String(url);
const getApiFromUrl = (url: string) => {
const parsedUrl = new URL(url);
return parsedUrl.searchParams.get("api");
};
const getMethodFromUrl = (url: string) => {
const parsedUrl = new URL(url);
return parsedUrl.searchParams.get("method");
};
const getTypeFromUrl = (url: string) => {
const parsedUrl = new URL(url);
return parsedUrl.searchParams.get("type");
};
const mockSuccessfulFlow = (options?: {
skipStorageV2?: boolean;
skipOptional?: boolean;
systemInfo?: {
success: boolean;
data: Record<string, unknown>;
};
utilization?: typeof utilizationResponse;
storageV2?: typeof storageV2Response;
storageLoadInfo?: typeof storageLoadInfoResponse;
}) => {
mockFetch.mockImplementation((url) => {
const urlString = toUrlString(url);
const apiName = getApiFromUrl(urlString);
const method = getMethodFromUrl(urlString);
const type = getTypeFromUrl(urlString);
if (apiName === "SYNO.API.Info" && method === "query") {
const query = new URL(urlString).searchParams.get("query") ?? "SYNO.API.Auth";
const definition = apiDefinitions[query as keyof typeof apiDefinitions];
return Promise.resolve(
new Response(JSON.stringify({ success: true, data: { [query]: definition } }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (apiName === "SYNO.API.Auth" && method === "login") {
return Promise.resolve(
new Response(JSON.stringify({ success: true, data: { sid: "test-session-id" } }), {
status: 200,
headers: {
"Content-Type": "application/json",
"Set-Cookie": "id=test-session-id; Path=/; HttpOnly",
},
}),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (apiName === "SYNO.Core.System" && method === "info" && type === "storage_v2") {
if (options?.skipStorageV2) {
return Promise.resolve(
new Response(JSON.stringify({ success: false, error: { code: 103 } }), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
return Promise.resolve(new Response(JSON.stringify(options?.storageV2 ?? storageV2Response), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
}
if (apiName === "SYNO.Core.System" && method === "info" && type === "storage") {
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
data: {
vol_info: [
{
name: "volume_1",
used_size: 4_000_000_000,
total_size: 10_000_000_000,
status: "normal",
},
],
},
}),
{ status: 200 },
),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (apiName === "SYNO.Core.System" && method === "info" && !type) {
return Promise.resolve(
new Response(JSON.stringify(options?.systemInfo ?? systemInfoResponse), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (apiName === "SYNO.Core.System.Utilization" && method === "get") {
return Promise.resolve(
new Response(JSON.stringify(options?.utilization ?? utilizationResponse), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (options?.skipOptional) {
return Promise.resolve(
new Response(JSON.stringify({ success: false, error: { code: 105 } }), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (apiName === "SYNO.Storage.CGI.Storage" && method === "load_info") {
return Promise.resolve(
new Response(JSON.stringify(options?.storageLoadInfo ?? storageLoadInfoResponse), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (apiName === "SYNO.Storage.CGI.Smart" && method === "get_smart_info") {
return Promise.resolve(new Response(JSON.stringify(smartInfoResponse), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
}
if (apiName === "SYNO.Core.System.Status" && method === "get") {
return Promise.resolve(
new Response(JSON.stringify(systemStatusResponse), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (apiName === "SYNO.Core.Upgrade" && method === "status") {
return Promise.resolve(
new Response(JSON.stringify(upgradeStatusResponse), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (apiName === "SYNO.Core.Upgrade.Server" && method === "check") {
return Promise.resolve(
new Response(JSON.stringify(upgradeCheckResponse), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
return Promise.resolve(
new Response(JSON.stringify({ success: false, error: { code: 102 } }), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
});
};
beforeEach(() => {
mockFetch.mockReset();
});
describe("SynologyIntegration.getSystemInfoAsync", () => {
test("maps all supported health monitoring fields", async () => {
mockSuccessfulFlow();
const integration = createIntegration();
const result = await integration.getSystemInfoAsync();
expect(result.version).toBe("DSM 7.3-12345");
expect(result.cpuModelName).toBe("DS920+");
expect(result.cpuUtilization).toBe(20);
expect(result.memUsedInBytes).toBe(Math.round((8 * 1024 * 1024 * 1024 * 35) / 100));
expect(result.memAvailableInBytes).toBe(8 * 1024 * 1024 * 1024 - Math.round((8 * 1024 * 1024 * 1024 * 35) / 100));
expect(result.uptime).toBe(48 * 3600 + 30 * 60 + 15);
expect(result.cpuTemp).toBe(42);
expect(result.network).toEqual({ up: 8192, down: 4096 });
expect(result.loadAverage).toEqual({ "1min": 10, "5min": 14, "15min": 9 });
expect(result.rebootRequired).toBe(true);
expect(result.availablePkgUpdates).toBe(2);
expect(result.gpu).toEqual([]);
expect(result.fileSystem).toHaveLength(2);
expect(result.fileSystem[0]).toEqual({
deviceName: "volume_1",
used: "4000000000",
available: "6000000000",
percentage: 40,
});
expect(result.smart).toEqual([
{
deviceName: "volume_1",
healthy: true,
overallStatus: "normal",
temperature: 39,
},
{
deviceName: "volume_2",
healthy: false,
overallStatus: "degraded",
temperature: 44,
},
]);
});
test("picks worst disk status when volume status is missing", async () => {
mockSuccessfulFlow({
storageV2: {
success: true,
data: {
volumes: [
{
id: "volume_1",
display_name: "Data Volume 1",
size: { total: 10_000_000_000, used: 4_000_000_000 },
},
],
},
},
storageLoadInfo: {
success: true,
data: {
disks: [
{
id: "sda",
display_name: "Drive 1",
temp: 38,
status: "normal",
volume_id: "volume_1",
},
{
id: "sdb",
display_name: "Drive 2",
temp: 40,
status: "degraded",
volume_id: "volume_1",
},
],
},
},
});
const integration = createIntegration();
const result = await integration.getSystemInfoAsync();
expect(result.smart).toEqual([
{
deviceName: "volume_1",
healthy: false,
overallStatus: "degraded",
temperature: 40,
},
]);
});
test("falls back to legacy storage info when storage_v2 is unavailable", async () => {
mockSuccessfulFlow({ skipStorageV2: true });
const integration = createIntegration();
const result = await integration.getSystemInfoAsync();
expect(result.fileSystem).toHaveLength(1);
expect(result.fileSystem[0]?.deviceName).toBe("volume_1");
});
test("continues when optional tier-2 calls fail", async () => {
mockSuccessfulFlow({ skipOptional: true });
const integration = createIntegration();
const result = await integration.getSystemInfoAsync();
expect(result.cpuUtilization).toBe(20);
expect(result.rebootRequired).toBe(false);
expect(result.availablePkgUpdates).toBe(0);
expect(result.smart.every((entry) => entry.temperature === null)).toBe(true);
});
test("converts memory values from KiB to bytes", async () => {
mockSuccessfulFlow({
utilization: {
...utilizationResponse,
data: {
...utilizationResponse.data,
memory: {
real_usage: 17,
total_real: 16_351_532,
avail_real: 426_568,
},
},
},
});
const integration = createIntegration();
const result = await integration.getSystemInfoAsync();
const totalBytes = 16_351_532 * 1024;
expect(result.memUsedInBytes).toBe(Math.round((totalBytes * 17) / 100));
expect(result.memAvailableInBytes).toBe(totalBytes - Math.round((totalBytes * 17) / 100));
});
test("caps cpu utilization at 100 when user and system load exceed 100", async () => {
mockSuccessfulFlow({
utilization: {
...utilizationResponse,
data: {
...utilizationResponse.data,
cpu: {
...utilizationResponse.data.cpu,
user_load: 80,
system_load: 30,
},
},
},
});
const integration = createIntegration();
const result = await integration.getSystemInfoAsync();
expect(result.cpuUtilization).toBe(100);
});
test("returns undefined cpu temperature when sensor is absent", async () => {
mockSuccessfulFlow({
systemInfo: {
success: true,
data: {
up_time: "01:00:00",
version_string: "DSM 7.3-12345",
model: "DS920+",
},
},
});
const integration = createIntegration();
const result = await integration.getSystemInfoAsync();
expect(result.cpuTemp).toBeUndefined();
});
});
describe("SynologyIntegration.listStorageVolumesAsync", () => {
test("returns auto-detected volume names", async () => {
mockSuccessfulFlow();
const integration = createIntegration();
const volumes = await integration.listStorageVolumesAsync();
expect(volumes).toEqual([
{ value: "test-synology:volume_1", label: "Data Volume 1 (Test Synology)" },
{ value: "test-synology:volume_2", label: "Data Volume 2 (Test Synology)" },
]);
});
});
describe("SynologyIntegration authentication", () => {
test("rejects two-factor authentication during login", async () => {
mockFetch.mockImplementation((url) => {
const urlString = toUrlString(url);
const apiName = getApiFromUrl(urlString);
const method = getMethodFromUrl(urlString);
if (apiName === "SYNO.API.Info" && method === "query") {
const query = new URL(urlString).searchParams.get("query") ?? "SYNO.API.Auth";
const definition = apiDefinitions[query as keyof typeof apiDefinitions];
return Promise.resolve(
new Response(JSON.stringify({ success: true, data: { [query]: definition } }), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (apiName === "SYNO.API.Auth" && method === "login") {
return Promise.resolve(
new Response(JSON.stringify({ success: false, error: { code: 403 } }), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
return Promise.resolve(new Response("{}", { status: 500 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
});
const integration = createIntegration();
await expect(integration.getSystemInfoAsync()).rejects.toThrow();
});
test("does not leak credentials in login error URLs", async () => {
mockFetch.mockImplementation((url, init) => {
const urlString = toUrlString(url);
const apiName = getApiFromUrl(urlString);
const method = getMethodFromUrl(urlString);
if (apiName === "SYNO.API.Info" && method === "query") {
const query = new URL(urlString).searchParams.get("query") ?? "SYNO.API.Auth";
const definition = apiDefinitions[query as keyof typeof apiDefinitions];
return Promise.resolve(
new Response(JSON.stringify({ success: true, data: { [query]: definition } }), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (apiName === "SYNO.API.Auth" && method === "login") {
expect(init?.method).toBe("POST");
expect(init?.body).toContain("account=homarr");
expect(init?.body).toContain("passwd=secret-password");
expect(urlString).not.toContain("account=");
expect(urlString).not.toContain("passwd=");
return Promise.resolve(
new Response(JSON.stringify({ success: false, error: { code: 403 } }), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
return Promise.resolve(new Response("{}", { status: 500 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
});
const integration = createIntegration();
try {
await integration.getSystemInfoAsync();
expect.unreachable("Expected login to fail");
} catch (error) {
expect(error).toBeInstanceOf(IntegrationResponseError);
const responseError = (error as IntegrationResponseError).cause;
expect(responseError).toBeInstanceOf(ResponseError);
expect(responseError.url).not.toContain("secret-password");
expect(responseError.url).not.toContain("passwd=");
expect(responseError.url).not.toContain("account=homarr");
}
});
});