diff --git a/packages/core/src/infrastructure/http/index.ts b/packages/core/src/infrastructure/http/index.ts index 572388f2a..116cf5971 100644 --- a/packages/core/src/infrastructure/http/index.ts +++ b/packages/core/src/infrastructure/http/index.ts @@ -5,4 +5,5 @@ export { createCustomCheckServerIdentity, createHttpsAgentAsync, fetchWithTrustedCertificatesAsync, + mergeHeadersWithUserAgent, } from "./request"; diff --git a/packages/core/src/infrastructure/http/request.ts b/packages/core/src/infrastructure/http/request.ts index 20af8232f..0b06b2725 100644 --- a/packages/core/src/infrastructure/http/request.ts +++ b/packages/core/src/infrastructure/http/request.ts @@ -18,6 +18,14 @@ import packageJson from "../../../../../package.json"; export const getDefaultUserAgent = () => `Homarr/${packageJson.version} (+https://homarr.dev)`; +export const mergeHeadersWithUserAgent = (headers: unknown): Headers => { + const mergedHeaders = new Headers(headers as HeadersInit); + if (!mergedHeaders.has("user-agent")) { + mergedHeaders.set("user-agent", getDefaultUserAgent()); + } + return mergedHeaders; +}; + export const createCustomCheckServerIdentity = ( trustedHostnames: TrustedCertificateHostname[], ): typeof checkServerIdentity => { @@ -77,30 +85,18 @@ export const fetchWithTrustedCertificatesAsync = async ( undefined, options?.bodyTimeout !== undefined ? { bodyTimeout: options.bodyTimeout } : undefined, )); + const buildFetchOptions = (fetchOptions: Omit) => ({ + ...fetchOptions, + headers: mergeHeadersWithUserAgent(fetchOptions.headers) as unknown as RequestInit["headers"], + }); if (options?.timeout) { const { bodyTimeout: _bodyTimeout, dispatcher: _dispatcher, ...fetchOptions } = options; return await withTimeoutAsync( - async (signal) => - fetch(url, { - ...fetchOptions, - headers: { - "User-Agent": getDefaultUserAgent(), - ...fetchOptions.headers, - }, - signal, - dispatcher: agent, - }), + async (signal) => fetch(url, { ...buildFetchOptions(fetchOptions), signal, dispatcher: agent }), options.timeout, ); } const { bodyTimeout: _bodyTimeout, dispatcher: _dispatcher, ...fetchOptions } = options ?? {}; - return fetch(url, { - ...fetchOptions, - headers: { - "User-Agent": getDefaultUserAgent(), - ...fetchOptions.headers, - }, - dispatcher: agent, - }); + return fetch(url, { ...buildFetchOptions(fetchOptions), dispatcher: agent }); }; diff --git a/packages/core/src/test/infrastructure/http/request.spec.ts b/packages/core/src/test/infrastructure/http/request.spec.ts new file mode 100644 index 000000000..e1980862d --- /dev/null +++ b/packages/core/src/test/infrastructure/http/request.spec.ts @@ -0,0 +1,120 @@ +// @vitest-environment node + +import { createServer } from "node:http"; +import type { Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import type { RequestInit } from "undici"; + +import { describe, expect, test, vi } from "vitest"; + +import { fetchWithTrustedCertificatesAsync, mergeHeadersWithUserAgent } from "@homarr/core/infrastructure/http"; + +vi.mock("@homarr/core/infrastructure/certificates", () => ({ + getAllTrustedCertificatesAsync: vi.fn(async () => []), + getTrustedCertificateHostnamesAsync: vi.fn(async () => []), +})); + +describe("mergeHeadersWithUserAgent", () => { + test("should keep existing headers when given a Headers instance", () => { + // Arrange + const headers = new Headers({ "x-api-key": "some-api-key", Accept: "application/json" }); + + // Act + const merged = mergeHeadersWithUserAgent(headers); + + // Assert + expect(merged.get("x-api-key")).toBe("some-api-key"); + expect(merged.get("accept")).toBe("application/json"); + expect(merged.get("user-agent")).toContain("Homarr/"); + }); + + test("should keep existing headers when given a record", () => { + // Arrange + const headers = { "x-api-key": "some-api-key", Accept: "application/json" }; + + // Act + const merged = mergeHeadersWithUserAgent(headers); + + // Assert + expect(merged.get("x-api-key")).toBe("some-api-key"); + expect(merged.get("accept")).toBe("application/json"); + expect(merged.get("user-agent")).toContain("Homarr/"); + }); + + test("should keep existing headers when given a tuple array", () => { + // Arrange + const headers: [string, string][] = [ + ["x-api-key", "some-api-key"], + ["Accept", "application/json"], + ]; + + // Act + const merged = mergeHeadersWithUserAgent(headers); + + // Assert + expect(merged.get("x-api-key")).toBe("some-api-key"); + expect(merged.get("accept")).toBe("application/json"); + expect(merged.get("user-agent")).toContain("Homarr/"); + }); + + test("should not override an existing user-agent in any casing", () => { + // Arrange + const headers = new Headers({ "USER-AGENT": "custom-agent" }); + + // Act + const merged = mergeHeadersWithUserAgent(headers); + + // Assert + expect(merged.get("user-agent")).toBe("custom-agent"); + }); + + test("should fall back to the default user agent when no headers are provided", () => { + // Act + const merged = mergeHeadersWithUserAgent(undefined); + + // Assert + expect(merged.get("user-agent")).toContain("Homarr/"); + }); +}); + +const startCaptureServerAsync = async (): Promise<{ + server: Server; + getReceivedHeaders: () => Record; + port: number; +}> => { + let receivedHeaders: Record = {}; + const server = createServer((req, res) => { + receivedHeaders = req.headers; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end("{}"); + }); + await new Promise((resolve) => server.listen(0, resolve)); + const port = (server.address() as AddressInfo).port; + return { + server, + port, + getReceivedHeaders: () => receivedHeaders, + }; +}; + +describe("fetchWithTrustedCertificatesAsync", () => { + test("should deliver a Headers instance with all headers to the remote server", async () => { + // Arrange - the @immich/sdk hands over a Headers instance after merging its defaults + const { server, port, getReceivedHeaders } = await startCaptureServerAsync(); + const headers = new Headers({ "x-api-key": "immich-api-key", Accept: "application/json" }); + + // Act + try { + const response = await fetchWithTrustedCertificatesAsync(`http://127.0.0.1:${port}/api/server/statistics`, { + headers: headers as unknown as RequestInit["headers"], + }); + + // Assert + expect(response.status).toBe(200); + expect(getReceivedHeaders()["x-api-key"]).toBe("immich-api-key"); + expect(getReceivedHeaders()["user-agent"]).toContain("Homarr/"); + } finally { + server.close(); + } + }); +}); diff --git a/packages/integrations/test/immich-sdk-e2e.spec.ts b/packages/integrations/test/immich-sdk-e2e.spec.ts new file mode 100644 index 000000000..9e1f5ade9 --- /dev/null +++ b/packages/integrations/test/immich-sdk-e2e.spec.ts @@ -0,0 +1,39 @@ +// @vitest-environment node + +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; + +import { getServerStatistics, init } from "@immich/sdk"; +import { describe, expect, test, vi } from "vitest"; + +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +vi.mock("@homarr/core/infrastructure/certificates", () => ({ + getAllTrustedCertificatesAsync: vi.fn(async () => []), + getTrustedCertificateHostnamesAsync: vi.fn(async () => []), +})); + +describe("real @immich/sdk end-to-end through fetchWithTrustedCertificatesAsync", () => { + test("x-api-key set via init() reaches the server", async () => { + let receivedXApiKey: string | string[] | undefined; + const server = createServer((req, res) => { + receivedXApiKey = req.headers["x-api-key"]; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ usage: 1000, photos: 5, videos: 2 })); + }); + await new Promise((resolve) => server.listen(0, resolve)); + const port = (server.address() as AddressInfo).port; + + init({ baseUrl: `http://127.0.0.1:${port}/api`, apiKey: "sdk-configured-key" }); + try { + const stats = await getServerStatistics({ + fetch: fetchWithTrustedCertificatesAsync as unknown as typeof fetch, + }); + + expect(receivedXApiKey).toBe("sdk-configured-key"); + expect(stats).toMatchObject({ usage: 1000, photos: 5, videos: 2 }); + } finally { + server.close(); + } + }); +});