Initial Watermaps import
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DEFAULT_LIMIT,
|
||||
MAX_HTML_BYTES,
|
||||
assertPublicHttpUrl,
|
||||
buildEnrichmentRecord,
|
||||
createHostLimiter,
|
||||
createPinnedLookup,
|
||||
extractContactsFromHtml,
|
||||
fetchHtmlPage,
|
||||
parseBooleanDefault,
|
||||
runWebsiteEnrichment,
|
||||
} from "../../../scripts/enrich-marine-websites.mjs";
|
||||
|
||||
const publicDns = async () => [{ address: "93.184.216.34", family: 4 }];
|
||||
|
||||
function htmlResponse(html, init = {}) {
|
||||
return new Response(html, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
function candidate(overrides = {}) {
|
||||
return {
|
||||
id: "42",
|
||||
layer: "harbours",
|
||||
source: "osm",
|
||||
sourceId: "w123",
|
||||
name: "Testhafen",
|
||||
properties: { website: "https://marina.example/contact" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("marine facility website enrichment", () => {
|
||||
it("defaults to dry-run and validates explicit boolean values", () => {
|
||||
expect(DEFAULT_LIMIT).toBe(25);
|
||||
expect(parseBooleanDefault(undefined)).toBe(true);
|
||||
expect(parseBooleanDefault("false")).toBe(false);
|
||||
expect(() => parseBooleanDefault("maybe")).toThrow(/true oder false/u);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"http://127.0.0.1/admin",
|
||||
"http://[::1]/admin",
|
||||
"http://localhost/admin",
|
||||
"http://service.local/admin",
|
||||
])("blocks local URL %s before fetching", async (url) => {
|
||||
await expect(assertPublicHttpUrl(url, { lookupImpl: publicDns })).rejects.toMatchObject({
|
||||
name: "WebsiteEnrichmentError",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a public hostname if any DNS result is private", async () => {
|
||||
const lookupImpl = vi.fn(async () => [
|
||||
{ address: "93.184.216.34", family: 4 },
|
||||
{ address: "10.0.0.8", family: 4 },
|
||||
]);
|
||||
await expect(
|
||||
assertPublicHttpUrl("https://marina.example", { lookupImpl }),
|
||||
).rejects.toMatchObject({ code: "SSRF_BLOCKED_DNS" });
|
||||
});
|
||||
|
||||
it("pins the socket lookup to the already validated DNS addresses", async () => {
|
||||
const lookup = createPinnedLookup([{ address: "93.184.216.34", family: 4 }]);
|
||||
const addresses = await new Promise((resolve, reject) => {
|
||||
lookup("a-second-dns-name.example", { all: true }, (error, result) => {
|
||||
if (error) reject(error);
|
||||
else resolve(result);
|
||||
});
|
||||
});
|
||||
expect(addresses).toEqual([{ address: "93.184.216.34", family: 4 }]);
|
||||
expect(() => createPinnedLookup([{ address: "127.0.0.1", family: 4 }])).toThrow(
|
||||
/gepinnt/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("checks a redirect target again and never requests a private redirect", async () => {
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: "http://169.254.169.254/latest/meta-data" },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
fetchHtmlPage("https://marina.example", {
|
||||
fetchImpl,
|
||||
lookupImpl: publicDns,
|
||||
beforeRequest: async () => {},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "SSRF_BLOCKED_IP" });
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
expect(fetchImpl.mock.calls[0][1]).toMatchObject({ redirect: "manual" });
|
||||
});
|
||||
|
||||
it("requires HTML and stops streamed responses above 512 KiB", async () => {
|
||||
await expect(
|
||||
fetchHtmlPage("https://marina.example/file.pdf", {
|
||||
lookupImpl: publicDns,
|
||||
beforeRequest: async () => {},
|
||||
fetchImpl: async () =>
|
||||
new Response("pdf", { status: 200, headers: { "content-type": "application/pdf" } }),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "UNSUPPORTED_CONTENT_TYPE" });
|
||||
|
||||
await expect(
|
||||
fetchHtmlPage("https://marina.example/huge", {
|
||||
lookupImpl: publicDns,
|
||||
beforeRequest: async () => {},
|
||||
fetchImpl: async () => htmlResponse("x".repeat(MAX_HTML_BYTES + 1)),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "BODY_TOO_LARGE" });
|
||||
});
|
||||
|
||||
it("aborts a hanging HTTP request at the configured timeout", async () => {
|
||||
const fetchImpl = async (_url, init) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init.signal.addEventListener("abort", () => reject(init.signal.reason), { once: true });
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchHtmlPage("https://marina.example/hangs", {
|
||||
fetchImpl,
|
||||
lookupImpl: publicDns,
|
||||
beforeRequest: async () => {},
|
||||
timeoutMs: 5,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "FETCH_FAILED" });
|
||||
});
|
||||
|
||||
it("also bounds a hanging DNS lookup", async () => {
|
||||
const fetchImpl = vi.fn();
|
||||
await expect(
|
||||
fetchHtmlPage("https://marina.example/hangs", {
|
||||
fetchImpl,
|
||||
lookupImpl: async () => new Promise(() => {}),
|
||||
beforeRequest: async () => {},
|
||||
timeoutMs: 5,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "DNS_TIMEOUT" });
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prefers JSON-LD and otherwise accepts only tel/mailto links", () => {
|
||||
const html = `
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Marina",
|
||||
"name": "Hafenbetrieb Musterstadt",
|
||||
"telephone": "+49 201 11111",
|
||||
"email": "hafen@example.test",
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"streetAddress": "Ufer 1",
|
||||
"postalCode": "12345",
|
||||
"addressLocality": "Musterstadt",
|
||||
"addressCountry": "DE"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<a href="tel:+49-201-99999">Alternative</a>
|
||||
<a href="mailto:other@example.test">Alternative</a>
|
||||
<p>Telefon 01234 567890</p>
|
||||
`;
|
||||
|
||||
expect(extractContactsFromHtml(html)).toEqual({
|
||||
phone: "+49 201 11111",
|
||||
email: "hafen@example.test",
|
||||
operator: "Hafenbetrieb Musterstadt",
|
||||
address: "Ufer 1, 12345 Musterstadt, DE",
|
||||
});
|
||||
expect(extractContactsFromHtml("<p>Telefon 01234 567890</p>")).toEqual({
|
||||
phone: null,
|
||||
email: null,
|
||||
operator: null,
|
||||
address: null,
|
||||
});
|
||||
expect(
|
||||
extractContactsFromHtml(
|
||||
'<a href="tel:0049%20201%20777">Anrufen</a><a href="mailto:lock%40example.test">Mail</a>',
|
||||
),
|
||||
).toMatchObject({ phone: "+49 201 777", email: "lock@example.test" });
|
||||
expect(
|
||||
extractContactsFromHtml('<a title="some href=\'tel:+49999\'">Kein Kontaktlink</a>'),
|
||||
).toMatchObject({ phone: null });
|
||||
expect(extractContactsFromHtml('<a href="tel:+49201�">X</a>')).toMatchObject({
|
||||
phone: null,
|
||||
});
|
||||
expect(
|
||||
extractContactsFromHtml(
|
||||
'<script type="application/ld+json">{"@type":"Marina","name":"Hafen\\u0000","email":"bad@example.test\\u0000"}</script>',
|
||||
),
|
||||
).toMatchObject({ email: null, operator: null });
|
||||
expect(
|
||||
extractContactsFromHtml(
|
||||
'<script type="application/ld+json">{"@type":"Organization","name":"Schleusenbetrieb Nord"}</script><a href="tel:+49-201-555">Telefon</a>',
|
||||
),
|
||||
).toMatchObject({ phone: "+49-201-555", operator: "Schleusenbetrieb Nord" });
|
||||
});
|
||||
|
||||
it("keeps existing contact values and only fills missing fields", () => {
|
||||
const record = buildEnrichmentRecord({
|
||||
candidate: candidate({
|
||||
properties: {
|
||||
website: "https://marina.example/contact",
|
||||
phone: "+49 201 100",
|
||||
},
|
||||
enrichmentProperties: { operator: "Vorhandener Hafenbetreiber" },
|
||||
}),
|
||||
website: {
|
||||
key: "website",
|
||||
original: "https://marina.example/contact",
|
||||
url: "https://marina.example/contact",
|
||||
},
|
||||
page: { finalUrl: "https://marina.example/kontakt" },
|
||||
extracted: {
|
||||
phone: "+49 201 999",
|
||||
email: "hafen@example.test",
|
||||
},
|
||||
fetchedAt: "2026-07-20T11:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(record).toMatchObject({
|
||||
sourceId: "osm:w123",
|
||||
properties: {
|
||||
website: "https://marina.example/contact",
|
||||
phone: "+49 201 100",
|
||||
email: "hafen@example.test",
|
||||
operator: "Vorhandener Hafenbetreiber",
|
||||
source_url: "https://marina.example/kontakt",
|
||||
fetchedAt: "2026-07-20T11:00:00.000Z",
|
||||
enriched_fields: ["email"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("spaces starts to the same host while allowing a deterministic injected clock", async () => {
|
||||
let currentTime = 1_000;
|
||||
const waits = [];
|
||||
const limiter = createHostLimiter({
|
||||
delayMs: 500,
|
||||
now: () => currentTime,
|
||||
sleepImpl: async (milliseconds) => {
|
||||
waits.push(milliseconds);
|
||||
currentTime += milliseconds;
|
||||
},
|
||||
});
|
||||
|
||||
await limiter(new URL("https://marina.example/one"));
|
||||
await limiter(new URL("https://marina.example/two"));
|
||||
await limiter(new URL("https://other.example/one"));
|
||||
expect(waits).toEqual([500]);
|
||||
});
|
||||
|
||||
it("does not invoke the database writer during the default dry-run", async () => {
|
||||
const writer = vi.fn();
|
||||
const fetchImpl = vi.fn(async (_url, init) => {
|
||||
expect(init.headers["User-Agent"]).toContain("Watermaps");
|
||||
return htmlResponse('<a href="tel:+49-201-12345">Schleuse anrufen</a>');
|
||||
});
|
||||
|
||||
const result = await runWebsiteEnrichment({
|
||||
candidates: [
|
||||
candidate(),
|
||||
candidate({ id: "43", sourceId: "w124", properties: { website: "https://other.example" } }),
|
||||
],
|
||||
limit: 1,
|
||||
writer,
|
||||
enrichmentOptions: {
|
||||
fetchImpl,
|
||||
lookupImpl: publicDns,
|
||||
hostDelayMs: 0,
|
||||
fetchedAt: "2026-07-20T11:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.dryRun).toBe(true);
|
||||
expect(result.records).toHaveLength(1);
|
||||
expect(result.records[0]).toMatchObject({
|
||||
originalId: "42",
|
||||
sourceId: "osm:w123",
|
||||
properties: { phone: "+49-201-12345" },
|
||||
});
|
||||
expect(writer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user