From 31e218295da8d0ac6c8099d363885dea917d4e2b Mon Sep 17 00:00:00 2001 From: ajnart Date: Sat, 22 Aug 2026 17:12:43 +0200 Subject: [PATCH 1/3] fix(http): preserve SDK headers when injecting User-Agent The v1.76.0 User-Agent injection object-spread `fetchOptions.headers`, which silently drops all headers when the value is a Headers instance. The Immich SDK passes a Headers object with the merged x-api-key header, so requests lost their API key and returned 401 (issue #6672). Build a real Headers instance instead, keeping case-insensitive user-agent handling. --- .../core/src/infrastructure/http/index.ts | 1 + .../core/src/infrastructure/http/request.ts | 32 ++++++------ .../test/infrastructure/http/request.spec.ts | 50 +++++++++++++++++++ 3 files changed, 65 insertions(+), 18 deletions(-) create mode 100644 packages/core/src/test/infrastructure/http/request.spec.ts 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..4d1f8527a --- /dev/null +++ b/packages/core/src/test/infrastructure/http/request.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "vitest"; + +import { mergeHeadersWithUserAgent } from "@homarr/core/infrastructure/http"; + +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 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/"); + }); +}); From b6f3aba23ea10e1f2a0da5b2756b6a73be7bd3e1 Mon Sep 17 00:00:00 2001 From: ajnart Date: Sat, 22 Aug 2026 17:16:31 +0200 Subject: [PATCH 2/3] test(http,immich): prove User-Agent fix at the wire level Add two regression tests that fail on v1.76.0 and pass with the fix: - wire-level test: a Headers instance (the shape @immich/sdk hands over) must reach the remote server with all headers intact - real-SDK e2e: getServerStatistics through fetchWithTrustedCertificatesAsync against a live local server must deliver x-api-key --- .../test/infrastructure/http/request.spec.ts | 55 ++++++++++++++++++- .../integrations/test/immich-sdk-e2e.spec.ts | 37 +++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 packages/integrations/test/immich-sdk-e2e.spec.ts diff --git a/packages/core/src/test/infrastructure/http/request.spec.ts b/packages/core/src/test/infrastructure/http/request.spec.ts index 4d1f8527a..3d2b9c888 100644 --- a/packages/core/src/test/infrastructure/http/request.spec.ts +++ b/packages/core/src/test/infrastructure/http/request.spec.ts @@ -1,6 +1,18 @@ -import { describe, expect, test } from "vitest"; +// @vitest-environment node -import { mergeHeadersWithUserAgent } from "@homarr/core/infrastructure/http"; +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", () => { @@ -48,3 +60,42 @@ describe("mergeHeadersWithUserAgent", () => { 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 + const response = await fetchWithTrustedCertificatesAsync(`http://127.0.0.1:${port}/api/server/statistics`, { + headers: headers as unknown as RequestInit["headers"], + }); + server.close(); + + // Assert + expect(response.status).toBe(200); + expect(getReceivedHeaders()["x-api-key"]).toBe("immich-api-key"); + expect(getReceivedHeaders()["user-agent"]).toContain("Homarr/"); + }); +}); 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..00072e91f --- /dev/null +++ b/packages/integrations/test/immich-sdk-e2e.spec.ts @@ -0,0 +1,37 @@ +// @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" }); + const stats = await getServerStatistics({ + fetch: fetchWithTrustedCertificatesAsync as unknown as typeof fetch, + }); + + server.close(); + + expect(receivedXApiKey).toBe("sdk-configured-key"); + expect(stats).toMatchObject({ usage: 1000, photos: 5, videos: 2 }); + }); +}); From 8579fcbfc2ecaa0be93e637909c915136914b472 Mon Sep 17 00:00:00 2001 From: ajnart Date: Sat, 22 Aug 2026 17:21:38 +0200 Subject: [PATCH 3/3] test(http,immich): address CodeRabbit review - guarantee server teardown via try/finally in both wire-level tests - add tuple-array HeadersInit coverage to the helper suite --- .../test/infrastructure/http/request.spec.ts | 35 ++++++++++++++----- .../integrations/test/immich-sdk-e2e.spec.ts | 16 +++++---- 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/packages/core/src/test/infrastructure/http/request.spec.ts b/packages/core/src/test/infrastructure/http/request.spec.ts index 3d2b9c888..e1980862d 100644 --- a/packages/core/src/test/infrastructure/http/request.spec.ts +++ b/packages/core/src/test/infrastructure/http/request.spec.ts @@ -41,6 +41,22 @@ describe("mergeHeadersWithUserAgent", () => { 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" }); @@ -88,14 +104,17 @@ describe("fetchWithTrustedCertificatesAsync", () => { const headers = new Headers({ "x-api-key": "immich-api-key", Accept: "application/json" }); // Act - const response = await fetchWithTrustedCertificatesAsync(`http://127.0.0.1:${port}/api/server/statistics`, { - headers: headers as unknown as RequestInit["headers"], - }); - server.close(); + 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/"); + // 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 index 00072e91f..9e1f5ade9 100644 --- a/packages/integrations/test/immich-sdk-e2e.spec.ts +++ b/packages/integrations/test/immich-sdk-e2e.spec.ts @@ -25,13 +25,15 @@ describe("real @immich/sdk end-to-end through fetchWithTrustedCertificatesAsync" const port = (server.address() as AddressInfo).port; init({ baseUrl: `http://127.0.0.1:${port}/api`, apiKey: "sdk-configured-key" }); - const stats = await getServerStatistics({ - fetch: fetchWithTrustedCertificatesAsync as unknown as typeof fetch, - }); + try { + const stats = await getServerStatistics({ + fetch: fetchWithTrustedCertificatesAsync as unknown as typeof fetch, + }); - server.close(); - - expect(receivedXApiKey).toBe("sdk-configured-key"); - expect(stats).toMatchObject({ usage: 1000, photos: 5, videos: 2 }); + expect(receivedXApiKey).toBe("sdk-configured-key"); + expect(stats).toMatchObject({ usage: 1000, photos: 5, videos: 2 }); + } finally { + server.close(); + } }); });