feat: add Technitium DNS integration, usable with DNS Hole widgets (#5832)
Co-authored-by: Daniel Lopes <daniel.lopes@agido.com>
This commit is contained in:
1 parent
a42af0e678
commit
cd1a82a640
9 files changed
+1634
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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"]],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
@@ -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<StoredSession>;
|
||||
|
||||
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<DnsHoleSummary> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<TestingResult> {
|
||||
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<ReturnType<typeof readTechnitiumBodyAsync>>;
|
||||
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<void> {
|
||||
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<T>(action: () => Promise<T>): Promise<T> {
|
||||
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<string | null> {
|
||||
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<TechnitiumVersion> {
|
||||
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<StoredSession> {
|
||||
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<StoredSession> => {
|
||||
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<string, string>,
|
||||
token: string | null,
|
||||
fetchFn: typeof fetchWithTrustedCertificatesAsync = fetchWithTrustedCertificatesAsync,
|
||||
): Promise<UndiciResponse> {
|
||||
const url = this.url(path, params);
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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 <token> (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<string, `/${string}`>;
|
||||
|
||||
// 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<unknown>;
|
||||
}): Promise<{ status: TechnitiumStatus; data: Record<string, unknown> }> {
|
||||
const data = (await response.json()) as Record<string, unknown>;
|
||||
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(),
|
||||
}),
|
||||
});
|
||||
@@ -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<typeof import("@homarr/db")>();
|
||||
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<typeof import("@homarr/core/infrastructure/certificates")>();
|
||||
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<StoredSession>,
|
||||
}));
|
||||
|
||||
// ─── 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<string, string> | 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<string, string> | 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<string, string> | 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<string, string> | 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<string, string> | 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<unknown>;
|
||||
}
|
||||
).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<string, string> | undefined)?.["Authorization"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof import("@homarr/db")>();
|
||||
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<typeof import("@homarr/core/infrastructure/certificates")>();
|
||||
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<string, StoredSession | null> = {};
|
||||
|
||||
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<string> {
|
||||
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<string, ContainerEntry> = {};
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user