src/map-renderer/resources.test.ts
Rama main · réplica de lectura del repositorio original.
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { basemapOrigins } from "@/lib/basemap";
import {
createResources,
describe as describeUrl,
ResourceError,
USER_AGENT,
} from "./resources";
import { readRendererConfig } from "./style";
const SITE_URL = "https://dives.example.com";
// What `basemapOrigins` would hand the renderer for a basemap on these hosts.
const ORIGINS = [
"https://tiles.example.com",
"https://down.example.com",
"https://t.example",
];
function publicDir() {
const dir = mkdtempSync(path.join(tmpdir(), "map-renderer-"));
mkdirSync(path.join(dir, "basemap", "sprite"), { recursive: true });
writeFileSync(path.join(dir, "basemap", "sprite", "ofm.json"), "{}");
writeFileSync(path.join(dir, "secret.txt"), "not served");
return dir;
}
function stubFetch(answer: (url: string) => Response | Promise<Response>) {
return vi.fn(async (input: string | URL | Request) =>
answer(input instanceof Request ? input.url : String(input)),
);
}
const bytes = (text: string) => new Response(text, { status: 200 });
describe("resources", () => {
it("names the instance to the basemap as its own pages do", async () => {
const fetch = stubFetch(() => bytes("tile"));
const resources = createResources({
siteUrl: SITE_URL,
origins: ORIGINS,
publicDir: publicDir(),
fetch,
});
await resources.get("https://tiles.example.com/planet/1/0/0.pbf");
const [, init] = fetch.mock.calls[0] as unknown as [string, RequestInit];
expect(init.headers).toEqual({
Origin: "https://dives.example.com",
Referer: "https://dives.example.com/",
"User-Agent": USER_AGENT,
});
});
// An error fails the whole render, which is right for a provider that is
// down and wrong for a source tile with nothing in it.
it("reads a 404 or a 204 as no content, not as an error", async () => {
const resources = createResources({
siteUrl: SITE_URL,
origins: ORIGINS,
publicDir: publicDir(),
fetch: stubFetch((url) =>
url.endsWith("empty.pbf")
? new Response(null, { status: 204 })
: new Response("Not found", { status: 404 }),
),
});
expect(await resources.get("https://tiles.example.com/empty.pbf")).toBe(
null,
);
expect(await resources.get("https://tiles.example.com/gone.pbf")).toBe(
null,
);
});
it("fails on any other answer, and on no answer at all", async () => {
const resources = createResources({
siteUrl: SITE_URL,
origins: ORIGINS,
publicDir: publicDir(),
fetch: stubFetch((url) => {
if (url.includes("down")) throw new TypeError("fetch failed");
return new Response("Busy", { status: 503 });
}),
});
await expect(
resources.get("https://tiles.example.com/busy.pbf"),
).rejects.toBeInstanceOf(ResourceError);
await expect(
resources.get("https://down.example.com/tile.pbf"),
).rejects.toBeInstanceOf(ResourceError);
});
it("never names a key it was given in what it says about a failure", async () => {
const resources = createResources({
siteUrl: SITE_URL,
origins: ORIGINS,
publicDir: publicDir(),
fetch: stubFetch(() => new Response("Denied", { status: 403 })),
});
const failure = resources.get(
"https://tiles.example.com/1/0/0.png?api_key=s3cret",
);
await expect(failure).rejects.toThrow(/tiles\.example\.com\/1\/0\/0\.png/);
await expect(failure).rejects.not.toThrow(/s3cret/);
expect(describeUrl("https://a.example/x?key=1")).toBe(
"https://a.example/x",
);
});
it("resolves a relative URL against SITE_URL, as a page does", async () => {
const fetch = stubFetch(() => bytes("{}"));
const resources = createResources({
siteUrl: SITE_URL,
origins: ORIGINS,
publicDir: publicDir(),
fetch,
});
await resources.get("/styles/mine.json");
expect(String(fetch.mock.calls[0][0])).toBe(
"https://dives.example.com/styles/mine.json",
);
});
it("reads this instance's /basemap/ from the image's own files", async () => {
const fetch = stubFetch(() => bytes("from the network"));
const resources = createResources({
siteUrl: SITE_URL,
origins: ORIGINS,
publicDir: publicDir(),
fetch,
});
const sprite = await resources.get(`${SITE_URL}/basemap/sprite/ofm.json`);
expect(sprite?.toString()).toBe("{}");
expect(await resources.get(`${SITE_URL}/basemap/sprite/none.png`)).toBe(
null,
);
// Nothing outside `basemap/`, however the path is spelled.
expect(await resources.get(`${SITE_URL}/basemap/%2E%2E%2Fsecret.txt`)).toBe(
null,
);
expect(fetch).not.toHaveBeenCalled();
});
it("serves a second request for the same URL from its cache", async () => {
const fetch = stubFetch(() => bytes("tile"));
const resources = createResources({
siteUrl: SITE_URL,
origins: ORIGINS,
publicDir: publicDir(),
fetch,
});
const url = "https://tiles.example.com/planet/9/1/1.pbf";
const [one, two] = await Promise.all([
resources.get(url),
resources.get(url),
]);
const three = await resources.get(url);
expect([one, two, three].map((data) => data?.toString())).toEqual([
"tile",
"tile",
"tile",
]);
expect(fetch).toHaveBeenCalledTimes(1);
});
it("keeps its cache within its budget, the least recent out first", async () => {
const fetch = stubFetch(() => bytes("x".repeat(400)));
const resources = createResources({
siteUrl: SITE_URL,
origins: ORIGINS,
publicDir: publicDir(),
fetch,
cacheBytes: 1000,
});
const url = (n: number) => `https://t.example/${n}.pbf`;
await resources.get(url(1));
await resources.get(url(2));
await resources.get(url(1)); // now the most recent
await resources.get(url(3)); // pushes 2 out
expect(fetch).toHaveBeenCalledTimes(3);
await resources.get(url(1));
expect(fetch).toHaveBeenCalledTimes(3);
await resources.get(url(2));
expect(fetch).toHaveBeenCalledTimes(4);
});
it("asks again for what it was told to keep only so long", async () => {
vi.useFakeTimers();
try {
const fetch = stubFetch(() => bytes("{}"));
const resources = createResources({
siteUrl: SITE_URL,
origins: ORIGINS,
publicDir: publicDir(),
fetch,
});
const tilejson = "https://tiles.example.com/planet";
await resources.get(tilejson, { maxAgeMs: 60_000 });
vi.advanceTimersByTime(59_000);
await resources.get(tilejson, { maxAgeMs: 60_000 });
expect(fetch).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(2_000);
await resources.get(tilejson, { maxAgeMs: 60_000 });
expect(fetch).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
it("refuses a scheme it has no business fetching", async () => {
const resources = createResources({
siteUrl: SITE_URL,
origins: ORIGINS,
publicDir: publicDir(),
fetch: stubFetch(() => bytes("")),
});
await expect(resources.get("file:///etc/passwd")).rejects.toBeInstanceOf(
ResourceError,
);
});
it("reads a data: URL, which is how a style that needs no network is given", async () => {
const resources = createResources({
siteUrl: SITE_URL,
origins: ORIGINS,
publicDir: publicDir(),
});
const data = await resources.get('data:application/json,{"version":8}');
expect(data?.toString()).toBe('{"version":8}');
});
// A style, TileJSON's `tiles` and a redirect are all the basemap host's to
// write, so none of them may send the renderer anywhere the browser could not
// be sent.
it.each([
"http://169.254.169.254/latest/meta-data/",
"http://localhost:6379/",
"https://tiles.example.com.evil.example/1/0/0.pbf",
"http://tiles.example.com/1/0/0.pbf",
])("refuses %s, which is not one of the basemap's origins", async (url) => {
const fetch = stubFetch(() => bytes("tile"));
const resources = createResources({
siteUrl: SITE_URL,
origins: ORIGINS,
publicDir: publicDir(),
fetch,
});
await expect(resources.get(url)).rejects.toBeInstanceOf(ResourceError);
expect(fetch).not.toHaveBeenCalled();
});
it("follows a redirect itself, within the basemap's origins", async () => {
const fetch = stubFetch((url) =>
url.endsWith("/planet")
? new Response(null, {
status: 302,
headers: { location: "/planet/v2" },
})
: bytes("tilejson"),
);
const resources = createResources({
siteUrl: SITE_URL,
origins: ORIGINS,
publicDir: publicDir(),
fetch,
});
const data = await resources.get("https://tiles.example.com/planet");
expect(data?.toString()).toBe("tilejson");
expect(fetch.mock.calls.map(([url]) => String(url))).toEqual([
"https://tiles.example.com/planet",
"https://tiles.example.com/planet/v2",
]);
for (const [, init] of fetch.mock.calls as unknown as [
string,
RequestInit,
][]) {
expect(init.redirect).toBe("manual");
}
});
it("refuses a redirect off the basemap's origins", async () => {
const fetch = stubFetch(
() =>
new Response(null, {
status: 302,
headers: { location: "http://169.254.169.254/latest/meta-data/" },
}),
);
const resources = createResources({
siteUrl: SITE_URL,
origins: ORIGINS,
publicDir: publicDir(),
fetch,
});
await expect(
resources.get("https://tiles.example.com/planet"),
).rejects.toThrow(/not one of the basemap's origins/);
expect(fetch).toHaveBeenCalledTimes(1);
});
it("gives up on a redirect that never lands", async () => {
const fetch = stubFetch(
() =>
new Response(null, { status: 302, headers: { location: "/planet" } }),
);
const resources = createResources({
siteUrl: SITE_URL,
origins: ORIGINS,
publicDir: publicDir(),
fetch,
});
await expect(
resources.get("https://tiles.example.com/planet"),
).rejects.toThrow(/Too many redirects/);
expect(fetch).toHaveBeenCalledTimes(6);
});
// A body the provider keeps sending. Counts what was pulled from it, so the
// assertions below can say it was abandoned rather than read to the end.
function endlessBody() {
const state = { pulled: 0, cancelled: false };
const body = new ReadableStream<Uint8Array>({
pull(controller) {
state.pulled += 1;
controller.enqueue(new Uint8Array(100));
},
cancel() {
state.cancelled = true;
},
});
return { body, state };
}
it("refuses a body that declares itself past the limit, unread", async () => {
const { body, state } = endlessBody();
const resources = createResources({
siteUrl: SITE_URL,
origins: ORIGINS,
publicDir: publicDir(),
maxBytes: 1000,
fetch: stubFetch(
() => new Response(body, { headers: { "content-length": "5000000" } }),
),
});
await expect(
resources.get("https://tiles.example.com/1/0/0.pbf"),
).rejects.toThrow(/more than 1000 bytes/);
expect(state.cancelled).toBe(true);
expect(state.pulled).toBeLessThanOrEqual(1);
});
it("stops reading a body once it runs past the limit", async () => {
const { body, state } = endlessBody();
const resources = createResources({
siteUrl: SITE_URL,
origins: ORIGINS,
publicDir: publicDir(),
maxBytes: 1000,
fetch: stubFetch(() => new Response(body)),
});
await expect(
resources.get("https://tiles.example.com/1/0/0.pbf"),
).rejects.toThrow(/more than 1000 bytes/);
expect(state.cancelled).toBe(true);
expect(state.pulled).toBeLessThan(20);
});
it("reads a body of exactly the limit", async () => {
const resources = createResources({
siteUrl: SITE_URL,
origins: ORIGINS,
publicDir: publicDir(),
maxBytes: 1000,
fetch: stubFetch(() => bytes("x".repeat(1000))),
});
const data = await resources.get("https://tiles.example.com/1/0/0.pbf");
expect(data?.length).toBe(1000);
});
});
// The renderer is configured as `main.ts` configures it, against the styles this
// app ships: every URL they name has to be one it may fetch, or the default
// basemap stops drawing the moment the list is wrong. A smoke test with a
// `data:` style needs no network and would not notice.
describe("the bundled styles", () => {
const config = readRendererConfig({ SITE_URL });
const publicRoot = path.resolve("public");
it.each(["liberty.json", "dark.json"])(
"%s names nothing the renderer refuses",
async (file) => {
const style = readFileSync(
path.join(publicRoot, "basemap", file),
"utf8",
);
const templates = style.match(/https?:\/\/[^"']+/g) ?? [];
const urls = templates.map((template) =>
template
.replace("{fontstack}", "Noto Sans Regular")
.replace("{range}", "0-255")
.replace("{z}", "1")
.replace("{x}", "0")
.replace("{y}", "0"),
);
const sprite = new URL(JSON.parse(style).sprite, SITE_URL).href;
expect(urls.length).toBeGreaterThan(0);
const fetch = stubFetch(() => bytes("ok"));
const resources = createResources({
siteUrl: config.siteUrl,
publicDir: publicRoot,
origins: basemapOrigins(config.basemap),
fetch,
});
for (const url of urls) {
expect(await resources.get(url)).not.toBeNull();
}
// Read from the image's own files, never fetched.
expect(await resources.get(`${sprite}.json`)).not.toBeNull();
expect(fetch).toHaveBeenCalledTimes(urls.length);
},
);
});