+12








fcd7c979df
Co-authored-by: Artur <145776561+arturict@users.noreply.github.com> Co-authored-by: arturict <arturict@users.noreply.github.com> Co-authored-by: Thomas <thomas@ajnart.fr> Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com> Co-authored-by: Danura30082 <danura30082+github@gmail.com> Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com> Co-authored-by: ajnart <thomas@ajnart.fr> Co-authored-by: ajnart <thomas@ajnart.dev> Co-authored-by: chenjun <chenjun@foxmail.com> Co-authored-by: Wikibear <wikibear@users.noreply.github.com> Co-authored-by: Bergasha <bergasha@gmail.com> Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com> Co-authored-by: Lucas Araujo <lucas.lra@gmail.com> Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com> Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social> Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com> Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com> Co-authored-by: thrawnx <tight.bulb9384@fastmail.com> Co-authored-by: Chris <58918656+nordada@users.noreply.github.com> Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com> Co-authored-by: Yanis <naamaneyanis@gmail.com> Co-authored-by: Wikibear <me@sej.de> Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
217 lines
7.6 KiB
TypeScript
217 lines
7.6 KiB
TypeScript
import { Response } from "undici";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
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", () => ({
|
|
createCertificateAgentAsync: vi.fn(),
|
|
fetchWithTrustedCertificatesAsync: vi.fn(),
|
|
}));
|
|
|
|
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
|
const mockCreateCertificateAgent = vi.mocked(createCertificateAgentAsync);
|
|
const mockCloseDispatcher = vi.fn();
|
|
const mockDestroyDispatcher = vi.fn().mockResolvedValue(undefined);
|
|
const mockDispatcher = { close: mockCloseDispatcher, destroy: mockDestroyDispatcher };
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockCreateCertificateAgent.mockResolvedValue(mockDispatcher as never);
|
|
});
|
|
|
|
describe("normalizeTimetableBaseUrl", () => {
|
|
it("normalizes an HTTP endpoint", () => {
|
|
expect(normalizeTimetableBaseUrl("https://search.ch/")).toBe("https://search.ch");
|
|
});
|
|
|
|
it.each(["file:///etc/passwd", "https://user:secret@example.com", "https://example.com?redirect=/admin"])(
|
|
"rejects unsafe base URL %s",
|
|
(url) => expect(() => normalizeTimetableBaseUrl(url)).toThrow(),
|
|
);
|
|
});
|
|
|
|
describe("timetable response bounds", () => {
|
|
it("rejects a response once its byte budget is exceeded", async () => {
|
|
const response = new Response(JSON.stringify({ value: "x".repeat(100) }));
|
|
await expect(readBoundedTimetableJsonAsync(response, 32)).rejects.toThrow("exceeds 32 bytes");
|
|
});
|
|
|
|
it("caps and caches station results and rejects redirects", async () => {
|
|
mockFetch.mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify(
|
|
Array.from({ length: 125 }, (_, index) => ({
|
|
id: String(index),
|
|
label: `Station ${index}`,
|
|
iconclass: "train",
|
|
})),
|
|
),
|
|
),
|
|
);
|
|
const input = { baseUrl: "http://timetable.internal", query: "bounded-stations" };
|
|
|
|
const first = await timetableSearchStationsRequestHandler.handler(input).getDataAsync();
|
|
const second = await timetableSearchStationsRequestHandler.handler(input).getDataAsync();
|
|
|
|
expect(first.data).toHaveLength(100);
|
|
expect(second.data).toHaveLength(100);
|
|
expect(mockFetch).toHaveBeenCalledOnce();
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
expect.objectContaining({ redirect: "error", bodyTimeout: 10_000 }),
|
|
);
|
|
});
|
|
|
|
it("pins the vetted address into a direct connection without changing the request hostname", async () => {
|
|
const pinnedAddresses = [
|
|
{ address: "93.184.216.34", family: 4 as const },
|
|
{ address: "2606:2800:220:1:248:1893:25c8:1946", family: 6 as const },
|
|
];
|
|
mockFetch.mockImplementation(async () => {
|
|
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);
|
|
expect(mockCloseDispatcher).not.toHaveBeenCalled();
|
|
|
|
const unexpectedHostCallback = vi.fn();
|
|
pinnedLookup("different.example.com", { all: true }, unexpectedHostCallback);
|
|
expect(unexpectedHostCallback.mock.calls[0]?.[0]).toMatchObject({ code: "ENOTFOUND" });
|
|
|
|
return new Response(JSON.stringify([{ id: "station", label: "Station", iconclass: "train" }]));
|
|
});
|
|
|
|
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" }),
|
|
);
|
|
|
|
expect(mockCloseDispatcher).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("pins timetable requests and closes their direct dispatcher", async () => {
|
|
mockFetch.mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify({
|
|
connections: [
|
|
{
|
|
time: "2026-01-01T00:00:00Z",
|
|
line: "1",
|
|
color: "ffffff",
|
|
terminal: { name: "Stop" },
|
|
dep_delay: "0",
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
);
|
|
const pinnedAddresses = [{ address: "93.184.216.34", family: 4 as const }];
|
|
|
|
await timetableGetTimetableRequestHandler
|
|
.handler({
|
|
baseUrl: "https://timetable.example.com",
|
|
stationId: "pinned-departures",
|
|
limit: 1,
|
|
pinnedAddresses,
|
|
})
|
|
.getDataAsync();
|
|
|
|
expect(mockCreateCertificateAgent).toHaveBeenCalledWith(
|
|
{ lookup: expect.any(Function) },
|
|
expect.objectContaining({ noProxy: "*" }),
|
|
);
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
expect.objectContaining({ dispatcher: mockDispatcher, redirect: "error" }),
|
|
);
|
|
expect(mockCloseDispatcher).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("destroys the pinned station dispatcher when response handling fails", async () => {
|
|
mockFetch.mockResolvedValue(new Response("not json"));
|
|
|
|
await expect(
|
|
timetableSearchStationsRequestHandler
|
|
.handler({
|
|
baseUrl: "https://timetable.example.com",
|
|
query: "invalid-stations",
|
|
pinnedAddresses: [{ address: "93.184.216.34", family: 4 }],
|
|
})
|
|
.getDataAsync(),
|
|
).rejects.toThrow();
|
|
|
|
expect(mockDestroyDispatcher).toHaveBeenCalledOnce();
|
|
expect(mockCloseDispatcher).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("destroys the pinned timetable dispatcher when response handling fails", async () => {
|
|
mockFetch.mockResolvedValue(new Response("not json"));
|
|
|
|
await expect(
|
|
timetableGetTimetableRequestHandler
|
|
.handler({
|
|
baseUrl: "https://timetable.example.com",
|
|
stationId: "invalid-departures",
|
|
limit: 1,
|
|
pinnedAddresses: [{ address: "93.184.216.34", family: 4 }],
|
|
})
|
|
.getDataAsync(),
|
|
).rejects.toThrow();
|
|
|
|
expect(mockDestroyDispatcher).toHaveBeenCalledOnce();
|
|
expect(mockCloseDispatcher).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("never caches more departures than requested", async () => {
|
|
mockFetch.mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify({
|
|
connections: Array.from({ length: 5 }, (_, index) => ({
|
|
time: `2026-01-01T00:0${index}:00Z`,
|
|
line: "1",
|
|
color: "ffffff",
|
|
terminal: { name: `Stop ${index}` },
|
|
dep_delay: "0",
|
|
})),
|
|
}),
|
|
),
|
|
);
|
|
|
|
const result = await timetableGetTimetableRequestHandler
|
|
.handler({ baseUrl: "http://timetable.internal", stationId: "bounded-departures", limit: 2 })
|
|
.getDataAsync();
|
|
|
|
expect(result.data.entries).toHaveLength(2);
|
|
});
|
|
});
|