feat: add VPN status widget (#5812)
Co-authored-by: Lokesh-Kumar-Dudi <lkumardudi@gmail.com> Co-authored-by: Thomas Camlong <thomas@ajnart.fr>
This commit is contained in:
36 files changed
+1332
-224
No files matched your search
@@ -21,6 +21,7 @@ import { GitHubContainerRegistryIntegration } from "../github-container-registry
|
||||
import { GithubIntegration } from "../github/github-integration";
|
||||
import { GitlabIntegration } from "../gitlab/gitlab-integration";
|
||||
import { GlancesIntegration } from "../glances/glances-integration";
|
||||
import { GluetunIntegration } from "../gluetun/gluetun-integration";
|
||||
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
|
||||
import { ICalIntegration } from "../ical/ical-integration";
|
||||
import { ImmichIntegration } from "../immich/immich-integration";
|
||||
@@ -137,6 +138,7 @@ export const integrationCreators = {
|
||||
audiobookshelf: AudiobookshelfIntegration,
|
||||
navidrome: NavidromeIntegration,
|
||||
umami: UmamiIntegration,
|
||||
gluetun: GluetunIntegration,
|
||||
archiveTeamWarrior: ArchiveTeamWarriorIntegration,
|
||||
uptimeKuma: UptimeKumaIntegration,
|
||||
peaNut: PeaNutIntegration,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { 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 { VpnSummaryIntegration } from "../interfaces/vpn-summary/vpn-summary-integration";
|
||||
import type { VpnSummary } from "../interfaces/vpn-summary/vpn-summary-types";
|
||||
import {
|
||||
gluetunDnsStatusSchema,
|
||||
gluetunPublicIpSchema,
|
||||
gluetunVpnSettingsSchema,
|
||||
gluetunVpnStatusSchema,
|
||||
} from "./gluetun-types";
|
||||
import type { GluetunDnsStatus, GluetunPublicIp, GluetunVpnSettings, GluetunVpnStatus } from "./gluetun-types";
|
||||
|
||||
export class GluetunIntegration extends Integration implements VpnSummaryIntegration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const authHeaders = this.getAuthHeaders();
|
||||
const url = this.url("/v1/vpn/status");
|
||||
const response = await input.fetchAsync(url, {
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return TestConnectionError.StatusResult(response);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
public async getSummaryAsync(): Promise<VpnSummary> {
|
||||
const authHeaders = this.getAuthHeaders();
|
||||
const [vpnStatus, dnsStatus, publicIp, vpnSettings] = await Promise.all([
|
||||
this.getVpnStatusAsync(authHeaders),
|
||||
this.getDnsStatusAsync(authHeaders),
|
||||
this.getPublicIpAsync(authHeaders),
|
||||
this.getVpnSettingAsync(authHeaders),
|
||||
]);
|
||||
|
||||
return {
|
||||
vpnStatus: vpnStatus.status,
|
||||
dnsStatus: dnsStatus.status,
|
||||
publicIp: publicIp.public_ip,
|
||||
country: publicIp.country,
|
||||
city: publicIp.city,
|
||||
vpnProvider: {
|
||||
protocol: vpnSettings.type,
|
||||
provider: vpnSettings.provider.name,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async getVpnStatusAsync(authHeaders: Record<string, string>): Promise<GluetunVpnStatus> {
|
||||
const url = this.url("/v1/vpn/status");
|
||||
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
if (!response.ok) throw new ResponseError(response);
|
||||
return gluetunVpnStatusSchema.parse(await response.json());
|
||||
}
|
||||
|
||||
private async getDnsStatusAsync(authHeaders: Record<string, string>): Promise<GluetunDnsStatus> {
|
||||
const url = this.url("/v1/dns/status");
|
||||
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
if (!response.ok) throw new ResponseError(response);
|
||||
return gluetunDnsStatusSchema.parse(await response.json());
|
||||
}
|
||||
|
||||
private async getPublicIpAsync(authHeaders: Record<string, string>): Promise<GluetunPublicIp> {
|
||||
const url = this.url("/v1/publicip/ip");
|
||||
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
if (!response.ok) throw new ResponseError(response);
|
||||
return gluetunPublicIpSchema.parse(await response.json());
|
||||
}
|
||||
|
||||
private async getVpnSettingAsync(authHeaders: Record<string, string>): Promise<GluetunVpnSettings> {
|
||||
const url = this.url("/v1/vpn/settings");
|
||||
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
if (!response.ok) throw new ResponseError(response);
|
||||
return gluetunVpnSettingsSchema.parse(await response.json());
|
||||
}
|
||||
|
||||
private getAuthHeaders(): Record<string, string> {
|
||||
if (this.hasSecretValue("apiKey")) {
|
||||
return { "X-API-Key": this.getSecretValue("apiKey") };
|
||||
}
|
||||
|
||||
if (this.hasSecretValue("username") && this.hasSecretValue("password")) {
|
||||
const username = this.getSecretValue("username");
|
||||
const password = this.getSecretValue("password");
|
||||
|
||||
const encodedCredentials = btoa(`${username}:${password}`);
|
||||
|
||||
return { Authorization: `Basic ${encodedCredentials}` };
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const gluetunVpnStatusSchema = z.object({
|
||||
status: z.string(),
|
||||
});
|
||||
|
||||
export type GluetunVpnStatus = z.infer<typeof gluetunVpnStatusSchema>;
|
||||
|
||||
export const gluetunDnsStatusSchema = gluetunVpnStatusSchema;
|
||||
|
||||
export type GluetunDnsStatus = z.infer<typeof gluetunDnsStatusSchema>;
|
||||
|
||||
export const gluetunPublicIpSchema = z.object({
|
||||
public_ip: z.string(),
|
||||
region: z.string(),
|
||||
country: z.string(),
|
||||
city: z.string(),
|
||||
location: z.string(),
|
||||
organization: z.string(),
|
||||
postal_code: z.string(),
|
||||
timezone: z.string(),
|
||||
});
|
||||
|
||||
export type GluetunPublicIp = z.infer<typeof gluetunPublicIpSchema>;
|
||||
|
||||
const gluetunServerSelectionOpenvpnSchema = z.object({
|
||||
config_file_path: z.string(),
|
||||
protocol: z.string(),
|
||||
endpoint_ip: z.string(),
|
||||
custom_port: z.number(),
|
||||
pia_encryption_preset: z.string(),
|
||||
});
|
||||
|
||||
const gluetunServerSelectionWireguardSchema = z.object({
|
||||
endpoint_ip: z.string(),
|
||||
endpoint_port: z.number(),
|
||||
public_key: z.string(),
|
||||
});
|
||||
|
||||
const gluetunServerSelectionSchema = z.object({
|
||||
vpn: z.string(),
|
||||
countries: z.array(z.string()).nullable(),
|
||||
categories: z.array(z.string()).nullable(),
|
||||
regions: z.array(z.string()).nullable(),
|
||||
cities: z.array(z.string()).nullable(),
|
||||
isps: z.array(z.string()).nullable(),
|
||||
names: z.array(z.string()).nullable(),
|
||||
numbers: z.array(z.number()).nullable(),
|
||||
hostnames: z.array(z.string()),
|
||||
owned_only: z.boolean(),
|
||||
free_only: z.boolean(),
|
||||
premium_only: z.boolean(),
|
||||
stream_only: z.boolean(),
|
||||
multi_hop_only: z.boolean(),
|
||||
port_forward_only: z.boolean(),
|
||||
secure_core_only: z.boolean(),
|
||||
tor_only: z.boolean(),
|
||||
openvpn: gluetunServerSelectionOpenvpnSchema,
|
||||
wireguard: gluetunServerSelectionWireguardSchema,
|
||||
});
|
||||
|
||||
const gluetunPortForwardingSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
provider: z.string(),
|
||||
status_file_path: z.string(),
|
||||
up_command: z.string(),
|
||||
down_command: z.string(),
|
||||
listening_port: z.number(),
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
});
|
||||
|
||||
const gluetunProviderSchema = z.object({
|
||||
name: z.string(),
|
||||
server_selection: gluetunServerSelectionSchema,
|
||||
port_forwarding: gluetunPortForwardingSchema,
|
||||
});
|
||||
|
||||
const gluetunOpenvpnSettingsSchema = z.object({
|
||||
version: z.string(),
|
||||
user: z.string(),
|
||||
password: z.string(),
|
||||
config_file_path: z.string(),
|
||||
ciphers: z.array(z.string()).nullable(),
|
||||
auth: z.string(),
|
||||
cert: z.string(),
|
||||
key: z.string(),
|
||||
encrypted_key: z.string(),
|
||||
key_passphrase: z.string(),
|
||||
pia_encryption_preset: z.string(),
|
||||
mssfix: z.number(),
|
||||
interface: z.string(),
|
||||
process_user: z.string(),
|
||||
verbosity: z.number(),
|
||||
flags: z.array(z.string()).nullable(),
|
||||
});
|
||||
|
||||
const gluetunWireguardSettingsSchema = z.object({
|
||||
private_key: z.string(),
|
||||
pre_shared_key: z.string(),
|
||||
addresses: z.array(z.string()),
|
||||
allowed_ips: z.array(z.string()),
|
||||
interface: z.string(),
|
||||
persistent_keep_alive_interval: z.number(),
|
||||
mtu: z.number(),
|
||||
implementation: z.string(),
|
||||
});
|
||||
|
||||
export const gluetunVpnSettingsSchema = z.object({
|
||||
type: z.string(),
|
||||
provider: gluetunProviderSchema,
|
||||
openvpn: gluetunOpenvpnSettingsSchema,
|
||||
wireguard: gluetunWireguardSettingsSchema,
|
||||
});
|
||||
|
||||
export type GluetunVpnSettings = z.infer<typeof gluetunVpnSettingsSchema>;
|
||||
@@ -0,0 +1,371 @@
|
||||
import { Response } from "undici";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { IntegrationTestingInput } from "../../base/integration";
|
||||
import { GluetunIntegration } from "../gluetun-integration";
|
||||
import {
|
||||
gluetunDnsStatusSchema,
|
||||
gluetunPublicIpSchema,
|
||||
gluetunVpnSettingsSchema,
|
||||
gluetunVpnStatusSchema,
|
||||
} from "../gluetun-types";
|
||||
|
||||
vi.mock("@homarr/db", async (importActual) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
const actual = await importActual<typeof import("@homarr/db")>();
|
||||
return {
|
||||
...actual,
|
||||
db: createDb(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
const TEST_URL = "https://gluetun.example.com";
|
||||
const TEST_API_KEY = "test-api-key";
|
||||
const TEST_USERNAME = "user";
|
||||
const TEST_PASSWORD = "p@ss";
|
||||
|
||||
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||
|
||||
type FetchResponse = Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>;
|
||||
|
||||
const VPN_STATUS_PAYLOAD = { status: "running" };
|
||||
const DNS_STATUS_PAYLOAD = { status: "running" };
|
||||
const PUBLIC_IP_PAYLOAD = {
|
||||
public_ip: "203.0.113.42",
|
||||
region: "Europe",
|
||||
country: "Germany",
|
||||
city: "Berlin",
|
||||
location: "52.5200,13.4050",
|
||||
organization: "Example Org",
|
||||
postal_code: "10115",
|
||||
timezone: "Europe/Berlin",
|
||||
};
|
||||
const VPN_SETTINGS_PAYLOAD = {
|
||||
type: "wireguard",
|
||||
provider: {
|
||||
name: "mullvad",
|
||||
server_selection: {
|
||||
vpn: "wireguard",
|
||||
countries: ["Germany"],
|
||||
categories: null,
|
||||
regions: null,
|
||||
cities: ["Berlin"],
|
||||
isps: null,
|
||||
names: null,
|
||||
numbers: null,
|
||||
hostnames: ["de-ber-wg-001"],
|
||||
owned_only: false,
|
||||
free_only: false,
|
||||
premium_only: false,
|
||||
stream_only: false,
|
||||
multi_hop_only: false,
|
||||
port_forward_only: false,
|
||||
secure_core_only: false,
|
||||
tor_only: false,
|
||||
openvpn: {
|
||||
config_file_path: "",
|
||||
protocol: "udp",
|
||||
endpoint_ip: "",
|
||||
custom_port: 0,
|
||||
pia_encryption_preset: "",
|
||||
},
|
||||
wireguard: {
|
||||
endpoint_ip: "203.0.113.1",
|
||||
endpoint_port: 51820,
|
||||
public_key: "pubkey",
|
||||
},
|
||||
},
|
||||
port_forwarding: {
|
||||
enabled: false,
|
||||
provider: "mullvad",
|
||||
status_file_path: "",
|
||||
up_command: "",
|
||||
down_command: "",
|
||||
listening_port: 0,
|
||||
username: "",
|
||||
password: "",
|
||||
},
|
||||
},
|
||||
openvpn: {
|
||||
version: "",
|
||||
user: "",
|
||||
password: "",
|
||||
config_file_path: "",
|
||||
ciphers: null,
|
||||
auth: "",
|
||||
cert: "",
|
||||
key: "",
|
||||
encrypted_key: "",
|
||||
key_passphrase: "",
|
||||
pia_encryption_preset: "",
|
||||
mssfix: 0,
|
||||
interface: "tun0",
|
||||
process_user: "",
|
||||
verbosity: 0,
|
||||
flags: null,
|
||||
},
|
||||
wireguard: {
|
||||
private_key: "",
|
||||
pre_shared_key: "",
|
||||
addresses: ["10.0.0.2/32"],
|
||||
allowed_ips: ["0.0.0.0/0"],
|
||||
interface: "wg0",
|
||||
persistent_keep_alive_interval: 25,
|
||||
mtu: 1420,
|
||||
implementation: "auto",
|
||||
},
|
||||
};
|
||||
|
||||
const jsonResponse = (body: unknown, init: ResponseInit = { status: 200 }): FetchResponse =>
|
||||
new Response(JSON.stringify(body), {
|
||||
...init,
|
||||
headers: { "content-type": "application/json" },
|
||||
}) as unknown as FetchResponse;
|
||||
|
||||
const setupHappyPath = () => {
|
||||
mockFetch.mockImplementation((url) => {
|
||||
const urlString = typeof url === "string" ? url : url.toString();
|
||||
const { pathname } = new URL(urlString);
|
||||
|
||||
switch (pathname) {
|
||||
case "/v1/vpn/status":
|
||||
return Promise.resolve(jsonResponse(VPN_STATUS_PAYLOAD));
|
||||
case "/v1/dns/status":
|
||||
return Promise.resolve(jsonResponse(DNS_STATUS_PAYLOAD));
|
||||
case "/v1/publicip/ip":
|
||||
return Promise.resolve(jsonResponse(PUBLIC_IP_PAYLOAD));
|
||||
case "/v1/vpn/settings":
|
||||
return Promise.resolve(jsonResponse(VPN_SETTINGS_PAYLOAD));
|
||||
default:
|
||||
return Promise.resolve(jsonResponse({ error: "not found" }, { status: 404 }));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const createIntegrationWithApiKey = () =>
|
||||
new GluetunIntegration({
|
||||
id: "gluetun-1",
|
||||
name: "Gluetun",
|
||||
url: TEST_URL,
|
||||
externalUrl: null,
|
||||
decryptedSecrets: [{ kind: "apiKey", value: TEST_API_KEY }],
|
||||
});
|
||||
|
||||
const createIntegrationWithBasicAuth = () =>
|
||||
new GluetunIntegration({
|
||||
id: "gluetun-2",
|
||||
name: "Gluetun",
|
||||
url: TEST_URL,
|
||||
externalUrl: null,
|
||||
decryptedSecrets: [
|
||||
{ kind: "username", value: TEST_USERNAME },
|
||||
{ kind: "password", value: TEST_PASSWORD },
|
||||
],
|
||||
});
|
||||
|
||||
describe("GluetunIntegration.getSummaryAsync", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("maps the four upstream responses into a flat VPN summary", async () => {
|
||||
setupHappyPath();
|
||||
|
||||
const result = await createIntegrationWithApiKey().getSummaryAsync();
|
||||
|
||||
expect(result).toEqual({
|
||||
vpnStatus: "running",
|
||||
dnsStatus: "running",
|
||||
publicIp: "203.0.113.42",
|
||||
country: "Germany",
|
||||
city: "Berlin",
|
||||
vpnProvider: {
|
||||
protocol: "wireguard",
|
||||
provider: "mullvad",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("hits each of the four control endpoints exactly once", async () => {
|
||||
setupHappyPath();
|
||||
|
||||
await createIntegrationWithApiKey().getSummaryAsync();
|
||||
|
||||
const calledPaths = mockFetch.mock.calls.map(([url]) => {
|
||||
const urlString = typeof url === "string" ? url : url.toString();
|
||||
return new URL(urlString).pathname;
|
||||
});
|
||||
|
||||
expect(calledPaths).toHaveLength(4);
|
||||
expect(new Set(calledPaths)).toEqual(
|
||||
new Set(["/v1/vpn/status", "/v1/dns/status", "/v1/publicip/ip", "/v1/vpn/settings"]),
|
||||
);
|
||||
});
|
||||
|
||||
test("sends X-API-Key header when an apiKey secret is configured", async () => {
|
||||
setupHappyPath();
|
||||
|
||||
await createIntegrationWithApiKey().getSummaryAsync();
|
||||
|
||||
for (const [, init] of mockFetch.mock.calls) {
|
||||
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||
expect(headers["X-API-Key"]).toBe(TEST_API_KEY);
|
||||
expect(headers.Authorization).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("sends Basic auth header when username/password are configured", async () => {
|
||||
setupHappyPath();
|
||||
|
||||
await createIntegrationWithBasicAuth().getSummaryAsync();
|
||||
|
||||
const expected = `Basic ${btoa(`${TEST_USERNAME}:${TEST_PASSWORD}`)}`;
|
||||
for (const [, init] of mockFetch.mock.calls) {
|
||||
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||
expect(headers.Authorization).toBe(expected);
|
||||
expect(headers["X-API-Key"]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("throws when any upstream endpoint returns a non-OK status", async () => {
|
||||
mockFetch.mockImplementation((url) => {
|
||||
const urlString = typeof url === "string" ? url : url.toString();
|
||||
const { pathname } = new URL(urlString);
|
||||
|
||||
if (pathname === "/v1/publicip/ip") {
|
||||
return Promise.resolve(jsonResponse({ error: "unauthorized" }, { status: 401 }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(VPN_STATUS_PAYLOAD));
|
||||
});
|
||||
|
||||
await expect(createIntegrationWithApiKey().getSummaryAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("throws when an upstream payload fails schema validation", async () => {
|
||||
mockFetch.mockImplementation((url) => {
|
||||
const urlString = typeof url === "string" ? url : url.toString();
|
||||
const { pathname } = new URL(urlString);
|
||||
|
||||
if (pathname === "/v1/publicip/ip") {
|
||||
return Promise.resolve(jsonResponse({ public_ip: 123 }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(VPN_STATUS_PAYLOAD));
|
||||
});
|
||||
|
||||
await expect(createIntegrationWithApiKey().getSummaryAsync()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GluetunIntegration testing endpoint", () => {
|
||||
type TestingFn = (input: IntegrationTestingInput) => Promise<{ success: boolean }>;
|
||||
|
||||
const invokeTesting = (integration: GluetunIntegration, 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 /v1/vpn/status responds 200", async () => {
|
||||
const input = makeInput(new Response(JSON.stringify(VPN_STATUS_PAYLOAD), { status: 200 }));
|
||||
|
||||
const result = await invokeTesting(createIntegrationWithApiKey(), input);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("returns a status-code failure when /v1/vpn/status responds non-OK", async () => {
|
||||
const input = makeInput(new Response("unauthorized", { status: 401 }));
|
||||
|
||||
const result = await invokeTesting(createIntegrationWithApiKey(), input);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("passes the api-key header through to fetchAsync", async () => {
|
||||
const response = new Response(JSON.stringify(VPN_STATUS_PAYLOAD), { status: 200 });
|
||||
const input = makeInput(response);
|
||||
|
||||
await invokeTesting(createIntegrationWithApiKey(), input);
|
||||
|
||||
const fetchAsyncMock = vi.mocked(input.fetchAsync);
|
||||
const init = fetchAsyncMock.mock.calls[0]?.[1];
|
||||
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||
expect(headers["X-API-Key"]).toBe(TEST_API_KEY);
|
||||
});
|
||||
|
||||
test("passes the Basic auth header through to fetchAsync when username/password are configured", async () => {
|
||||
const response = new Response(JSON.stringify(VPN_STATUS_PAYLOAD), { 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 ${btoa(`${TEST_USERNAME}:${TEST_PASSWORD}`)}`;
|
||||
expect(headers.Authorization).toBe(expected);
|
||||
expect(headers["X-API-Key"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("gluetun schemas", () => {
|
||||
test("vpn status schema accepts a string status", () => {
|
||||
expect(gluetunVpnStatusSchema.parse({ status: "running" })).toEqual({ status: "running" });
|
||||
});
|
||||
|
||||
test("vpn status schema rejects a missing status", () => {
|
||||
expect(() => gluetunVpnStatusSchema.parse({})).toThrow();
|
||||
});
|
||||
|
||||
test("dns status schema mirrors vpn status schema", () => {
|
||||
expect(gluetunDnsStatusSchema.parse({ status: "stopped" })).toEqual({ status: "stopped" });
|
||||
expect(() => gluetunDnsStatusSchema.parse({ status: 42 })).toThrow();
|
||||
});
|
||||
|
||||
test("public ip schema parses the full payload", () => {
|
||||
expect(gluetunPublicIpSchema.parse(PUBLIC_IP_PAYLOAD)).toEqual(PUBLIC_IP_PAYLOAD);
|
||||
});
|
||||
|
||||
test("public ip schema rejects a missing required field", () => {
|
||||
const { city: _city, ...partial } = PUBLIC_IP_PAYLOAD;
|
||||
expect(() => gluetunPublicIpSchema.parse(partial)).toThrow();
|
||||
});
|
||||
|
||||
test("vpn settings schema parses a wireguard payload", () => {
|
||||
const parsed = gluetunVpnSettingsSchema.parse(VPN_SETTINGS_PAYLOAD);
|
||||
expect(parsed.type).toBe("wireguard");
|
||||
expect(parsed.provider.name).toBe("mullvad");
|
||||
});
|
||||
|
||||
test("vpn settings schema accepts nullable server-selection arrays", () => {
|
||||
const payloadWithNulls = {
|
||||
...VPN_SETTINGS_PAYLOAD,
|
||||
provider: {
|
||||
...VPN_SETTINGS_PAYLOAD.provider,
|
||||
server_selection: {
|
||||
...VPN_SETTINGS_PAYLOAD.provider.server_selection,
|
||||
countries: null,
|
||||
cities: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => gluetunVpnSettingsSchema.parse(payloadWithNulls)).not.toThrow();
|
||||
});
|
||||
|
||||
test("vpn settings schema rejects a non-string type", () => {
|
||||
expect(() => gluetunVpnSettingsSchema.parse({ ...VPN_SETTINGS_PAYLOAD, type: 1 })).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -15,9 +15,9 @@ export class GotifyIntegration extends Integration implements INotificationsInte
|
||||
}
|
||||
|
||||
private getHeaders() {
|
||||
const credentials = Buffer.from(
|
||||
`${super.getSecretValue("username")}:${super.getSecretValue("password")}`,
|
||||
).toString("base64");
|
||||
const credentials = Buffer.from(`${super.getSecretValue("username")}:${super.getSecretValue("password")}`).toString(
|
||||
"base64",
|
||||
);
|
||||
return { Authorization: `Basic ${credentials}` };
|
||||
}
|
||||
|
||||
@@ -31,11 +31,13 @@ export class GotifyIntegration extends Integration implements INotificationsInte
|
||||
if (!result.success) throw new Error(`Failed to parse Gotify response: ${result.error.message}`);
|
||||
const parsed = result.data;
|
||||
|
||||
return parsed.messages.map((message): Notification => ({
|
||||
id: String(message.id),
|
||||
time: new Date(message.date),
|
||||
title: message.title,
|
||||
body: message.message,
|
||||
}));
|
||||
return parsed.messages.map(
|
||||
(message): Notification => ({
|
||||
id: String(message.id),
|
||||
time: new Date(message.date),
|
||||
title: message.title,
|
||||
body: message.message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { VpnSummary } from "./vpn-summary-types";
|
||||
|
||||
export interface VpnSummaryIntegration {
|
||||
getSummaryAsync(): Promise<VpnSummary>;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export interface VpnProviderDetails {
|
||||
protocol: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
export interface VpnSummary {
|
||||
vpnStatus: string;
|
||||
dnsStatus: string;
|
||||
publicIp: string;
|
||||
country: string;
|
||||
city: string;
|
||||
vpnProvider: VpnProviderDetails;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export * from "./interfaces/network-controller-summary/network-controller-summar
|
||||
export * from "./interfaces/firewall-summary/firewall-summary-types";
|
||||
export * from "./interfaces/health-monitoring/health-monitoring-types";
|
||||
export * from "./interfaces/ups-summary/ups-summary-types";
|
||||
export * from "./interfaces/vpn-summary/vpn-summary-types";
|
||||
export * from "./interfaces/indexer-manager/indexer-manager-types";
|
||||
export * from "./interfaces/media-requests/media-request-types";
|
||||
export * from "./base/searchable-integration";
|
||||
@@ -22,4 +23,5 @@ export * from "./audiobookshelf/audiobookshelf-types";
|
||||
export * from "./navidrome/navidrome-types";
|
||||
export * from "./paperless-ngx/paperless-ngx-types";
|
||||
export * from "./umami/umami-types";
|
||||
export * from "./gluetun/gluetun-types";
|
||||
export * from "./beszel/beszel-types";
|
||||
Reference in new issue
Block a user