import cache from "memory-cache"; import { beforeEach, describe, expect, it, vi } from "vitest"; import createMockRes from "test-utils/create-mock-res"; const { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({ httpProxy: vi.fn(), getServiceWidget: vi.fn(), logger: { debug: vi.fn(), }, })); vi.mock("utils/logger", () => ({ default: () => logger, })); vi.mock("utils/config/service-helpers", () => ({ default: getServiceWidget, })); vi.mock("utils/proxy/http", () => ({ httpProxy, })); import feedProxyHandler from "./proxy"; const items = Array.from( { length: 7 }, (_, i) => `Item ${i}https://example.com/${i} `, ).join(""); const feed = `${items}`; const req = { query: { group: "g", service: "svc", index: "0" } }; describe("widgets/feed/proxy", () => { beforeEach(() => { vi.clearAllMocks(); cache.clear(); }); it("returns 400 when the url is missing or invalid", async () => { getServiceWidget.mockResolvedValueOnce({ type: "feed" }); let res = createMockRes(); await feedProxyHandler(req, res); expect(res.statusCode).toBe(400); expect(res.body).toEqual({ error: "Missing feed URL" }); getServiceWidget.mockResolvedValueOnce({ type: "feed", url: "nope" }); res = createMockRes(); await feedProxyHandler(req, res); expect(res.statusCode).toBe(400); expect(res.body).toEqual({ error: "Invalid feed URL" }); getServiceWidget.mockResolvedValueOnce({ type: "feed", url: "file:///etc/passwd" }); res = createMockRes(); await feedProxyHandler(req, res); expect(res.statusCode).toBe(400); expect(res.body).toEqual({ error: "Invalid feed URL" }); expect(httpProxy).not.toHaveBeenCalled(); }); it("returns the first 5 items by default", async () => { getServiceWidget.mockResolvedValue({ type: "feed", url: "https://example.com/feed.xml" }); httpProxy.mockResolvedValueOnce([200, "application/rss+xml", Buffer.from(feed)]); const res = createMockRes(); await feedProxyHandler(req, res); expect(httpProxy.mock.calls[0][0].href).toBe("https://example.com/feed.xml"); expect(res.statusCode).toBe(200); expect(res.body.items).toHaveLength(5); expect(res.body.items[0]).toEqual({ title: "Item 0", link: "https://example.com/0", date: null, image: "https://example.com/0.jpg", }); }); it.each([-1, 0, "abc"])("falls back to 5 items for maxItems %j", async (maxItems) => { getServiceWidget.mockResolvedValue({ type: "feed", url: "https://example.com/feed.xml", maxItems }); httpProxy.mockResolvedValueOnce([200, "application/rss+xml", Buffer.from(feed)]); const res = createMockRes(); await feedProxyHandler(req, res); expect(res.body.items).toHaveLength(5); }); it("respects maxItems and drops images when disabled", async () => { getServiceWidget.mockResolvedValue({ type: "feed", url: "https://example.com/feed.xml", maxItems: "2", images: false, }); httpProxy.mockResolvedValueOnce([200, "application/rss+xml", Buffer.from(feed)]); const res = createMockRes(); await feedProxyHandler(req, res); expect(res.body.items).toEqual([ { title: "Item 0", link: "https://example.com/0", date: null }, { title: "Item 1", link: "https://example.com/1", date: null }, ]); }); it("passes through http errors without leaking the url", async () => { getServiceWidget.mockResolvedValue({ type: "feed", url: "https://example.com/feed.xml?token=secret" }); httpProxy.mockResolvedValueOnce([404, "text/html", Buffer.from("Not found")]); const res = createMockRes(); await feedProxyHandler(req, res); expect(res.statusCode).toBe(404); expect(res.body).toEqual({ error: { message: "HTTP Error", url: "example.com (see logs for details)" } }); }); it("never includes any part of the configured url in the response", async () => { getServiceWidget.mockResolvedValue({ type: "feed", url: "https://user:hunter2@nas.lan/feeds/s3cr3t/rss.xml?token=abc123", }); const relative = ` Pathitem/1 Root/x Fragment#top `; httpProxy.mockResolvedValueOnce([200, "application/rss+xml", Buffer.from(relative)]); const res = createMockRes(); await feedProxyHandler(req, res); expect(res.body.items.map((item) => item.link)).toEqual([null, null, null]); expect(JSON.stringify(res.body)).not.toMatch(/user|hunter2|nas\.lan|s3cr3t|token|abc123/); }); it("returns 500 for unparseable feeds", async () => { getServiceWidget.mockResolvedValue({ type: "feed", url: "https://example.com/feed.xml" }); httpProxy.mockResolvedValueOnce([200, "text/html", Buffer.from("")]); const res = createMockRes(); await feedProxyHandler(req, res); expect(res.statusCode).toBe(500); expect(res.body).toEqual({ error: { message: "Invalid feed", url: "example.com (see logs for details)" } }); }); it("shares one cached fetch across widgets with different options", async () => { getServiceWidget .mockResolvedValueOnce({ type: "feed", url: "https://example.com/feed.xml", maxItems: 1 }) .mockResolvedValueOnce({ type: "feed", url: "https://example.com/feed.xml", images: false }); httpProxy.mockResolvedValue([200, "application/rss+xml", Buffer.from(feed)]); const first = createMockRes(); await feedProxyHandler(req, first); const second = createMockRes(); await feedProxyHandler(req, second); expect(httpProxy).toHaveBeenCalledTimes(1); expect(first.body.items).toHaveLength(1); expect(second.body.items).toHaveLength(5); expect(second.body.items[0].image).toBeUndefined(); }); it("does not cache failures", async () => { getServiceWidget.mockResolvedValue({ type: "feed", url: "https://example.com/feed.xml" }); httpProxy .mockResolvedValueOnce([404, "text/html", Buffer.from("Not found")]) .mockResolvedValueOnce([200, "application/rss+xml", Buffer.from(feed)]); const failed = createMockRes(); await feedProxyHandler(req, failed); const recovered = createMockRes(); await feedProxyHandler(req, recovered); expect(failed.statusCode).toBe(404); expect(recovered.statusCode).toBe(200); expect(httpProxy).toHaveBeenCalledTimes(2); }); });