From cd1a82a640101cdca88d1e825edb11bd51f5ce66 Mon Sep 17 00:00:00 2001 From: Daniel Lopes Date: Thu, 11 Jun 2026 00:22:09 +0200 Subject: [PATCH] feat: add Technitium DNS integration, usable with DNS Hole widgets (#5832) Co-authored-by: Daniel Lopes --- .../src/docs/homarr-docs-sitemap.ts | 1 + packages/definitions/src/integration.ts | 7 + packages/integrations/src/base/creator.ts | 2 + packages/integrations/src/index.ts | 1 + .../technitium-integration-factory.ts | 11 + .../src/technitium/technitium-integration.ts | 333 +++++++ .../src/technitium/technitium-types.ts | 86 ++ .../integrations/test/technitium-unit.spec.ts | 852 ++++++++++++++++++ packages/integrations/test/technitium.spec.ts | 341 +++++++ 9 files changed, 1634 insertions(+) create mode 100644 packages/integrations/src/technitium/technitium-integration-factory.ts create mode 100644 packages/integrations/src/technitium/technitium-integration.ts create mode 100644 packages/integrations/src/technitium/technitium-types.ts create mode 100644 packages/integrations/test/technitium-unit.spec.ts create mode 100644 packages/integrations/test/technitium.spec.ts diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts index 348bbf78d..8a0e19f34 100644 --- a/packages/definitions/src/docs/homarr-docs-sitemap.ts +++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts @@ -198,6 +198,7 @@ export type HomarrDocumentationPath = | "/docs/integrations/sonarr" | "/docs/integrations/speedtest-tracker" | "/docs/integrations/tdarr" + | "/docs/integrations/technitium-dns" | "/docs/integrations/tracearr" | "/docs/integrations/transmission" | "/docs/integrations/truenas" diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index eed534ea0..5cd23182e 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -205,6 +205,13 @@ export const integrationDefs = { documentationUrl: createDocumentationLink("/docs/integrations/adguard-home"), defaultPort: 3000, }, + technitiumDns: { + name: "Technitium DNS", + secretKinds: [["apiKey"], ["username", "password"]], + iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/technitium.svg", + category: ["dnsHole"], + documentationUrl: createDocumentationLink("/docs/integrations/technitium-dns"), + }, homeAssistant: { name: "Home Assistant", secretKinds: [["apiKey"]], diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index 51eb7fa64..f2d9547fc 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -42,6 +42,7 @@ import { OverseerrIntegration } from "../overseerr/overseerr-integration"; import { PaperlessNgxIntegration } from "../paperless-ngx/paperless-ngx-integration"; import { PeaNutIntegration } from "../peanut/peanut-integration"; import { createPiHoleIntegrationAsync } from "../pi-hole/pi-hole-integration-factory"; +import { createTechnitiumDnsIntegrationAsync } from "../technitium/technitium-integration-factory"; import { PlexIntegration } from "../plex/plex-integration"; import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration"; import { ProxmoxIntegration } from "../proxmox/proxmox-integration"; @@ -83,6 +84,7 @@ export const integrationCreators = { anchor: AnchorIntegration, piHole: [createPiHoleIntegrationAsync], adGuardHome: AdGuardHomeIntegration, + technitiumDns: [createTechnitiumDnsIntegrationAsync], homeAssistant: HomeAssistantIntegration, jellyfin: JellyfinIntegration, plex: PlexIntegration, diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 2f1c7d2df..34c994ad3 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -1,5 +1,6 @@ // General integrations export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration"; +export { TechnitiumDnsIntegration } from "./technitium/technitium-integration"; export { AnchorIntegration } from "./anchor/anchor-integration"; export { Aria2Integration } from "./download-client/aria2/aria2-integration"; export { DelugeIntegration } from "./download-client/deluge/deluge-integration"; diff --git a/packages/integrations/src/technitium/technitium-integration-factory.ts b/packages/integrations/src/technitium/technitium-integration-factory.ts new file mode 100644 index 000000000..6cf9444a3 --- /dev/null +++ b/packages/integrations/src/technitium/technitium-integration-factory.ts @@ -0,0 +1,11 @@ +import type { IntegrationInput } from "../base/integration"; +import { TechnitiumDnsIntegration } from "./technitium-integration"; + +/** + * Create a Technitium DNS integration. + * + * Auth version detection (Bearer vs ?token=) is lazy: it happens on the first authenticated + * request and the result is cached in the session store, so no probe runs on every poll. + */ +export const createTechnitiumDnsIntegrationAsync = async (input: IntegrationInput) => + new TechnitiumDnsIntegration(input); diff --git a/packages/integrations/src/technitium/technitium-integration.ts b/packages/integrations/src/technitium/technitium-integration.ts new file mode 100644 index 000000000..9b805f38d --- /dev/null +++ b/packages/integrations/src/technitium/technitium-integration.ts @@ -0,0 +1,333 @@ +import type { Response as UndiciResponse } from "undici"; + +import { ResponseError } from "@homarr/common/server"; +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import { createLogger } from "@homarr/core/infrastructure/logs"; + +import type { IntegrationInput, IntegrationTestingInput } from "../base/integration"; +import { Integration } from "../base/integration"; +import type { SessionStore } from "../base/session-store"; +import { createSessionStore } from "../base/session-store"; +import { TestConnectionError } from "../base/test-connection/test-connection-error"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; +import type { DnsHoleSummaryIntegration } from "../interfaces/dns-hole-summary/dns-hole-summary-integration"; +import type { DnsHoleSummary } from "../interfaces/dns-hole-summary/dns-hole-summary-types"; +import { + apiPaths, + loginResponseSchema, + parseTechnitiumVersion, + readTechnitiumBodyAsync, + settingsGetResponseSchema, + statsGetResponseSchema, + TokenExpiredError, +} from "./technitium-types"; +import type { StoredSession, TechnitiumVersion } from "./technitium-types"; + +const logger = createLogger({ module: "technitiumDnsIntegration" }); + +export class TechnitiumDnsIntegration extends Integration implements DnsHoleSummaryIntegration { + // Mutable: updated when a login response or session cache reveals the server's auth version. + private version: TechnitiumVersion; + private readonly sessionStore: SessionStore; + + constructor(integration: IntegrationInput, version: TechnitiumVersion = "v15") { + super(integration); + this.version = version; + this.sessionStore = createSessionStore(integration); + } + + // || null rather than ?? null: treats an empty string as absent since "" is not a valid token. + private get apiKey(): string | null { + return super.hasSecretValue("apiKey") ? super.getSecretValue("apiKey") || null : null; + } + + public async getSummaryAsync(): Promise { + return this.withTokenRetryAsync(async () => { + const token = await this.acquireTokenAsync(); + // Sequential: stats is required; only attempt settings when stats succeeds. + // Parallel would race two TokenExpiredError throws — both are handled correctly by + // Promise.all internals, but sequential makes the required/optional split explicit. + const statsData = await this.fetchStatsAsync(token); + const settingsData = await this.fetchSettingsAsync(token); + + const { totalQueries, totalBlocked, blockedZones, blockListZones } = statsData.response.stats; + + let status: "enabled" | "disabled" | undefined; + if (settingsData) { + if (settingsData.response.enableBlocking) { + const till = settingsData.response.temporaryDisableBlockingTill; + status = till && new Date(till).getTime() > Date.now() ? "disabled" : "enabled"; + } else { + status = "disabled"; + } + } + + return { + status, + dnsQueriesToday: totalQueries, + adsBlockedToday: totalBlocked, + adsBlockedTodayPercentage: totalQueries > 0 ? (totalBlocked / totalQueries) * 100 : 0, + domainsBeingBlocked: blockedZones + blockListZones, + }; + }); + } + + public async enableAsync(): Promise { + return this.withTokenRetryAsync(async () => { + const token = await this.acquireTokenAsync(); + await this.expectOkAsync(await this.requestAsync(apiPaths.settingsSet, { enableBlocking: "true" }, token)); + }); + } + + public async disableAsync(duration = 0): Promise { + return this.withTokenRetryAsync(async () => { + const token = await this.acquireTokenAsync(); + + if (duration > 0) { + // Technitium timed-disable takes minutes; round up from seconds (1–59s becomes 1 min). + await this.expectOkAsync( + await this.requestAsync(apiPaths.temporaryDisable, { minutes: String(Math.ceil(duration / 60)) }, token), + ); + } else { + await this.expectOkAsync(await this.requestAsync(apiPaths.settingsSet, { enableBlocking: "false" }, token)); + } + }); + } + + protected async testingAsync({ fetchAsync }: IntegrationTestingInput): Promise { + const apiKey = this.apiKey; + let token: string | null; + + if (apiKey) { + token = apiKey; + // Ensure version is known; read from cache if warm, probe if cold. + // Without this, API key users on a legacy server would send Bearer (wrong auth) on first test. + const cached = await this.sessionStore.getAsync(); + this.version = cached?.version ?? (await this.detectVersionAsync(apiKey)); + } else { + const session = await this.resolveTokenAsync(fetchAsync).catch(() => null); + if (!session) return TestConnectionError.StatusResult({ status: 401, url: this.integration.url }); + this.version = session.version; + token = session.token; + } + if (!token) return TestConnectionError.StatusResult({ status: 401, url: this.integration.url }); + + const response = await this.requestAsync(apiPaths.stats, { type: "LastHour" }, token, fetchAsync); + const body = await readTechnitiumBodyAsync(response); + + if (body.status !== "ok") return TestConnectionError.StatusResult({ status: 401, url: this.integration.url }); + + // For credentials auth, log out server-side to free the session slot, then clear local cache. + // API keys are permanent and have no server-side session to release. + if (!apiKey) { + await this.requestAsync(apiPaths.logout, {}, token, fetchAsync).catch(() => { + logger.warn("Failed to logout session after connection test", { integrationId: this.integration.id }); + }); + } + + await this.sessionStore.clearAsync(); + return { success: true }; + } + + private async fetchStatsAsync(token: string | null) { + const response = await this.requestAsync(apiPaths.stats, { type: "LastHour" }, token); + const body = await this.readAuthenticatedBodyAsync(response); + + if (body.status !== "ok") throw new ResponseError({ status: 500, url: String(response.url) }); + + return statsGetResponseSchema.parseAsync(body.data); + } + + /** + * Returns null when blocking status is unavailable: + * - "error" status body → permission denied (restricted user) + * - non-JSON or schema mismatch → endpoint absent or unexpected shape on some versions + * Throws TokenExpiredError on "invalid-token" so withTokenRetryAsync can re-authenticate. + */ + private async fetchSettingsAsync(token: string | null) { + const response = await this.requestAsync(apiPaths.settingsGet, {}, token); + + let body: Awaited>; + try { + body = await readTechnitiumBodyAsync(response); + } catch { + logger.warn("Could not fetch blocking status, continuing without it", { + integrationId: this.integration.id, + error: "Settings response could not be parsed", + }); + return null; + } + + if (body.status === "invalid-token") throw new TokenExpiredError(this.publicIntegration); + + if (body.status === "error") { + logger.warn("Could not fetch blocking status, continuing without it", { + integrationId: this.integration.id, + error: String(body.data["errorMessage"] ?? "Settings request was denied"), + }); + return null; + } + + try { + return await settingsGetResponseSchema.parseAsync(body.data); + } catch { + logger.warn("Could not fetch blocking status, continuing without it", { + integrationId: this.integration.id, + error: "Settings response did not match expected schema", + }); + return null; + } + } + + private async expectOkAsync(response: UndiciResponse): Promise { + const body = await this.readAuthenticatedBodyAsync(response); + if (body.status !== "ok") throw new ResponseError({ status: 500, url: String(response.url) }); + } + + /** + * Run an action with a single transparent retry on token expiry. + * On TokenExpiredError: clears the session store, re-logins, and re-runs the action once. + * For API key auth the error is re-thrown immediately (API keys don't expire via session). + */ + private async withTokenRetryAsync(action: () => Promise): Promise { + return action().catch(async (err: unknown) => { + if (!(err instanceof TokenExpiredError) || this.apiKey) throw err; + + logger.debug("Token expired, refreshing and retrying", { integrationId: this.integration.id }); + await this.sessionStore.clearAsync(); + const session = await this.resolveTokenAsync(); + this.version = session.version; + await this.sessionStore.setAsync(session); + return action(); + }); + } + + /** + * Resolve a token from the API key or the session store, logging in if the store is cold. + * + * For API key users: version is probed once via Bearer auth on the stats endpoint and cached. + * For credentials users: version is parsed from the login response's info.version field. + */ + private async acquireTokenAsync(): Promise { + const apiKey = this.apiKey; + + if (apiKey) { + const cached = await this.sessionStore.getAsync(); + if (cached) { + this.version = cached.version; + } else { + // First call with an API key: probe once to detect the auth version, then cache it. + this.version = await this.detectVersionAsync(apiKey); + await this.sessionStore.setAsync({ token: "", version: this.version }); + } + return apiKey; + } + + const stored = await this.sessionStore.getAsync(); + if (stored) { + this.version = stored.version; + return stored.token; + } + + const session = await this.resolveTokenAsync(); + this.version = session.version; + await this.sessionStore.setAsync(session); + return session.token; + } + + /** + * Detect the auth version for API key users by probing the stats endpoint with Bearer auth. + * Not used for credentials users — they get the version from info.version in the login response. + * + * All tested versions (v11–v15) have /api/dashboard/stats/get, but only v15 accepts + * the Authorization: Bearer header — older versions return status "error" for it. + * Bearer returns "ok" or "invalid-token" → v15 (Bearer auth supported) + * Bearer returns "error" → legacy (?token= auth required) + * + * Falls back to "v15" on network errors; subsequent requests will also fail, surfacing the issue. + */ + private async detectVersionAsync(apiKey: string): Promise { + try { + const response = await fetchWithTrustedCertificatesAsync(this.url(apiPaths.stats, { type: "LastHour" }), { + headers: { Authorization: `Bearer ${apiKey}` }, + }); + const { status } = await readTechnitiumBodyAsync(response); + return status !== "error" ? "v15" : "legacy"; + } catch { + return "v15"; + } + } + + /** + * Log in with credentials, detecting the auth version from the server's own version string. + * Uses /api/user/login (available on all tested versions, v11–v15). + * Falls back to /api/login on HTTP 404 for very old servers. + */ + private async resolveTokenAsync( + fetchFn: typeof fetchWithTrustedCertificatesAsync = fetchWithTrustedCertificatesAsync, + ): Promise { + const creds = { + user: super.getSecretValue("username"), + pass: super.getSecretValue("password"), + // includeInfo causes the server to return its version string — the only reliable + // way to distinguish v15 (Bearer auth) from legacy (?token= auth). + includeInfo: "true", + }; + + const parseLogin = async (response: UndiciResponse, url: URL): Promise => { + const parsed = loginResponseSchema.safeParse(await response.json()); + if (!parsed.success) { + logger.warn("Login failed", { integrationId: this.integration.id }); + throw new ResponseError({ status: 401, url: String(url) }); + } + const version = parseTechnitiumVersion(parsed.data.info?.version); + logger.info(`Login successful (${version}, server ${parsed.data.info?.version ?? "unknown"})`, { + integrationId: this.integration.id, + }); + return { token: parsed.data.token, version }; + }; + + const loginUrl = this.url(apiPaths.login, creds); + const loginResponse = await fetchFn(loginUrl); + if (loginResponse.status !== 404) return parseLogin(loginResponse, loginUrl); + + // /api/user/login returned 404 — try the very old /api/login path + logger.debug("Primary login path not found, trying legacy /api/login", { integrationId: this.integration.id }); + const fallbackUrl = this.url("/api/login", creds); + return parseLogin(await fetchFn(fallbackUrl), fallbackUrl); + } + + /** + * Build and execute a GET request, attaching the token as a Bearer header (v15) + * or as a ?token= query parameter (legacy). + */ + private async requestAsync( + path: `/${string}`, + params: Record, + token: string | null, + fetchFn: typeof fetchWithTrustedCertificatesAsync = fetchWithTrustedCertificatesAsync, + ): Promise { + const url = this.url(path, params); + const headers: Record = {}; + + if (token) { + if (this.version === "v15") { + headers["Authorization"] = `Bearer ${token}`; + } else { + // Legacy auth: token is a query parameter. The URL is not logged directly by us; + // any error-path URL that reaches logs is covered by the logging middleware's sanitization. + url.searchParams.set("token", token); + } + } + + return fetchFn(url, { headers }); + } + + // Reads body and throws TokenExpiredError on "invalid-token". + // Use in all authenticated request paths so token expiry propagates to withTokenRetryAsync. + private async readAuthenticatedBodyAsync(response: UndiciResponse) { + const body = await readTechnitiumBodyAsync(response); + if (body.status === "invalid-token") throw new TokenExpiredError(this.publicIntegration); + return body; + } +} diff --git a/packages/integrations/src/technitium/technitium-types.ts b/packages/integrations/src/technitium/technitium-types.ts new file mode 100644 index 000000000..79fad70dc --- /dev/null +++ b/packages/integrations/src/technitium/technitium-types.ts @@ -0,0 +1,86 @@ +import { z } from "zod/v4"; + +import type { IntegrationErrorData } from "../base/errors/integration-error"; +import { IntegrationError } from "../base/errors/integration-error"; + +// All tested versions (v11–v15) share the same API paths. +// The version only controls the auth mechanism: +// v15 → Authorization: Bearer (introduced in v15) +// legacy → ?token= query parameter (all versions before v15) +export type TechnitiumVersion = "v15" | "legacy"; + +export const apiPaths = { + login: "/api/user/login", + logout: "/api/user/logout", + stats: "/api/dashboard/stats/get", + settingsGet: "/api/settings/get", + settingsSet: "/api/settings/set", + temporaryDisable: "/api/settings/temporaryDisableBlocking", +} as const satisfies Record; + +// Technitium always returns HTTP 200. Auth/permission state is signalled in the body: +// status: "ok" → success +// status: "invalid-token" → session expired or invalid API key +// status: "error" → permission denied or other server-side error +export type TechnitiumStatus = "ok" | "invalid-token" | "error"; + +// Internal signal thrown when Technitium reports the token as expired. +// Extends IntegrationError so the @HandleIntegrationErrors decorator re-throws it as-is +// instead of wrapping it, allowing withTokenRetryAsync to catch it by type. +export class TokenExpiredError extends IntegrationError { + constructor(integration: IntegrationErrorData) { + super(integration, "Token expired", {}); + this.name = TokenExpiredError.name; + } +} + +// Cached in Redis: the token (empty for API key users) and the detected auth version. +export type StoredSession = { token: string; version: TechnitiumVersion }; + +// ─── pure utilities ────────────────────────────────────────────────────────── + +/** Parses the server version string from a login response into a TechnitiumVersion. */ +export function parseTechnitiumVersion(infoVersion: string | undefined): TechnitiumVersion { + return parseInt(infoVersion?.split(".")[0] ?? "0") >= 15 ? "v15" : "legacy"; +} + +/** + * Parses the status and data from a Technitium HTTP response. + * Technitium always returns HTTP 200 — auth/error state is in the body's `status` field. + */ +export async function readTechnitiumBodyAsync(response: { + json(): Promise; +}): Promise<{ status: TechnitiumStatus; data: Record }> { + const data = (await response.json()) as Record; + const status = (data["status"] as TechnitiumStatus | undefined) ?? "error"; + return { status, data }; +} + +// ─── Zod schemas ───────────────────────────────────────────────────────────── + +export const loginResponseSchema = z.object({ + status: z.literal("ok"), + token: z.string(), + // info.version is returned when includeInfo=true is passed; used to determine the API path set. + info: z.object({ version: z.string() }).optional(), +}); + +export const statsGetResponseSchema = z.object({ + status: z.literal("ok"), + response: z.object({ + stats: z.object({ + totalQueries: z.number(), + totalBlocked: z.number(), + blockedZones: z.number(), + blockListZones: z.number(), + }), + }), +}); + +export const settingsGetResponseSchema = z.object({ + status: z.literal("ok"), + response: z.object({ + enableBlocking: z.boolean(), + temporaryDisableBlockingTill: z.string().nullable().optional(), + }), +}); diff --git a/packages/integrations/test/technitium-unit.spec.ts b/packages/integrations/test/technitium-unit.spec.ts new file mode 100644 index 000000000..e9e4ef975 --- /dev/null +++ b/packages/integrations/test/technitium-unit.spec.ts @@ -0,0 +1,852 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { createDb } from "@homarr/db/test"; +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import type { IntegrationInput } from "../src/base/integration"; +import { IntegrationError } from "../src/base/errors/integration-error"; +import type { SessionStore } from "../src/base/session-store"; +import { TechnitiumDnsIntegration } from "../src/technitium/technitium-integration"; +import { apiPaths } from "../src/technitium/technitium-types"; +import type { TechnitiumVersion } from "../src/technitium/technitium-types"; + +// ─── module mocks ──────────────────────────────────────────────────────────── + +vi.mock("@homarr/db", async (importActual) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual = await importActual(); + return { ...actual, db: createDb() }; +}); + +vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual = await importActual(); + return { + ...actual, + getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]), + }; +}); + +vi.mock("@homarr/core/infrastructure/http", () => ({ + fetchWithTrustedCertificatesAsync: vi.fn(), + createCertificateAgentAsync: vi.fn().mockResolvedValue({ dispatch: vi.fn() }), + createAxiosCertificateInstanceAsync: vi.fn().mockResolvedValue({}), +})); + +type StoredSession = { token: string; version: TechnitiumVersion }; + +// Stateful session store shared across tests — reset in beforeEach +let sessionData: StoredSession | null = null; +let sessionClearCount = 0; + +vi.mock("../src/base/session-store", () => ({ + createSessionStore: () => + ({ + async getAsync() { + return sessionData; + }, + async setAsync(value: StoredSession) { + sessionData = value; + }, + async clearAsync() { + sessionData = null; + sessionClearCount++; + }, + }) satisfies SessionStore, +})); + +// ─── helpers ───────────────────────────────────────────────────────────────── + +const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync); + +const BASE_URL = "http://technitium.local:5380"; +const STORED_TOKEN = "stored-session-token"; +const FRESH_TOKEN = "fresh-session-token"; +const API_KEY = "my-api-key"; + +// Cast lives here so call sites don't need `as never`. +function makeResponse(body: unknown) { + return { + ok: true, + status: 200, + url: BASE_URL, + json: () => Promise.resolve(body), + } as never; +} + +function makeInput( + auth: { kind: "apiKey"; value: string } | { kind: "credentials"; username?: string; password?: string }, +): IntegrationInput { + return { + id: "test-technitium", + name: "Technitium DNS", + url: BASE_URL, + externalUrl: null, + decryptedSecrets: + auth.kind === "apiKey" + ? [{ kind: "apiKey", value: auth.value }] + : [ + { kind: "username", value: auth.username ?? "admin" }, + { kind: "password", value: auth.password ?? "admin" }, + ], + }; +} + +const okLoginBody = { status: "ok", token: FRESH_TOKEN, info: { version: "15.2.0" } }; +const okLoginBodyLegacy = { status: "ok", token: FRESH_TOKEN, info: { version: "14.3.0" } }; + +const okStatsBody = { + status: "ok", + response: { + stats: { + totalQueries: 1000, + totalBlocked: 200, + blockedZones: 5, + blockListZones: 300000, + }, + }, +}; + +const okSettingsEnabledBody = { + status: "ok", + response: { enableBlocking: true }, +}; + +// Use resetAllMocks so the mockResolvedValueOnce queue is cleared between tests. +// Pre-populate version cache so API key tests don't trigger the one-time detectVersionAsync probe. +beforeEach(() => { + vi.resetAllMocks(); + sessionData = { token: "", version: "v15" }; + sessionClearCount = 0; +}); + +// ─── getSummaryAsync ───────────────────────────────────────────────────────── + +// Happy-path helper: queues the default stats+settings mocks and runs getSummaryAsync. +function getSummaryWithDefaults() { + mockFetch.mockResolvedValueOnce(makeResponse(okStatsBody)).mockResolvedValueOnce(makeResponse(okSettingsEnabledBody)); + return new TechnitiumDnsIntegration(makeInput({ kind: "apiKey", value: API_KEY }), "v15").getSummaryAsync(); +} + +describe("getSummaryAsync", () => { + describe("data mapping", () => { + test("maps totalBlocked → adsBlockedToday", async () => { + const result = await getSummaryWithDefaults(); + expect(result.adsBlockedToday).toBe(200); + }); + + test("maps totalQueries → dnsQueriesToday", async () => { + const result = await getSummaryWithDefaults(); + expect(result.dnsQueriesToday).toBe(1000); + }); + + test("domainsBeingBlocked = blockedZones + blockListZones", async () => { + const result = await getSummaryWithDefaults(); + expect(result.domainsBeingBlocked).toBe(5 + 300000); + }); + + test("adsBlockedTodayPercentage = totalBlocked / totalQueries * 100", async () => { + const result = await getSummaryWithDefaults(); + expect(result.adsBlockedTodayPercentage).toBeCloseTo(20); // 200/1000*100 + }); + + test("adsBlockedTodayPercentage is 0 when totalQueries is 0 (division-by-zero guard)", async () => { + const zeroStats = { + status: "ok", + response: { stats: { totalQueries: 0, totalBlocked: 0, blockedZones: 0, blockListZones: 0 } }, + }; + mockFetch + .mockResolvedValueOnce(makeResponse(zeroStats)) + .mockResolvedValueOnce(makeResponse(okSettingsEnabledBody)); + + const result = await new TechnitiumDnsIntegration( + makeInput({ kind: "apiKey", value: API_KEY }), + "v15", + ).getSummaryAsync(); + + expect(result.adsBlockedTodayPercentage).toBe(0); + }); + }); + + describe("blocking status", () => { + test("status is 'enabled' when enableBlocking=true and no timer", async () => { + mockFetch + .mockResolvedValueOnce(makeResponse(okStatsBody)) + .mockResolvedValueOnce(makeResponse(okSettingsEnabledBody)); + + const result = await new TechnitiumDnsIntegration( + makeInput({ kind: "apiKey", value: API_KEY }), + "v15", + ).getSummaryAsync(); + + expect(result.status).toBe("enabled"); + }); + + test("status is 'disabled' when enableBlocking=false (permanent disable)", async () => { + const settings = { status: "ok", response: { enableBlocking: false } }; + mockFetch.mockResolvedValueOnce(makeResponse(okStatsBody)).mockResolvedValueOnce(makeResponse(settings)); + + const result = await new TechnitiumDnsIntegration( + makeInput({ kind: "apiKey", value: API_KEY }), + "v15", + ).getSummaryAsync(); + + expect(result.status).toBe("disabled"); + }); + + test("status is 'disabled' when enableBlocking=false with a future temporaryDisableBlockingTill (timed disable)", async () => { + const futureTill = new Date(Date.now() + 5 * 60 * 1000).toISOString(); + const settings = { status: "ok", response: { enableBlocking: false, temporaryDisableBlockingTill: futureTill } }; + mockFetch.mockResolvedValueOnce(makeResponse(okStatsBody)).mockResolvedValueOnce(makeResponse(settings)); + + const result = await new TechnitiumDnsIntegration( + makeInput({ kind: "apiKey", value: API_KEY }), + "v15", + ).getSummaryAsync(); + + expect(result.status).toBe("disabled"); + }); + + test("status is 'enabled' when enableBlocking=true with a past temporaryDisableBlockingTill", async () => { + const pastTill = new Date(Date.now() - 60 * 1000).toISOString(); + const settings = { status: "ok", response: { enableBlocking: true, temporaryDisableBlockingTill: pastTill } }; + mockFetch.mockResolvedValueOnce(makeResponse(okStatsBody)).mockResolvedValueOnce(makeResponse(settings)); + + const result = await new TechnitiumDnsIntegration( + makeInput({ kind: "apiKey", value: API_KEY }), + "v15", + ).getSummaryAsync(); + + expect(result.status).toBe("enabled"); + }); + + test("status is undefined when settings are unavailable (permission denied)", async () => { + const denied = { status: "error", errorMessage: "Access was denied." }; + mockFetch.mockResolvedValueOnce(makeResponse(okStatsBody)).mockResolvedValueOnce(makeResponse(denied)); + + const result = await new TechnitiumDnsIntegration( + makeInput({ kind: "apiKey", value: API_KEY }), + "v15", + ).getSummaryAsync(); + + expect(result.status).toBeUndefined(); + }); + }); + + describe("error propagation", () => { + test("throws when stats returns invalid-token", async () => { + mockFetch + .mockResolvedValueOnce(makeResponse({ status: "invalid-token" })) + .mockResolvedValueOnce(makeResponse(okSettingsEnabledBody)); + + await expect( + new TechnitiumDnsIntegration(makeInput({ kind: "apiKey", value: API_KEY }), "v15").getSummaryAsync(), + ).rejects.toBeInstanceOf(IntegrationError); + }); + + test("throws when stats returns error status", async () => { + mockFetch + .mockResolvedValueOnce(makeResponse({ status: "error", errorMessage: "Something went wrong" })) + .mockResolvedValueOnce(makeResponse(okSettingsEnabledBody)); + + await expect( + new TechnitiumDnsIntegration(makeInput({ kind: "apiKey", value: API_KEY }), "v15").getSummaryAsync(), + ).rejects.toBeInstanceOf(IntegrationError); + }); + + test("throws when settings return invalid-token (not silently degraded)", async () => { + mockFetch + .mockResolvedValueOnce(makeResponse(okStatsBody)) + .mockResolvedValueOnce(makeResponse({ status: "invalid-token" })); + + await expect( + new TechnitiumDnsIntegration(makeInput({ kind: "apiKey", value: API_KEY }), "v15").getSummaryAsync(), + ).rejects.toBeInstanceOf(IntegrationError); + }); + }); + + describe("version routing", () => { + test("all versions hit /api/dashboard/stats/get", async () => { + for (const version of ["v15", "legacy"] as const) { + vi.resetAllMocks(); + sessionData = { token: "", version }; + mockFetch + .mockResolvedValueOnce(makeResponse(okStatsBody)) + .mockResolvedValueOnce(makeResponse(okSettingsEnabledBody)); + + await new TechnitiumDnsIntegration(makeInput({ kind: "apiKey", value: API_KEY }), version).getSummaryAsync(); + + expect(mockFetch.mock.calls.some((c) => String(c[0]).includes(apiPaths.stats))).toBe(true); + } + }); + }); +}); + +// ─── enableAsync ───────────────────────────────────────────────────────────── + +describe("enableAsync", () => { + test("sends enableBlocking=true to settingsSet endpoint", async () => { + mockFetch.mockResolvedValueOnce(makeResponse({ status: "ok" })); + + await new TechnitiumDnsIntegration(makeInput({ kind: "apiKey", value: API_KEY }), "v15").enableAsync(); + + const url = String(mockFetch.mock.calls[0]?.[0]); + expect(url).toContain(apiPaths.settingsSet); + expect(url).toContain("enableBlocking=true"); + }); + + test("throws on error status", async () => { + mockFetch.mockResolvedValueOnce(makeResponse({ status: "error", errorMessage: "Denied" })); + + await expect( + new TechnitiumDnsIntegration(makeInput({ kind: "apiKey", value: API_KEY }), "v15").enableAsync(), + ).rejects.toBeInstanceOf(IntegrationError); + }); + + test("throws and clears store on invalid-token (credentials auth)", async () => { + sessionData = { token: STORED_TOKEN, version: "v15" }; + mockFetch.mockResolvedValueOnce(makeResponse({ status: "invalid-token" })); + + await expect( + new TechnitiumDnsIntegration(makeInput({ kind: "credentials" }), "v15").enableAsync(), + ).rejects.toBeInstanceOf(IntegrationError); + + expect(sessionClearCount).toBeGreaterThan(0); + expect(sessionData).toBeNull(); + }); + + test("throws but does NOT clear store on invalid-token (apiKey auth)", async () => { + mockFetch.mockResolvedValueOnce(makeResponse({ status: "invalid-token" })); + + await expect( + new TechnitiumDnsIntegration(makeInput({ kind: "apiKey", value: API_KEY }), "v15").enableAsync(), + ).rejects.toBeInstanceOf(IntegrationError); + + expect(sessionClearCount).toBe(0); + }); +}); + +// ─── disableAsync ───────────────────────────────────────────────────────────── + +describe("disableAsync", () => { + test("duration=0 sends enableBlocking=false to settingsSet (permanent disable)", async () => { + mockFetch.mockResolvedValueOnce(makeResponse({ status: "ok" })); + + await new TechnitiumDnsIntegration(makeInput({ kind: "apiKey", value: API_KEY }), "v15").disableAsync(0); + + const url = String(mockFetch.mock.calls[0]?.[0]); + expect(url).toContain(apiPaths.settingsSet); + expect(url).toContain("enableBlocking=false"); + expect(url).not.toContain("temporaryDisable"); + }); + + test("duration>0 hits temporaryDisableBlocking, NOT settingsSet", async () => { + mockFetch.mockResolvedValueOnce(makeResponse({ status: "ok" })); + + await new TechnitiumDnsIntegration(makeInput({ kind: "apiKey", value: API_KEY }), "v15").disableAsync(120); + + const url = String(mockFetch.mock.calls[0]?.[0]); + expect(url).toContain(apiPaths.temporaryDisable); + expect(url).not.toContain("enableBlocking"); + }); + + test.each([ + [60, 1], + [120, 2], + [1, 1], // 1s rounds up to 1 min + [59, 1], // 59s rounds up to 1 min + [61, 2], // 61s rounds up to 2 min + [3600, 60], // 1 hour = 60 min + ])("duration=%is → minutes=%i sent to temporaryDisableBlocking", async (durationSec, expectedMin) => { + mockFetch.mockResolvedValueOnce(makeResponse({ status: "ok" })); + + await new TechnitiumDnsIntegration(makeInput({ kind: "apiKey", value: API_KEY }), "v15").disableAsync(durationSec); + + expect(String(mockFetch.mock.calls[0]?.[0])).toContain(`minutes=${expectedMin}`); + }); +}); + +// ─── token acquisition ──────────────────────────────────────────────────────── + +describe("token acquisition", () => { + test("apiKey is sent as Bearer header — session store is never touched", async () => { + sessionData = { token: "should-not-be-used", version: "v15" }; + mockFetch + .mockResolvedValueOnce(makeResponse(okStatsBody)) + .mockResolvedValueOnce(makeResponse(okSettingsEnabledBody)); + + await new TechnitiumDnsIntegration(makeInput({ kind: "apiKey", value: API_KEY }), "v15").getSummaryAsync(); + + for (const call of mockFetch.mock.calls) { + const headers = call[1]?.headers as Record | undefined; + expect(headers?.["Authorization"]).toBe(`Bearer ${API_KEY}`); + } + }); + + test("empty apiKey falls back to credentials — login is called", async () => { + sessionData = null; // cold store — credentials need to login + // Realistic scenario: apiKey secret exists but is empty, credentials are also configured + const input: IntegrationInput = { + id: "test-technitium", + name: "Technitium DNS", + url: BASE_URL, + externalUrl: null, + decryptedSecrets: [ + { kind: "apiKey", value: "" }, + { kind: "username", value: "admin" }, + { kind: "password", value: "admin" }, + ], + }; + mockFetch + .mockResolvedValueOnce(makeResponse(okLoginBody)) + .mockResolvedValueOnce(makeResponse(okStatsBody)) + .mockResolvedValueOnce(makeResponse(okSettingsEnabledBody)); + + await new TechnitiumDnsIntegration(input, "v15").getSummaryAsync(); + + expect(mockFetch.mock.calls.some((c) => String(c[0]).includes(apiPaths.login))).toBe(true); + }); + + test("warm session store is used — no login call", async () => { + sessionData = { token: STORED_TOKEN, version: "v15" }; + mockFetch + .mockResolvedValueOnce(makeResponse(okStatsBody)) + .mockResolvedValueOnce(makeResponse(okSettingsEnabledBody)); + + await new TechnitiumDnsIntegration(makeInput({ kind: "credentials" }), "v15").getSummaryAsync(); + + expect(mockFetch.mock.calls.every((c) => !String(c[0]).includes("login"))).toBe(true); + }); + + test("cold session store triggers login and caches the token", async () => { + sessionData = null; // cold store — override beforeEach version cache + mockFetch + .mockResolvedValueOnce(makeResponse(okLoginBody)) + .mockResolvedValueOnce(makeResponse(okStatsBody)) + .mockResolvedValueOnce(makeResponse(okSettingsEnabledBody)); + + await new TechnitiumDnsIntegration(makeInput({ kind: "credentials" }), "v15").getSummaryAsync(); + + expect(mockFetch.mock.calls.some((c) => String(c[0]).includes(apiPaths.login))).toBe(true); + expect((sessionData as { token: string } | null)?.token).toBe(FRESH_TOKEN); + }); + + test("login failure throws", async () => { + sessionData = null; // cold store — override beforeEach version cache + mockFetch.mockResolvedValueOnce(makeResponse({ status: "error", errorMessage: "Invalid credentials" })); + + await expect( + new TechnitiumDnsIntegration(makeInput({ kind: "credentials" }), "v15").getSummaryAsync(), + ).rejects.toBeInstanceOf(IntegrationError); + }); + + test("stats invalid-token clears store (credentials auth)", async () => { + sessionData = { token: STORED_TOKEN, version: "v15" }; + mockFetch + .mockResolvedValueOnce(makeResponse({ status: "invalid-token" })) + .mockResolvedValueOnce(makeResponse(okSettingsEnabledBody)); + + await expect( + new TechnitiumDnsIntegration(makeInput({ kind: "credentials" }), "v15").getSummaryAsync(), + ).rejects.toBeInstanceOf(IntegrationError); + + expect(sessionData).toBeNull(); + }); + + test("stats invalid-token does NOT clear store (apiKey auth)", async () => { + mockFetch + .mockResolvedValueOnce(makeResponse({ status: "invalid-token" })) + .mockResolvedValueOnce(makeResponse(okSettingsEnabledBody)); + + await expect( + new TechnitiumDnsIntegration(makeInput({ kind: "apiKey", value: API_KEY }), "v15").getSummaryAsync(), + ).rejects.toBeInstanceOf(IntegrationError); + + expect(sessionClearCount).toBe(0); + }); +}); + +// ─── version-specific request format ───────────────────────────────────────── + +describe("request format per version", () => { + test("v15 sends token as Authorization: Bearer header", async () => { + sessionData = { token: STORED_TOKEN, version: "v15" }; + mockFetch + .mockResolvedValueOnce(makeResponse(okStatsBody)) + .mockResolvedValueOnce(makeResponse(okSettingsEnabledBody)); + + await new TechnitiumDnsIntegration(makeInput({ kind: "credentials" }), "v15").getSummaryAsync(); + + const statsCall = mockFetch.mock.calls.find((c) => String(c[0]).includes("stats")); + const headers = statsCall?.[1]?.headers as Record | undefined; + expect(headers?.["Authorization"]).toBe(`Bearer ${STORED_TOKEN}`); + expect(String(statsCall?.[0])).not.toContain("token="); + }); + + test("legacy sends token as ?token= query param, not in header", async () => { + sessionData = { token: STORED_TOKEN, version: "legacy" }; + mockFetch + .mockResolvedValueOnce(makeResponse(okStatsBody)) + .mockResolvedValueOnce(makeResponse(okSettingsEnabledBody)); + + await new TechnitiumDnsIntegration(makeInput({ kind: "credentials" }), "legacy").getSummaryAsync(); + + const statsCall = mockFetch.mock.calls.find((c) => String(c[0]).includes(apiPaths.stats)); + const headers = statsCall?.[1]?.headers as Record | undefined; + expect(headers?.["Authorization"]).toBeUndefined(); + expect(String(statsCall?.[0])).toContain(`token=${STORED_TOKEN}`); + }); + + test("login uses /api/user/login", async () => { + sessionData = null; // cold store — override beforeEach version cache + mockFetch + .mockResolvedValueOnce(makeResponse(okLoginBody)) + .mockResolvedValueOnce(makeResponse(okStatsBody)) + .mockResolvedValueOnce(makeResponse(okSettingsEnabledBody)); + + await new TechnitiumDnsIntegration(makeInput({ kind: "credentials" }), "v15").getSummaryAsync(); + + expect(mockFetch.mock.calls[0]?.[0].toString()).toContain(apiPaths.login); + }); + + test("fallback login uses /api/login when primary path returns 404", async () => { + sessionData = null; // cold store — override beforeEach version cache + mockFetch + .mockResolvedValueOnce({ status: 404, json: () => Promise.resolve({}) } as never) // primary login → 404 + .mockResolvedValueOnce(makeResponse(okLoginBodyLegacy)) // fallback login + .mockResolvedValueOnce(makeResponse(okStatsBody)) + .mockResolvedValueOnce(makeResponse(okSettingsEnabledBody)); + + await new TechnitiumDnsIntegration(makeInput({ kind: "credentials" }), "v15").getSummaryAsync(); + + // Call 0 is the primary probe (404), call 1 is the fallback /api/login + expect(mockFetch.mock.calls[1]?.[0].toString()).toContain("/api/login"); + }); + + test("all versions use /api/settings/get", async () => { + for (const version of ["v15", "legacy"] as const) { + vi.resetAllMocks(); + sessionData = { token: "", version }; + mockFetch + .mockResolvedValueOnce(makeResponse(okStatsBody)) + .mockResolvedValueOnce(makeResponse(okSettingsEnabledBody)); + + await new TechnitiumDnsIntegration(makeInput({ kind: "apiKey", value: API_KEY }), version).getSummaryAsync(); + + expect(mockFetch.mock.calls.some((c) => String(c[0]).includes(apiPaths.settingsGet))).toBe(true); + } + }); +}); + +// ─── withTokenRetryAsync ───────────────────────────────────────────────────── + +describe("withTokenRetryAsync (retry on token expiry)", () => { + test("retries getSummaryAsync once after invalid-token and succeeds", async () => { + sessionData = { token: STORED_TOKEN, version: "v15" }; + // Sequential: stats fails → settings never runs → re-login → stats ok → settings ok + mockFetch + .mockResolvedValueOnce(makeResponse({ status: "invalid-token" })) // stats attempt 1 + .mockResolvedValueOnce(makeResponse(okLoginBody)) // re-login + .mockResolvedValueOnce(makeResponse(okStatsBody)) // stats attempt 2 + .mockResolvedValueOnce(makeResponse(okSettingsEnabledBody)); // settings attempt 2 + + const result = await new TechnitiumDnsIntegration(makeInput({ kind: "credentials" }), "v15").getSummaryAsync(); + + expect(result.adsBlockedToday).toBe(200); + expect((sessionData as { token: string } | null)?.token).toBe(FRESH_TOKEN); + }); + + test("retry uses the fresh token, not the expired one", async () => { + sessionData = { token: STORED_TOKEN, version: "v15" }; + // Sequential: stats(1)→invalid-token, login, stats(2)→ok, settings(2)→ok + mockFetch + .mockResolvedValueOnce(makeResponse({ status: "invalid-token" })) // stats attempt 1 + .mockResolvedValueOnce(makeResponse(okLoginBody)) // re-login + .mockResolvedValueOnce(makeResponse(okStatsBody)) // stats attempt 2 + .mockResolvedValueOnce(makeResponse(okSettingsEnabledBody)); // settings attempt 2 + + await new TechnitiumDnsIntegration(makeInput({ kind: "credentials" }), "v15").getSummaryAsync(); + + // Retry calls are: stats attempt 2 (index 2) + settings attempt 2 (index 3) + const retryCalls = mockFetch.mock.calls.slice(2); // skip stats(1) and re-login + for (const call of retryCalls) { + const headers = call[1]?.headers as Record | undefined; + expect(headers?.["Authorization"]).toBe(`Bearer ${FRESH_TOKEN}`); + } + }); + + test("does not retry more than once — second invalid-token throws", async () => { + sessionData = { token: STORED_TOKEN, version: "v15" }; + // Sequential: stats(1)→invalid-token, login, stats(2)→invalid-token → throws + mockFetch + .mockResolvedValueOnce(makeResponse({ status: "invalid-token" })) // stats attempt 1 + .mockResolvedValueOnce(makeResponse(okLoginBody)) // re-login + .mockResolvedValueOnce(makeResponse({ status: "invalid-token" })); // stats attempt 2 still fails + + await expect( + new TechnitiumDnsIntegration(makeInput({ kind: "credentials" }), "v15").getSummaryAsync(), + ).rejects.toBeInstanceOf(IntegrationError); + }); + + test("settings invalid-token triggers full retry (both stats and settings re-run)", async () => { + sessionData = { token: STORED_TOKEN, version: "v15" }; + mockFetch + .mockResolvedValueOnce(makeResponse(okStatsBody)) // stats attempt 1 + .mockResolvedValueOnce(makeResponse({ status: "invalid-token" })) // settings attempt 1 + .mockResolvedValueOnce(makeResponse(okLoginBody)) // re-login + .mockResolvedValueOnce(makeResponse(okStatsBody)) // stats attempt 2 + .mockResolvedValueOnce(makeResponse(okSettingsEnabledBody)); // settings attempt 2 + + const result = await new TechnitiumDnsIntegration(makeInput({ kind: "credentials" }), "v15").getSummaryAsync(); + + expect(result.status).toBe("enabled"); + expect(mockFetch.mock.calls).toHaveLength(5); // verify 5 requests total (2+1+2) + }); + + test("settings permission denied ('error') does NOT trigger retry", async () => { + mockFetch + .mockResolvedValueOnce(makeResponse(okStatsBody)) + .mockResolvedValueOnce(makeResponse({ status: "error", errorMessage: "Access was denied." })); + + const result = await new TechnitiumDnsIntegration( + makeInput({ kind: "apiKey", value: API_KEY }), + "v15", + ).getSummaryAsync(); + + expect(result.status).toBeUndefined(); // graceful degradation + expect(mockFetch.mock.calls).toHaveLength(2); // exactly 2 calls, no retry + }); + + test("retries enableAsync once after invalid-token and succeeds", async () => { + sessionData = { token: STORED_TOKEN, version: "v15" }; + mockFetch + .mockResolvedValueOnce(makeResponse({ status: "invalid-token" })) // attempt 1 + .mockResolvedValueOnce(makeResponse(okLoginBody)) // re-login + .mockResolvedValueOnce(makeResponse({ status: "ok" })); // attempt 2 + + await new TechnitiumDnsIntegration(makeInput({ kind: "credentials" }), "v15").enableAsync(); + + expect(mockFetch.mock.calls).toHaveLength(3); + }); + + test("apiKey invalid-token is NOT retried — throws immediately", async () => { + mockFetch.mockResolvedValueOnce(makeResponse({ status: "invalid-token" })); + + await expect( + new TechnitiumDnsIntegration(makeInput({ kind: "apiKey", value: API_KEY }), "v15").enableAsync(), + ).rejects.toBeInstanceOf(IntegrationError); + + expect(mockFetch.mock.calls).toHaveLength(1); // only the one failed call, no re-login + }); +}); + +// ─── login URL format ──────────────────────────────────────────────────────── + +describe("login URL format", () => { + test("login URL includes user, pass and includeInfo=true parameters", async () => { + sessionData = null; // cold store — override beforeEach version cache + mockFetch + .mockResolvedValueOnce(makeResponse(okLoginBody)) + .mockResolvedValueOnce(makeResponse(okStatsBody)) + .mockResolvedValueOnce(makeResponse(okSettingsEnabledBody)); + + const input = makeInput({ kind: "credentials", username: "myuser", password: "mypass" }); + await new TechnitiumDnsIntegration(input, "v15").getSummaryAsync(); + + const loginUrl = String(mockFetch.mock.calls[0]?.[0]); + expect(loginUrl).toContain("user=myuser"); + expect(loginUrl).toContain("pass=mypass"); + expect(loginUrl).toContain("includeInfo=true"); + }); + + test("fallback /api/login carries same parameters as primary path", async () => { + sessionData = null; // cold store — override beforeEach version cache + mockFetch + .mockResolvedValueOnce({ status: 404, json: () => Promise.resolve({}) } as never) // primary → 404 + .mockResolvedValueOnce(makeResponse(okLoginBodyLegacy)) // fallback login + .mockResolvedValueOnce(makeResponse(okStatsBody)) + .mockResolvedValueOnce(makeResponse(okSettingsEnabledBody)); + + const input = makeInput({ kind: "credentials", username: "admin", password: "secret" }); + await new TechnitiumDnsIntegration(input, "v15").getSummaryAsync(); + + // Call 1 is the fallback login (call 0 was the primary that got 404) + const loginUrl = String(mockFetch.mock.calls[1]?.[0]); + expect(loginUrl).toContain("/api/login"); + expect(loginUrl).toContain("user=admin"); + expect(loginUrl).toContain("pass=secret"); + expect(loginUrl).toContain("includeInfo=true"); + }); +}); + +// ─── auth mechanism per version ────────────────────────────────────────────── +// All versions share the same API paths; only the auth mechanism differs. +describe("auth mechanism per version", () => { + test("all versions use /api/settings/set for enableAsync and permanent disableAsync", async () => { + for (const version of ["v15", "legacy"] as const) { + for (const call of [ + () => new TechnitiumDnsIntegration(makeInput({ kind: "apiKey", value: API_KEY }), version).enableAsync(), + () => new TechnitiumDnsIntegration(makeInput({ kind: "apiKey", value: API_KEY }), version).disableAsync(0), + ]) { + vi.resetAllMocks(); + sessionData = { token: "", version }; + mockFetch.mockResolvedValueOnce(makeResponse({ status: "ok" })); + await call(); + expect(String(mockFetch.mock.calls[0]?.[0])).toContain(apiPaths.settingsSet); + } + } + }); + + test("disableAsync() with no argument defaults to permanent disable", async () => { + mockFetch.mockResolvedValueOnce(makeResponse({ status: "ok" })); + + await new TechnitiumDnsIntegration(makeInput({ kind: "apiKey", value: API_KEY }), "v15").disableAsync(); + + const url = String(mockFetch.mock.calls[0]?.[0]); + expect(url).toContain("enableBlocking=false"); + expect(url).not.toContain("minutes="); + }); + + test("all versions use /api/settings/temporaryDisableBlocking for timed disableAsync", async () => { + for (const version of ["v15", "legacy"] as const) { + vi.resetAllMocks(); + sessionData = { token: "", version }; + mockFetch.mockResolvedValueOnce(makeResponse({ status: "ok" })); + + await new TechnitiumDnsIntegration(makeInput({ kind: "apiKey", value: API_KEY }), version).disableAsync(60); + + expect(String(mockFetch.mock.calls[0]?.[0])).toContain(apiPaths.temporaryDisable); + } + }); + + test("v15 auth uses Bearer header, legacy auth uses ?token= param", async () => { + for (const [version, expectBearer] of [ + ["v15", true], + ["legacy", false], + ] as const) { + vi.resetAllMocks(); + sessionData = { token: "", version }; + mockFetch.mockResolvedValueOnce(makeResponse({ status: "ok" })); + + await new TechnitiumDnsIntegration(makeInput({ kind: "apiKey", value: API_KEY }), version).enableAsync(); + + const url = String(mockFetch.mock.calls[0]?.[0]); + const headers = mockFetch.mock.calls[0]?.[1]?.headers as Record | undefined; + if (expectBearer) { + expect(headers?.["Authorization"]).toContain("Bearer"); + expect(url).not.toContain("token="); + } else { + expect(headers?.["Authorization"]).toBeUndefined(); + expect(url).toContain(`token=${API_KEY}`); + } + } + }); +}); + +// ─── testingAsync (session cleanup) ────────────────────────────────────────── + +// Calls the protected testingAsync directly with a custom fetchAsync. +function callTestingAsync( + integration: TechnitiumDnsIntegration, + fetchAsync: typeof fetchWithTrustedCertificatesAsync, +) { + return ( + integration as unknown as { + testingAsync: (input: { fetchAsync: typeof fetchWithTrustedCertificatesAsync }) => Promise; + } + ).testingAsync({ fetchAsync }); +} + +describe("testingAsync (session cleanup)", () => { + + test("credentials auth calls logout endpoint after successful test", async () => { + sessionData = null; + const fetchMock = vi.fn(); + fetchMock + .mockResolvedValueOnce(makeResponse(okLoginBody)) // resolveTokenAsync login + .mockResolvedValueOnce(makeResponse({ status: "ok" })) // stats check + .mockResolvedValueOnce(makeResponse({ status: "ok" })); // logout + + const integration = new TechnitiumDnsIntegration(makeInput({ kind: "credentials" }), "v15"); + const result = await callTestingAsync(integration, fetchMock); + + const urls = fetchMock.mock.calls.map((c) => String(c[0])); + expect(urls.some((u) => u.includes(apiPaths.logout))).toBe(true); + expect(sessionData).toBeNull(); + expect(result).toMatchObject({ success: true }); + }); + + test("API key auth does NOT call logout endpoint (warm session cache)", async () => { + // session already has version cached — no probe needed + const fetchMock = vi.fn(); + fetchMock.mockResolvedValueOnce(makeResponse({ status: "ok" })); // stats check only + + const integration = new TechnitiumDnsIntegration(makeInput({ kind: "apiKey", value: API_KEY }), "v15"); + const result = await callTestingAsync(integration, fetchMock); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const url = String(fetchMock.mock.calls[0]?.[0]); + expect(url).not.toContain("/logout"); + expect(sessionData).toBeNull(); + expect(result).toMatchObject({ success: true }); + }); + + test("API key auth with cold session: detectVersionAsync probe runs via module-level fetch, not fetchAsync", async () => { + // Cold store — detectVersionAsync should fire via fetchWithTrustedCertificatesAsync (not fetchMock) + sessionData = null; + const fetchMock = vi.fn(); + fetchMock.mockResolvedValueOnce(makeResponse({ status: "ok" })); // stats check + + // detectVersionAsync uses the global mockFetch (fetchWithTrustedCertificatesAsync) + mockFetch.mockResolvedValueOnce(makeResponse({ status: "ok" })); // version probe → v15 + + const integration = new TechnitiumDnsIntegration(makeInput({ kind: "apiKey", value: API_KEY }), "v15"); + const result = await callTestingAsync(integration, fetchMock); + + expect(result).toMatchObject({ success: true }); + // fetchMock (the injected fetchAsync) was only called for the stats check, not the probe + expect(fetchMock).toHaveBeenCalledTimes(1); + // The global mockFetch handled the version probe + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + test("logout failure does not fail the connection test", async () => { + sessionData = null; + const fetchMock = vi.fn(); + fetchMock + .mockResolvedValueOnce(makeResponse(okLoginBody)) // resolveTokenAsync login + .mockResolvedValueOnce(makeResponse({ status: "ok" })) // stats check + .mockRejectedValueOnce(new Error("network error")); // logout fails + + const integration = new TechnitiumDnsIntegration(makeInput({ kind: "credentials" }), "v15"); + const result = await callTestingAsync(integration, fetchMock); + + expect(fetchMock).toHaveBeenCalledTimes(3); // login + stats + logout (attempted) + expect(result).toMatchObject({ success: true }); + expect(sessionData).toBeNull(); // store still cleared even when logout fails + }); + + test("legacy server: auth version updated to legacy so ?token= is used instead of Bearer", async () => { + sessionData = null; + const fetchMock = vi.fn(); + fetchMock + .mockResolvedValueOnce({ status: 404, json: () => Promise.resolve({}) } as never) // primary login → 404 + .mockResolvedValueOnce(makeResponse(okLoginBodyLegacy)) // fallback login → legacy + .mockResolvedValueOnce(makeResponse({ status: "ok" })) // stats check + .mockResolvedValueOnce(makeResponse({ status: "ok" })); // logout + + const integration = new TechnitiumDnsIntegration(makeInput({ kind: "credentials" }), "v15"); + const result = await callTestingAsync(integration, fetchMock); + + expect(result).toMatchObject({ success: true }); + // All versions use the same paths; the distinction is auth method + expect(fetchMock.mock.calls.some((c) => String(c[0]).includes(apiPaths.stats))).toBe(true); + expect(fetchMock.mock.calls.some((c) => String(c[0]).includes(apiPaths.logout))).toBe(true); + // Stats and logout must use ?token= param, not Bearer (legacy auth) + const statsCall = fetchMock.mock.calls.find((c) => String(c[0]).includes(apiPaths.stats)); + expect(String(statsCall?.[0])).toContain("token="); + expect((statsCall?.[1]?.headers as Record | undefined)?.["Authorization"]).toBeUndefined(); + }); +}); diff --git a/packages/integrations/test/technitium.spec.ts b/packages/integrations/test/technitium.spec.ts new file mode 100644 index 000000000..e8750af0b --- /dev/null +++ b/packages/integrations/test/technitium.spec.ts @@ -0,0 +1,341 @@ +// @vitest-environment node + +import type { StartedTestContainer } from "testcontainers"; +import { GenericContainer, Wait } from "testcontainers"; +import { beforeAll, beforeEach, afterAll, describe, expect, test, vi } from "vitest"; + +import { createDb } from "@homarr/db/test"; + +import type { IntegrationInput } from "../src/base/integration"; +import type { SessionStore } from "../src/base/session-store"; +import { TestConnectionError } from "../src/base/test-connection/test-connection-error"; +import { createTechnitiumDnsIntegrationAsync } from "../src/technitium/technitium-integration-factory"; +import { TechnitiumDnsIntegration } from "../src/technitium/technitium-integration"; +import type { TechnitiumVersion } from "../src/technitium/technitium-types"; + +// ─── module mocks ──────────────────────────────────────────────────────────── + +vi.mock("@homarr/db", async (importActual) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual = await importActual(); + return { ...actual, db: createDb() }; +}); + +vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual = await importActual(); + return { + ...actual, + getTrustedCertificateHostnamesAsync: vi.fn().mockImplementation(() => Promise.resolve([])), + }; +}); + +// Stateful in-memory session store keyed by integration id. +type StoredSession = { token: string; version: TechnitiumVersion }; +const sessionState: Record = {}; + +vi.mock("../src/base/session-store", () => ({ + createSessionStore: (integration: { id: string }) => + ({ + async getAsync() { + return sessionState[integration.id] ?? null; + }, + async setAsync(value: StoredSession) { + sessionState[integration.id] = value; + }, + async clearAsync() { + sessionState[integration.id] = null; + }, + }) satisfies SessionStore<{ token: string }>, +})); + +// ─── version matrix ─────────────────────────────────────────────────────────── +// +// We test three representative versions: +// latest → v15 (current API: Bearer auth, new path layout) +// 14.3.0 → v14 (last legacy release, most likely to be in production) +// 11.5.3 → v11 (oldest supported, sanity-checks long-term legacy stability) +// +// v12 and v13 use the same legacy API as v14 so a separate run adds no new coverage. + +type VersionEntry = { + tag: string; + apiVersion: TechnitiumVersion; +}; + +const VERSIONS: VersionEntry[] = [ + { tag: "latest", apiVersion: "v15" }, + { tag: "14.3.0", apiVersion: "legacy" }, + { tag: "11.5.3", apiVersion: "legacy" }, +]; + +// ─── helpers ───────────────────────────────────────────────────────────────── + +const DEFAULT_USERNAME = "admin"; +const DEFAULT_PASSWORD = "admin"; +const INTEGRATION_ID = "test-technitium"; + +const makeInput = ( + baseUrl: string, + auth: { kind: "credentials"; password?: string } | { kind: "apiKey"; value: string }, +): IntegrationInput => ({ + id: INTEGRATION_ID, + name: "Technitium DNS", + url: baseUrl, + externalUrl: null, + decryptedSecrets: + auth.kind === "credentials" + ? [ + { kind: "username", value: DEFAULT_USERNAME }, + { kind: "password", value: auth.password ?? DEFAULT_PASSWORD }, + ] + : [{ kind: "apiKey", value: auth.value }], +}); + +// Fetch an API token appropriate for the server version. +// v15 has a dedicated createToken endpoint; legacy reuses the session token from login. +async function fetchApiToken(baseUrl: string, apiVersion: TechnitiumVersion): Promise { + if (apiVersion === "v15") { + // v15 exposes a dedicated endpoint for non-expiring API tokens. + const res = await fetch( + `${baseUrl}/api/user/createToken?user=${DEFAULT_USERNAME}&pass=${DEFAULT_PASSWORD}&tokenName=homarr-test`, + ); + return ((await res.json()) as { token: string }).token; + } + // Legacy: /api/user/login exists on all tested versions (v11–v14) and returns a session + // token that can be used as a long-lived API key via the ?token= query parameter. + const res = await fetch(`${baseUrl}/api/user/login?user=${DEFAULT_USERNAME}&pass=${DEFAULT_PASSWORD}`); + return ((await res.json()) as { token: string }).token; +} + +// ─── parallel container pool ───────────────────────────────────────────────── +// +// All containers start in parallel so total wait ≈ slowest single startup (~30s), +// not the sum of all startups. + +type ContainerEntry = { + container: StartedTestContainer; + baseUrl: string; + apiToken: string; +}; + +const pool: Record = {}; + +beforeAll(async () => { + await Promise.all( + VERSIONS.map(async ({ tag, apiVersion }) => { + const container = await new GenericContainer(`technitium/dns-server:${tag}`) + .withExposedPorts(5380) + // Explicitly set the admin password so all versions use the same credentials. + // Without this, older versions may generate a random password on first run. + .withEnvironment({ DNS_SERVER_ADMIN_PASSWORD: DEFAULT_PASSWORD }) + .withWaitStrategy(Wait.forLogMessage("Technitium DNS Server was started successfully.")) + .start(); + const baseUrl = `http://${container.getHost()}:${container.getMappedPort(5380)}`; + const apiToken = await fetchApiToken(baseUrl, apiVersion); + pool[tag] = { container, baseUrl, apiToken }; + }), + ); +}, 120_000); + +afterAll(async () => { + await Promise.all(Object.values(pool).map(({ container }) => container.stop())); +}); + +// ─── version-parameterised suites ──────────────────────────────────────────── + +describe.each(VERSIONS)("Technitium DNS $tag ($apiVersion)", ({ tag, apiVersion }) => { + let baseUrl: string; + let apiToken: string; + + beforeAll(() => { + const entry = pool[tag]; + if (!entry) throw new Error(`Container pool entry for ${tag} was not initialized`); + ({ baseUrl, apiToken } = entry); + }); + + beforeEach(() => { + sessionState[INTEGRATION_ID] = null; + }); + + // ── factory ──────────────────────────────────────────────────────────────── + + describe("createTechnitiumDnsIntegrationAsync (factory)", () => { + test("factory creates a working integration (auto-detects version)", async () => { + const integration = await createTechnitiumDnsIntegrationAsync(makeInput(baseUrl, { kind: "credentials" })); + + const result = await integration.getSummaryAsync(); + + expect(result.status).toBe("enabled"); + expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0); + }, 20_000); + }); + + // ── getSummaryAsync ──────────────────────────────────────────────────────── + + describe("getSummaryAsync", () => { + test("returns valid summary with credentials", async () => { + const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" })); + + const result = await integration.getSummaryAsync(); + + expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0); + expect(result.adsBlockedToday).toBeGreaterThanOrEqual(0); + expect(result.adsBlockedTodayPercentage).toBeGreaterThanOrEqual(0); + expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0); + expect(result.status).toBe("enabled"); + }, 20_000); + + test("returns valid summary with API key", async () => { + const integration = new TechnitiumDnsIntegration( + makeInput(baseUrl, { kind: "apiKey", value: apiToken }), + apiVersion, + ); + + const result = await integration.getSummaryAsync(); + + expect(result.status).toBe("enabled"); + expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0); + }, 20_000); + + test("domainsBeingBlocked is a non-negative number (blockedZones + blockListZones)", async () => { + const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" })); + + const result = await integration.getSummaryAsync(); + + expect(typeof result.domainsBeingBlocked).toBe("number"); + expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0); + }, 20_000); + }); + + // ── session caching ──────────────────────────────────────────────────────── + + describe("session caching", () => { + test("login is performed once and token is cached for subsequent calls", async () => { + const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" })); + + await integration.getSummaryAsync(); + const tokenAfterFirst = sessionState[INTEGRATION_ID]?.token; + expect(tokenAfterFirst).toBeDefined(); + + await integration.getSummaryAsync(); + const tokenAfterSecond = sessionState[INTEGRATION_ID]?.token; + + expect(tokenAfterSecond).toBe(tokenAfterFirst); + }, 20_000); + + test("recovers from an expired/invalid stored token by re-logging in", async () => { + // Plant a bad token with the correct version so it hits the right API path before failing + sessionState[INTEGRATION_ID] = { token: "deliberately-invalid-token", version: apiVersion }; + + const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }), apiVersion); + + const result = await integration.getSummaryAsync(); + + expect(result.status).toBe("enabled"); + expect(sessionState[INTEGRATION_ID]).not.toBeNull(); + expect(sessionState[INTEGRATION_ID]?.token).not.toBe("deliberately-invalid-token"); + }, 20_000); + }); + + // ── blocking state changes ───────────────────────────────────────────────── + + describe("blocking state changes", () => { + test("disableAsync permanently disables blocking", async () => { + const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" })); + + await integration.disableAsync(); + const result = await integration.getSummaryAsync(); + + expect(result.status).toBe("disabled"); + + await integration.enableAsync(); // restore + }, 20_000); + + test("enableAsync re-enables blocking after permanent disable", async () => { + const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" })); + + await integration.disableAsync(); + await integration.enableAsync(); + const result = await integration.getSummaryAsync(); + + expect(result.status).toBe("enabled"); + }, 20_000); + + test("disableAsync with duration temporarily disables blocking", async () => { + const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" })); + + await integration.disableAsync(120); // 120s → 2 min + const result = await integration.getSummaryAsync(); + + expect(result.status).toBe("disabled"); + + await integration.enableAsync(); // restore + }, 20_000); + + test("disableAsync with small duration (1s) rounds up to 1 minute and disables", async () => { + const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" })); + + await integration.disableAsync(1); + const result = await integration.getSummaryAsync(); + + expect(result.status).toBe("disabled"); + + await integration.enableAsync(); // restore + }, 20_000); + + test("enableAsync cancels an active timed disable immediately", async () => { + const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" })); + + await integration.disableAsync(600); // 10 minutes + await integration.enableAsync(); + const result = await integration.getSummaryAsync(); + + expect(result.status).toBe("enabled"); + }, 20_000); + }); + + // ── testConnectionAsync ──────────────────────────────────────────────────── + + describe("testConnectionAsync", () => { + test("succeeds with valid credentials", async () => { + const result = await new TechnitiumDnsIntegration( + makeInput(baseUrl, { kind: "credentials" }), + ).testConnectionAsync(); + expect(result.success).toBe(true); + }, 20_000); + + test("succeeds with valid API key", async () => { + const result = await new TechnitiumDnsIntegration( + makeInput(baseUrl, { kind: "apiKey", value: apiToken }), + apiVersion, + ).testConnectionAsync(); + expect(result.success).toBe(true); + }, 20_000); + + test("fails with wrong password — returns authorization error", async () => { + const integration = new TechnitiumDnsIntegration( + makeInput(baseUrl, { kind: "credentials", password: "wrong-password" }), + ); + + const result = await integration.testConnectionAsync(); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toBeInstanceOf(TestConnectionError); + expect(result.error.type).toBe("authorization"); + }, 20_000); + + test("fails with invalid API key — returns authorization error", async () => { + const result = await new TechnitiumDnsIntegration( + makeInput(baseUrl, { kind: "apiKey", value: "invalid-api-key-xyz" }), + apiVersion, + ).testConnectionAsync(); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toBeInstanceOf(TestConnectionError); + expect(result.error.type).toBe("authorization"); + }, 20_000); + }); +});