import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import type { SensorEndpoint } from "@websensor/core"; import { expandUrl, JsonListConnector } from "./jsonlist"; import { parseFeed, RssConnector } from "./rss"; import { parseSitemap } from "./sitemap"; import { StatuspageConnector } from "./statuspage"; import { NormalizeError } from "./types"; const here = dirname(fileURLToPath(import.meta.url)); const fx = (name: string): string => readFileSync(join(here, "..", "..", "..", "tests", "fixtures", name), "utf8"); const obs = (sensorId: string, body: string, contentType = "application/xml") => ({ sensorId, url: "https://example.com", fetchedAt: new Date("2026-09-08T12:00:00Z"), notModified: false, body: Buffer.from(body), meta: { status: 200, url: "https://example.com", finalUrl: "https://example.com", contentType, contentLength: body.length, etag: null, lastModified: null, durationMs: 10, redirects: 0, method: "GET" as const, headers: {} } }); const ep = (type: SensorEndpoint["type"], connector: string, config: Record = {}, state: Record | null = null): SensorEndpoint => ({ id: "t", sourceId: "acme", name: "t", url: "https://example.com", type, tier: "A", connector, config, state }); describe("feed parsing", () => { it("parses RSS 2.0 with GUIDs, dates and HTML descriptions", () => { const f = parseFeed(fx("rss-before.xml")); expect(f.kind).toBe("rss"); expect(f.items).toHaveLength(2); expect(f.items[0]).toMatchObject({ key: "post-2", title: "Second post", url: "https://acme.com/blog/second" }); expect(f.items[0]!.summary).toBe("Body of second post with bold text."); expect(f.items[0]!.publishedAt).toBe("2026-09-07T10:00:00.000Z"); }); it("parses Atom and JSON Feed", () => { const a = parseFeed(fx("atom.xml")); expect(a.kind).toBe("atom"); expect(a.items[0]).toMatchObject({ key: "tag:acme.com,2026:rel-2.1.0", title: "v2.1.0", url: "https://github.com/acme/tool/releases/tag/v2.1.0" }); const j = parseFeed('{"version":"https://jsonfeed.org/version/1.1","title":"J","items":[{"id":"1","url":"https://a.com/1","title":"One","date_published":"2026-09-01T00:00:00Z"}]}'); expect(j.kind).toBe("jsonfeed"); expect(j.items[0]!.publishedAt).toBe("2026-09-01T00:00:00.000Z"); }); it("rejects HTML masquerading as a feed", async () => { const c = new RssConnector(); await expect(c.normalize(ep("RSS", "rss"), obs("t", "nope", "text/html"))).rejects.toBeInstanceOf(NormalizeError); }); it("normalizes to a keyed list and tracks seen keys across runs", async () => { const c = new RssConnector(); const n1 = await c.normalize(ep("RSS", "rss"), obs("t", fx("rss-before.xml"))); expect(n1.mode).toBe("list"); expect(n1.items?.map((i) => i.key)).toEqual(["post-2", "post-1"]); const n2 = await c.normalize(ep("RSS", "rss", {}, n1.state ?? null), obs("t", fx("rss-after.xml"))); expect(n2.canonicalHash).not.toBe(n1.canonicalHash); expect((n2.state?.seenKeys as string[]).sort()).toEqual(["post-1", "post-2", "post-3"]); expect(n2.publishedAt?.toISOString()).toBe("2026-09-08T09:30:00.000Z"); }); }); describe("sitemap parsing", () => { it("parses url sets, indexes and news metadata", () => { const s = parseSitemap(fx("sitemap.xml")); expect(s.kind).toBe("urlset"); expect(s.entries).toHaveLength(3); expect(s.entries[0]).toMatchObject({ url: "https://acme.com/news/launch", lastmod: "2026-09-08" }); expect(s.entries[0]!.title).toBe("Acme launches Widget 2"); const idx = parseSitemap('https://acme.com/s1.xml'); expect(idx.kind).toBe("sitemapindex"); expect(idx.children).toEqual(["https://acme.com/s1.xml"]); expect(() => parseSitemap("")).toThrow(NormalizeError); }); }); describe("statuspage", () => { it("emits incidents, maintenances, degraded components and the overall indicator", async () => { const c = new StatuspageConnector(); const n = await c.normalize(ep("STATUSPAGE", "statuspage"), obs("t", fx("statuspage.json"), "application/json")); const keys = n.items!.map((i) => i.key); expect(keys).toEqual(expect.arrayContaining(["incident:inc1", "maintenance:m1", "component:c2", "overall"])); expect(keys).not.toContain("component:c1"); // operational components are not tracked expect(n.items!.find((i) => i.key === "incident:inc1")).toMatchObject({ status: "investigating", impact: "major" }); expect(n.extra).toMatchObject({ indicator: "major", activeIncidents: 1, degradedComponents: 1 }); }); }); describe("json list connector", () => { it("maps KEV-style records with templates and dot paths", async () => { const c = new JsonListConnector(); const n = await c.normalize(ep("REST_API", "jsonlist", { itemsPath: "vulnerabilities", keyField: "cveID", titleTemplate: "{cveID} — {vendorProject} {product}", summaryField: "shortDescription", dateField: "dateAdded", urlTemplate: "https://kev.example/{key}", compareFields: ["knownRansomwareCampaignUse"] }), obs("t", fx("kev.json"), "application/json")); expect(n.items).toHaveLength(2); expect(n.items![0]).toMatchObject({ key: "CVE-2026-0001", title: "CVE-2026-0001 — Acme Router", url: "https://kev.example/CVE-2026-0001", knownRansomwareCampaignUse: "Known" }); expect(n.publishedAt?.toISOString()).toBe("2026-09-08T00:00:00.000Z"); await expect(c.normalize(ep("REST_API", "jsonlist", { itemsPath: "nope" }), obs("t", "{}", "application/json"))).rejects.toThrow(/not an array/); }); it("expands time placeholders", () => { const u = expandUrl("https://api.example/cves?start={now-2h}&end={now}"); expect(u).toMatch(/start=\d{4}-\d{2}-\d{2}T\d{2}%3A\d{2}%3A\d{2}\.000&end=/); }); });