Seeds now insert generic Demo User personas (INSERT OR IGNORE, so existing databases keep their current profile data). Drops the every-boot UPDATE backfills that re-asserted personal values, and removes the personal cron env example templates in favor of the generic scripts/jobber-cron.env.example.
229 lines
7.6 KiB
TypeScript
229 lines
7.6 KiB
TypeScript
import type { Server } from "node:http";
|
|
import { gzipSync } from "node:zlib";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { startServer, stopServer } from "./test-utils";
|
|
|
|
describe.sequential("Stats proxy routes", () => {
|
|
let server: Server;
|
|
let baseUrl: string;
|
|
let closeDb: () => void;
|
|
let tempDir: string;
|
|
|
|
beforeEach(async () => {
|
|
({ server, baseUrl, closeDb, tempDir } = await startServer());
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.unstubAllGlobals();
|
|
await stopServer({ server, closeDb, tempDir });
|
|
});
|
|
|
|
it("proxies the umami script through the first-party stats route", async () => {
|
|
const realFetch = global.fetch;
|
|
const mockFetch = vi.fn(
|
|
async (input: URL | RequestInfo, init?: RequestInit) => {
|
|
const url = typeof input === "string" ? input : input.toString();
|
|
if (url === "https://umami.dakheera47.com/script.js") {
|
|
expect(init?.method).toBe("GET");
|
|
return new Response(gzipSync("console.log('umami')"), {
|
|
status: 200,
|
|
headers: {
|
|
"content-type": "application/javascript; charset=utf-8",
|
|
"cache-control": "public, max-age=60",
|
|
"content-encoding": "gzip",
|
|
},
|
|
});
|
|
}
|
|
return realFetch(input, init);
|
|
},
|
|
);
|
|
vi.stubGlobal("fetch", mockFetch);
|
|
|
|
const response = await fetch(`${baseUrl}/stats/script.js`);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("content-type")).toContain(
|
|
"application/javascript",
|
|
);
|
|
expect(response.headers.get("cache-control")).toBe("public, max-age=60");
|
|
expect(response.headers.get("content-encoding")).toBe("gzip");
|
|
expect(await response.text()).toContain("umami");
|
|
});
|
|
|
|
it("forwards tracking requests to the umami upstream", async () => {
|
|
const realFetch = global.fetch;
|
|
const mockFetch = vi.fn(
|
|
async (input: URL | RequestInfo, init?: RequestInit) => {
|
|
const url = typeof input === "string" ? input : input.toString();
|
|
if (url === "https://umami.dakheera47.com/api/send?foo=bar") {
|
|
expect(init?.method).toBe("POST");
|
|
expect(init?.headers).toBeInstanceOf(Headers);
|
|
const headers = init?.headers as Headers;
|
|
expect(headers.get("content-type")).toBe("application/json");
|
|
expect(headers.get("authorization")).toBeNull();
|
|
expect(headers.get("cookie")).toBeNull();
|
|
expect(headers.get("x-forwarded-for")).toBeNull();
|
|
const normalizedBody = init?.body
|
|
? await new Response(init.body as BodyInit).text()
|
|
: "";
|
|
expect(normalizedBody).toBe('{"type":"event"}');
|
|
return new Response(null, { status: 202 });
|
|
}
|
|
return realFetch(input, init);
|
|
},
|
|
);
|
|
vi.stubGlobal("fetch", mockFetch);
|
|
|
|
const response = await fetch(`${baseUrl}/stats/api/send?foo=bar`, {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: "Basic abc123",
|
|
cookie: "session=secret",
|
|
"content-type": "application/json",
|
|
"x-forwarded-for": "10.0.0.1",
|
|
},
|
|
body: JSON.stringify({ type: "event" }),
|
|
});
|
|
|
|
expect(response.status).toBe(202);
|
|
});
|
|
|
|
it("returns 404 for non-allowlisted stats routes", async () => {
|
|
const realFetch = global.fetch;
|
|
const mockFetch = vi.fn(
|
|
async (input: URL | RequestInfo, init?: RequestInit) =>
|
|
realFetch(input, init),
|
|
);
|
|
vi.stubGlobal("fetch", mockFetch);
|
|
|
|
const response = await fetch(`${baseUrl}/stats`);
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(mockFetch).not.toHaveBeenCalledWith(
|
|
"https://umami.dakheera47.com/",
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("returns 405 for unsupported methods on allowlisted stats routes", async () => {
|
|
const realFetch = global.fetch;
|
|
const mockFetch = vi.fn(
|
|
async (input: URL | RequestInfo, init?: RequestInit) =>
|
|
realFetch(input, init),
|
|
);
|
|
vi.stubGlobal("fetch", mockFetch);
|
|
|
|
const response = await fetch(`${baseUrl}/stats/script.js`, {
|
|
method: "POST",
|
|
body: "unexpected",
|
|
});
|
|
|
|
expect(response.status).toBe(405);
|
|
expect(response.headers.get("allow")).toBe("GET, HEAD");
|
|
expect(await response.text()).toBe("Method not allowed");
|
|
expect(mockFetch).not.toHaveBeenCalledWith(
|
|
"https://umami.dakheera47.com/script.js",
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("returns a sanitized upstream failure response", async () => {
|
|
const realFetch = global.fetch;
|
|
const mockFetch = vi.fn(
|
|
async (input: URL | RequestInfo, init?: RequestInit) => {
|
|
const url = typeof input === "string" ? input : input.toString();
|
|
if (url === "https://umami.dakheera47.com/script.js") {
|
|
throw new Error("upstream down");
|
|
}
|
|
return realFetch(input, init);
|
|
},
|
|
);
|
|
vi.stubGlobal("fetch", mockFetch);
|
|
|
|
const response = await fetch(`${baseUrl}/stats/script.js`);
|
|
|
|
expect(response.status).toBe(502);
|
|
expect(await response.text()).toBe("Upstream error");
|
|
});
|
|
|
|
it("returns 504 when the umami upstream times out", async () => {
|
|
const realFetch = global.fetch;
|
|
const mockFetch = vi.fn(
|
|
async (input: URL | RequestInfo, init?: RequestInit) => {
|
|
const url = typeof input === "string" ? input : input.toString();
|
|
if (url === "https://umami.dakheera47.com/script.js") {
|
|
return await new Promise<Response>((_resolve, reject) => {
|
|
init?.signal?.addEventListener("abort", () => {
|
|
reject(
|
|
new DOMException("The operation timed out", "TimeoutError"),
|
|
);
|
|
});
|
|
});
|
|
}
|
|
return realFetch(input, init);
|
|
},
|
|
);
|
|
vi.stubGlobal("fetch", mockFetch);
|
|
|
|
const response = await fetch(`${baseUrl}/stats/script.js`);
|
|
|
|
expect(response.status).toBe(504);
|
|
expect(await response.text()).toBe("Upstream timeout");
|
|
});
|
|
|
|
it("allows stats proxy requests when basic auth is enabled", async () => {
|
|
await stopServer({ server, closeDb, tempDir });
|
|
({ server, baseUrl, closeDb, tempDir } = await startServer({
|
|
env: {
|
|
BASIC_AUTH_USER: "demo",
|
|
BASIC_AUTH_PASSWORD: "secret",
|
|
},
|
|
}));
|
|
|
|
const realFetch = global.fetch;
|
|
const mockFetch = vi.fn(
|
|
async (input: URL | RequestInfo, init?: RequestInit) => {
|
|
const url = typeof input === "string" ? input : input.toString();
|
|
if (url === "https://umami.dakheera47.com/script.js") {
|
|
return new Response("ok", {
|
|
status: 200,
|
|
headers: { "content-type": "application/javascript" },
|
|
});
|
|
}
|
|
return realFetch(input, init);
|
|
},
|
|
);
|
|
vi.stubGlobal("fetch", mockFetch);
|
|
|
|
const response = await fetch(`${baseUrl}/stats/script.js`);
|
|
|
|
expect(response.status).toBe(200);
|
|
});
|
|
|
|
it("does not add CORS headers to stats proxy responses", async () => {
|
|
const realFetch = global.fetch;
|
|
const mockFetch = vi.fn(
|
|
async (input: URL | RequestInfo, init?: RequestInit) => {
|
|
const url = typeof input === "string" ? input : input.toString();
|
|
if (url === "https://umami.dakheera47.com/script.js") {
|
|
return new Response("ok", {
|
|
status: 200,
|
|
headers: { "content-type": "application/javascript" },
|
|
});
|
|
}
|
|
return realFetch(input, init);
|
|
},
|
|
);
|
|
vi.stubGlobal("fetch", mockFetch);
|
|
|
|
const response = await fetch(`${baseUrl}/stats/script.js`, {
|
|
headers: {
|
|
origin: "https://evil.example",
|
|
},
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("access-control-allow-origin")).toBeNull();
|
|
});
|
|
});
|