// @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); }); });