fix: address widget review feedback

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 11:07:08 +02:00
1 parent b1b17ad69f
commit a38e731209
79 files changed
+2138 -244

No files matched your search

@@ -1,15 +1,18 @@
import { stringify } from "superjson";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import type { Database } from "@homarr/db";
import { eq } from "@homarr/db";
import { boards, items, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { timetableRouter } from "../../widgets/timetable";
const mocks = vi.hoisted(() => ({ getTimetable: vi.fn(), searchStations: vi.fn() }));
const mocks = vi.hoisted(() => ({ getTimetable: vi.fn(), searchStations: vi.fn(), lookup: vi.fn() }));
vi.mock("node:dns/promises", () => ({ default: { lookup: mocks.lookup }, lookup: mocks.lookup }));
vi.mock("@homarr/request-handler/timetable", () => ({
timetableGetTimetableRequestHandler: {
@@ -30,6 +33,11 @@ vi.mock("@homarr/request-handler/timetable", () => ({
},
}));
beforeEach(() => {
vi.clearAllMocks();
mocks.lookup.mockResolvedValue([{ address: "8.8.8.8", family: 4 }]);
});
const createSession = (userId: string): Session => ({
user: { id: userId, permissions: [], colorScheme: "light" },
expires: new Date().toISOString(),
@@ -66,6 +74,14 @@ describe("timetableRouter source authorization", () => {
});
});
it("reports invalid timetable URLs as bad requests", async () => {
const caller = createCaller(createDb(), null);
await expect(
caller.searchStations({ baseUrl: "https://search.ch?redirect=/admin", query: "Bern" }),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
it("loads a persisted private endpoint only through a viewable timetable item", async () => {
const db = createDb();
const baseUrl = "http://timetable.internal:8080";
@@ -89,14 +105,161 @@ describe("timetableRouter source authorization", () => {
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("allows board modifiers to search an unsaved self-hosted endpoint", async () => {
it("allows board modifiers to search an unsaved public endpoint", async () => {
const db = createDb();
const baseUrl = "http://timetable.internal:8080";
const baseUrl = "https://timetable.example.com";
const { boardId, ownerId } = await createTimetableItemAsync(db, { baseUrl: "https://search.ch", isPublic: false });
const caller = createCaller(db, createSession(ownerId));
await caller.searchStations({ baseUrl, boardId, query: "Bern" });
expect(mocks.searchStations).toHaveBeenLastCalledWith(expect.objectContaining({ baseUrl }));
expect(mocks.lookup).toHaveBeenCalledWith("timetable.example.com", { all: true, verbatim: true });
expect(mocks.searchStations).toHaveBeenLastCalledWith(
expect.objectContaining({ baseUrl, pinnedAddresses: [{ address: "8.8.8.8", family: 4 }] }),
);
});
it.each([
"http://127.0.0.1:8080",
"http://timetable.internal:8080",
"http://[::1]:8080",
"http://[::ffff:127.0.0.1]:8080",
"http://[fd00::1]:8080",
"http://[64:ff9b::7f00:1]:8080",
"http://[2002:7f00:1::]:8080",
])("blocks an unsaved private endpoint %s even for board modifiers", async (baseUrl) => {
const db = createDb();
const { boardId, ownerId } = await createTimetableItemAsync(db, {
baseUrl: "https://search.ch",
isPublic: false,
});
const caller = createCaller(db, createSession(ownerId));
await expect(caller.searchStations({ baseUrl, boardId, query: "Bern" })).rejects.toMatchObject({
code: "BAD_REQUEST",
});
expect(mocks.searchStations).not.toHaveBeenCalled();
});
it("blocks an RFC 2765 IPv4-translated loopback address", async () => {
const db = createDb();
const { boardId, ownerId } = await createTimetableItemAsync(db, {
baseUrl: "https://search.ch",
isPublic: false,
});
const caller = createCaller(db, createSession(ownerId));
await expect(
caller.searchStations({ baseUrl: "http://[::ffff:0:7f00:1]:8080", boardId, query: "Bern" }),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(mocks.searchStations).not.toHaveBeenCalled();
});
it("blocks an unsaved public hostname that resolves to a private address", async () => {
mocks.lookup.mockResolvedValueOnce([{ address: "10.0.0.8", family: 4 }]);
const db = createDb();
const { boardId, ownerId } = await createTimetableItemAsync(db, {
baseUrl: "https://search.ch",
isPublic: false,
});
const caller = createCaller(db, createSession(ownerId));
await expect(
caller.searchStations({ baseUrl: "https://timetable.example.com", boardId, query: "Bern" }),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(mocks.searchStations).not.toHaveBeenCalled();
});
it("allows the exact saved private endpoint when item and board IDs match", async () => {
const db = createDb();
const baseUrl = "http://timetable.internal:8080";
const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, { baseUrl, isPublic: false });
await createCaller(db, createSession(ownerId)).searchStations({ baseUrl, boardId, itemId, query: "Bern" });
expect(mocks.lookup).not.toHaveBeenCalled();
expect(mocks.searchStations).toHaveBeenLastCalledWith(
expect.objectContaining({ baseUrl, pinnedAddresses: undefined }),
);
});
it("rejects an item ID that does not belong to the requested board", async () => {
const db = createDb();
const first = await createTimetableItemAsync(db, { baseUrl: "http://timetable.internal:8080", isPublic: false });
const second = await createTimetableItemAsync(db, { baseUrl: "https://search.ch", isPublic: false });
await expect(
createCaller(db, createSession(second.ownerId)).searchStations({
baseUrl: "http://timetable.internal:8080",
boardId: second.boardId,
itemId: first.itemId,
query: "Bern",
}),
).rejects.toMatchObject({ code: "NOT_FOUND" });
expect(mocks.searchStations).not.toHaveBeenCalled();
});
it("blocks a changed private endpoint for an existing item", async () => {
const db = createDb();
const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, {
baseUrl: "http://timetable.internal:8080",
isPublic: false,
});
await expect(
createCaller(db, createSession(ownerId)).searchStations({
baseUrl: "http://127.0.0.1:8080",
boardId,
itemId,
query: "Bern",
}),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(mocks.searchStations).not.toHaveBeenCalled();
});
it("allows a changed public endpoint for an existing item and pins its address", async () => {
const db = createDb();
const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, {
baseUrl: "http://timetable.internal:8080",
isPublic: false,
});
const baseUrl = "https://timetable.example.com";
await createCaller(db, createSession(ownerId)).searchStations({ baseUrl, boardId, itemId, query: "Bern" });
expect(mocks.searchStations).toHaveBeenLastCalledWith(
expect.objectContaining({ baseUrl, pinnedAddresses: [{ address: "8.8.8.8", family: 4 }] }),
);
});
it("reports malformed saved widget options as server data corruption", async () => {
const db = createDb();
const { itemId } = await createTimetableItemAsync(db, { baseUrl: "https://search.ch" });
await db.update(items).set({ options: "{" }).where(eq(items.id, itemId));
await expect(
createCaller(db, null).getTimetable({
baseUrl: "https://search.ch",
itemId,
stationId: "station",
limit: 10,
}),
).rejects.toMatchObject({ code: "INTERNAL_SERVER_ERROR", message: "Timetable widget configuration is invalid" });
});
it("reports an invalid saved timetable URL as server data corruption", async () => {
const db = createDb();
const { itemId } = await createTimetableItemAsync(db, {
baseUrl: "https://search.ch?redirect=/admin",
});
await expect(
createCaller(db, null).getTimetable({
baseUrl: "https://search.ch",
itemId,
stationId: "station",
limit: 10,
}),
).rejects.toMatchObject({ code: "INTERNAL_SERVER_ERROR", message: "Timetable widget configuration is invalid" });
});
it("does not treat public board view access as configuration access", async () => {