fix: address widget review feedback
This commit is contained in:
1 parent
b1b17ad69f
commit
a38e731209
79 files changed
+2138
-244
No files matched your search
@@ -0,0 +1,50 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { immichAlbumsRequestHandler } from "./immich";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createIntegrationAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/integrations", () => ({
|
||||
createIntegrationAsync: mocks.createIntegrationAsync,
|
||||
}));
|
||||
|
||||
const integration = {
|
||||
id: "immich-integration",
|
||||
kind: "immich",
|
||||
} as Parameters<typeof immichAlbumsRequestHandler.handler>[0];
|
||||
|
||||
const albums = [
|
||||
{ id: "upstream-first", albumName: "Zebra", assetCount: 1 },
|
||||
{ id: "second-b", albumName: "Beta", assetCount: 10 },
|
||||
{ id: "second-a", albumName: "Alpha", assetCount: 10 },
|
||||
];
|
||||
|
||||
describe("immichAlbumsRequestHandler", () => {
|
||||
beforeEach(() => {
|
||||
immichAlbumsRequestHandler.invalidateCache();
|
||||
mocks.createIntegrationAsync.mockReset();
|
||||
});
|
||||
|
||||
test("sorts deterministically before caching a limited response", async () => {
|
||||
const getAlbumsAsync = vi.fn().mockResolvedValue(albums);
|
||||
mocks.createIntegrationAsync.mockResolvedValue({ getAlbumsAsync });
|
||||
|
||||
const first = await immichAlbumsRequestHandler.handler(integration, { limit: 2 }).getDataAsync();
|
||||
const cached = await immichAlbumsRequestHandler.handler(integration, { limit: 2 }).getDataAsync();
|
||||
|
||||
expect(first.data.map(({ id }) => id)).toStrictEqual(["second-a", "second-b"]);
|
||||
expect(cached.data).toStrictEqual(first.data);
|
||||
expect(getAlbumsAsync).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("preserves upstream order when no limit is requested", async () => {
|
||||
const getAlbumsAsync = vi.fn().mockResolvedValue(albums);
|
||||
mocks.createIntegrationAsync.mockResolvedValue({ getAlbumsAsync });
|
||||
|
||||
const result = await immichAlbumsRequestHandler.handler(integration, {}).getDataAsync();
|
||||
|
||||
expect(result.data).toStrictEqual(albums);
|
||||
});
|
||||
});
|
||||
@@ -22,14 +22,27 @@ export const immichAlbumsRequestHandler = createIntegrationRequestHandler<
|
||||
assetCount: number;
|
||||
}[],
|
||||
IntegrationKindByCategory<"photoService">,
|
||||
Record<string, never>
|
||||
{ limit?: number }
|
||||
>({
|
||||
async requestAsync(integration) {
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getAlbumsAsync();
|
||||
const albums = await integrationInstance.getAlbumsAsync();
|
||||
|
||||
if (input.limit === undefined) return albums;
|
||||
|
||||
return albums
|
||||
.toSorted(
|
||||
(left, right) =>
|
||||
right.assetCount - left.assetCount ||
|
||||
compareText(left.albumName, right.albumName) ||
|
||||
compareText(left.id, right.id),
|
||||
)
|
||||
.slice(0, input.limit);
|
||||
},
|
||||
});
|
||||
|
||||
const compareText = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0);
|
||||
|
||||
export const immichAlbumRequestHandler = createIntegrationRequestHandler<
|
||||
ImmichAlbum,
|
||||
IntegrationKindByCategory<"photoService">,
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import { Response } from "undici";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { createCertificateAgentAsync, fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import { timetableGetTimetableRequestHandler, timetableSearchStationsRequestHandler } from "./timetable";
|
||||
import { normalizeTimetableBaseUrl, readBoundedTimetableJsonAsync } from "./timetable-url";
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({ fetchWithTrustedCertificatesAsync: vi.fn() }));
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
createCertificateAgentAsync: vi.fn(),
|
||||
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||
const mockCreateCertificateAgent = vi.mocked(createCertificateAgentAsync);
|
||||
const mockCloseDispatcher = vi.fn();
|
||||
const mockDispatcher = { close: mockCloseDispatcher };
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreateCertificateAgent.mockResolvedValue(mockDispatcher as never);
|
||||
});
|
||||
|
||||
describe("normalizeTimetableBaseUrl", () => {
|
||||
it("normalizes an HTTP endpoint", () => {
|
||||
@@ -55,6 +64,58 @@ describe("timetable response bounds", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("pins the vetted address into a direct connection without changing the request hostname", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify([{ id: "station", label: "Station", iconclass: "train" }])),
|
||||
);
|
||||
const pinnedAddresses = [
|
||||
{ address: "93.184.216.34", family: 4 as const },
|
||||
{ address: "2606:2800:220:1:248:1893:25c8:1946", family: 6 as const },
|
||||
];
|
||||
|
||||
await timetableSearchStationsRequestHandler
|
||||
.handler({
|
||||
baseUrl: "https://timetable.example.com",
|
||||
query: "pinned-stations",
|
||||
pinnedAddresses,
|
||||
})
|
||||
.getDataAsync();
|
||||
|
||||
expect(mockCreateCertificateAgent).toHaveBeenCalledWith(
|
||||
{ lookup: expect.any(Function) },
|
||||
{
|
||||
autoSelectFamily: true,
|
||||
bodyTimeout: 10_000,
|
||||
httpProxy: "",
|
||||
httpsProxy: "",
|
||||
noProxy: "*",
|
||||
},
|
||||
);
|
||||
const requestUrl = mockFetch.mock.calls[0]?.[0];
|
||||
expect(new URL(String(requestUrl)).hostname).toBe("timetable.example.com");
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ dispatcher: mockDispatcher, redirect: "error" }),
|
||||
);
|
||||
|
||||
const pinnedLookup = mockCreateCertificateAgent.mock.calls[0]?.[0]?.lookup;
|
||||
expect(pinnedLookup).toBeTypeOf("function");
|
||||
if (!pinnedLookup) throw new Error("Expected a pinned lookup function");
|
||||
|
||||
const lookupCallback = vi.fn();
|
||||
pinnedLookup("timetable.example.com", { all: true }, lookupCallback);
|
||||
expect(lookupCallback).toHaveBeenCalledWith(null, pinnedAddresses);
|
||||
|
||||
const repeatedLookupCallback = vi.fn();
|
||||
pinnedLookup("timetable.example.com", { all: true }, repeatedLookupCallback);
|
||||
expect(repeatedLookupCallback).toHaveBeenCalledWith(null, pinnedAddresses);
|
||||
|
||||
const unexpectedHostCallback = vi.fn();
|
||||
pinnedLookup("different.example.com", { all: true }, unexpectedHostCallback);
|
||||
expect(unexpectedHostCallback.mock.calls[0]?.[0]).toMatchObject({ code: "ENOTFOUND" });
|
||||
expect(mockCloseDispatcher).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("never caches more departures than requested", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { LookupFunction } from "node:net";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { createCertificateAgentAsync, fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import { createRequestHandler } from "./lib/request-handler";
|
||||
import { normalizeTimetableBaseUrl, readBoundedTimetableJsonAsync } from "./timetable-url";
|
||||
@@ -36,12 +37,17 @@ const MAX_STATION_RESULTS = 100;
|
||||
const MAX_TIMETABLE_ENTRIES = 100;
|
||||
const timetableFetchOptions = { redirect: "error", timeout: 10_000, bodyTimeout: 10_000 } as const;
|
||||
|
||||
export interface TimetableResolvedAddress {
|
||||
address: string;
|
||||
family: 4 | 6;
|
||||
}
|
||||
|
||||
export const timetableSearchStationsRequestHandler = createRequestHandler<
|
||||
Station[],
|
||||
{ baseUrl: string; query: string }
|
||||
{ baseUrl: string; query: string; pinnedAddresses?: TimetableResolvedAddress[] }
|
||||
>({
|
||||
async requestAsync(input) {
|
||||
return await searchStationsAsync(input.baseUrl, input.query);
|
||||
return await searchStationsAsync(input.baseUrl, input.query, input.pinnedAddresses);
|
||||
},
|
||||
cacheTtlMs: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
@@ -64,19 +70,73 @@ const buildUrl = (baseUrl: string, path: `/${string}`, queryParams: Record<strin
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const searchStationsAsync = async (baseUrl: string, query: string): Promise<Station[]> => {
|
||||
const response = await fetchWithTrustedCertificatesAsync(
|
||||
buildUrl(baseUrl, "/timetable/api/completion.json", { term: query, show_ids: 1, nofavorites: 1 }),
|
||||
timetableFetchOptions,
|
||||
);
|
||||
if (!response.ok) throw new ResponseError(response);
|
||||
const normalizeLookupHostname = (hostname: string) =>
|
||||
hostname
|
||||
.replace(/^\[(.*)\]$/, "$1")
|
||||
.toLowerCase()
|
||||
.replace(/\.$/, "");
|
||||
|
||||
const body = await readBoundedTimetableJsonAsync(response);
|
||||
const data = await searchSchema.parseAsync(Array.isArray(body) ? body.slice(0, MAX_STATION_RESULTS) : body);
|
||||
return data
|
||||
.filter((item) => supportedStationTypes.some((type) => item.iconclass.endsWith(type)))
|
||||
.map((item) => (item.id !== undefined ? { id: item.id, name: item.label } : null))
|
||||
.filter((item) => item !== null);
|
||||
const createPinnedLookup = (baseUrl: string, addresses: TimetableResolvedAddress[]): LookupFunction => {
|
||||
const expectedHostname = normalizeLookupHostname(new URL(baseUrl).hostname);
|
||||
|
||||
return (hostname, options, callback) => {
|
||||
const requestedFamily = options.family === 4 || options.family === 6 ? options.family : undefined;
|
||||
const candidates = requestedFamily ? addresses.filter(({ family }) => family === requestedFamily) : addresses;
|
||||
if (normalizeLookupHostname(hostname) !== expectedHostname || candidates.length === 0) {
|
||||
const error = new Error("Pinned timetable address is unavailable") as NodeJS.ErrnoException;
|
||||
error.code = "ENOTFOUND";
|
||||
callback(error, []);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.all) {
|
||||
callback(null, candidates);
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = candidates[0];
|
||||
if (!selected) return callback(new Error("Pinned timetable address is unavailable"), []);
|
||||
callback(null, selected.address, selected.family);
|
||||
};
|
||||
};
|
||||
|
||||
const createPinnedTimetableDispatcherAsync = async (baseUrl: string, addresses: TimetableResolvedAddress[]) => {
|
||||
// Proxies resolve CONNECT targets independently, which would defeat DNS pinning. Force this security-sensitive
|
||||
// configuration request to connect directly. The original URL remains unchanged for the Host header and TLS SNI.
|
||||
return await createCertificateAgentAsync(
|
||||
{ lookup: createPinnedLookup(baseUrl, addresses) },
|
||||
{
|
||||
autoSelectFamily: addresses.length > 1,
|
||||
bodyTimeout: timetableFetchOptions.bodyTimeout,
|
||||
httpProxy: "",
|
||||
httpsProxy: "",
|
||||
noProxy: "*",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const searchStationsAsync = async (
|
||||
baseUrl: string,
|
||||
query: string,
|
||||
pinnedAddresses?: TimetableResolvedAddress[],
|
||||
): Promise<Station[]> => {
|
||||
const dispatcher = pinnedAddresses ? await createPinnedTimetableDispatcherAsync(baseUrl, pinnedAddresses) : undefined;
|
||||
try {
|
||||
const response = await fetchWithTrustedCertificatesAsync(
|
||||
buildUrl(baseUrl, "/timetable/api/completion.json", { term: query, show_ids: 1, nofavorites: 1 }),
|
||||
{ ...timetableFetchOptions, dispatcher },
|
||||
);
|
||||
if (!response.ok) throw new ResponseError(response);
|
||||
|
||||
const body = await readBoundedTimetableJsonAsync(response);
|
||||
const data = await searchSchema.parseAsync(Array.isArray(body) ? body.slice(0, MAX_STATION_RESULTS) : body);
|
||||
return data
|
||||
.filter((item) => supportedStationTypes.some((type) => item.iconclass.endsWith(type)))
|
||||
.map((item) => (item.id !== undefined ? { id: item.id, name: item.label } : null))
|
||||
.filter((item) => item !== null);
|
||||
} finally {
|
||||
await dispatcher?.close();
|
||||
}
|
||||
};
|
||||
|
||||
const getTimetableAsync = async (
|
||||
|
||||
Reference in new issue
Block a user