216 lines
7.7 KiB
TypeScript
216 lines
7.7 KiB
TypeScript
import type { Counter, Meter, MeterProvider } from "@opentelemetry/api";
|
|
import { describe, expect, it } from "@suaveplan/testing/runner";
|
|
import type { BundledAssetProbe } from "../assets/assets.js";
|
|
import { createScalarUiHandler } from "./handler.js";
|
|
|
|
describe("createScalarUiHandler", () => {
|
|
it("handler returns HTML referencing openapiUrl", { timeout: 5000 }, () => {
|
|
const bundle = createScalarUiHandler({ openapiUrl: "/openapi.json" });
|
|
const response = bundle.handler({
|
|
method: "GET",
|
|
path: "/docs/ui/scalar",
|
|
});
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers["Content-Type"]).toBe(
|
|
"text/html; charset=utf-8"
|
|
);
|
|
// The OpenAPI URL appears in the Scalar configuration block.
|
|
const occurrences = response.body.split("/openapi.json").length - 1;
|
|
expect(occurrences).toBe(1);
|
|
});
|
|
|
|
it("emits strict CSP — never permits inline scripts", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const { handler } = createScalarUiHandler({
|
|
openapiUrl: "/openapi.json",
|
|
assetMode: "cdn",
|
|
});
|
|
const response = handler({ method: "GET", path: "/docs/ui/scalar" });
|
|
const csp = response.headers["Content-Security-Policy"];
|
|
expect(csp).toBeDefined();
|
|
expect(csp).not.toContain("script-src 'self' 'unsafe-inline'");
|
|
expect(response.headers["X-Content-Type-Options"]).toBe("nosniff");
|
|
expect(response.headers["Referrer-Policy"]).toBe("no-referrer");
|
|
});
|
|
|
|
it("cdn mode whitelists the CDN origin in CSP", { timeout: 5000 }, () => {
|
|
const { handler } = createScalarUiHandler({
|
|
openapiUrl: "/openapi.json",
|
|
assetMode: "cdn",
|
|
cdnUrl: "https://cdn.example.com/scalar/api-reference",
|
|
});
|
|
const response = handler({ method: "GET", path: "/docs/ui/scalar" });
|
|
const csp = response.headers["Content-Security-Policy"] ?? "";
|
|
expect(csp).toContain("https://cdn.example.com");
|
|
});
|
|
|
|
it("bundled mode keeps CSP origin-restricted (no cdn host)", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const { handler } = createScalarUiHandler({
|
|
openapiUrl: "/openapi.json",
|
|
assetMode: "bundled",
|
|
});
|
|
const response = handler({ method: "GET", path: "/docs/ui/scalar" });
|
|
const csp = response.headers["Content-Security-Policy"] ?? "";
|
|
expect(csp).not.toContain("cdn.jsdelivr.net");
|
|
});
|
|
|
|
it("embeds caller-supplied nonce on inline script tag", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const { handler } = createScalarUiHandler({
|
|
openapiUrl: "/openapi.json",
|
|
});
|
|
const response = handler({
|
|
method: "GET",
|
|
path: "/docs/ui/scalar",
|
|
nonce: "n-1234567",
|
|
});
|
|
expect(response.body).toContain('nonce="n-1234567"');
|
|
});
|
|
|
|
it("is idempotent across requests", { timeout: 5000 }, () => {
|
|
const { handler } = createScalarUiHandler({
|
|
openapiUrl: "/openapi.json",
|
|
});
|
|
const a = handler({ method: "GET", path: "/docs/ui/scalar" });
|
|
const b = handler({ method: "GET", path: "/docs/ui/scalar" });
|
|
expect(a.body).toBe(b.body);
|
|
expect(a.headers).toEqual(b.headers);
|
|
});
|
|
|
|
it("openapiUrl carrying < is JSON-escaped inside the config block", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const { handler } = createScalarUiHandler({
|
|
openapiUrl: "/openapi.json?q=<script>",
|
|
});
|
|
const { body } = handler({
|
|
method: "GET",
|
|
path: "/docs/ui/scalar",
|
|
});
|
|
expect(body).not.toContain("?q=<script>");
|
|
expect(body).toContain("\\u003cscript>");
|
|
});
|
|
|
|
it("assetHandler serves bundled JS with cache headers", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const bundle = createScalarUiHandler({
|
|
openapiUrl: "/openapi.json",
|
|
assetMode: "bundled",
|
|
});
|
|
const response = bundle.assetHandler({
|
|
method: "GET",
|
|
path: `${bundle.options.uiEndpoint}/assets/scalar.js`,
|
|
});
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers["Content-Type"]).toBe(
|
|
"application/javascript; charset=utf-8"
|
|
);
|
|
expect(response.headers["Cache-Control"]).toBe(
|
|
"public, max-age=31536000, immutable"
|
|
);
|
|
});
|
|
|
|
it("assetHandler tolerates filename without prefix", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const bundle = createScalarUiHandler({
|
|
openapiUrl: "/openapi.json",
|
|
});
|
|
const response = bundle.assetHandler({
|
|
method: "GET",
|
|
path: "scalar.css",
|
|
});
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers["Content-Type"]).toBe(
|
|
"text/css; charset=utf-8"
|
|
);
|
|
});
|
|
|
|
it("assetHandler resolves trailing segment of any path", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const bundle = createScalarUiHandler({
|
|
openapiUrl: "/openapi.json",
|
|
});
|
|
const response = bundle.assetHandler({
|
|
method: "GET",
|
|
path: "/some/unrelated/path/scalar.js",
|
|
});
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers["Content-Type"]).toBe(
|
|
"application/javascript; charset=utf-8"
|
|
);
|
|
});
|
|
|
|
it("accepts no-arg invocation (defaults everywhere)", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const bundle = createScalarUiHandler();
|
|
expect(bundle.options.openapiUrl).toBe("/openapi.json");
|
|
});
|
|
|
|
it("exposes name + version + parsed options", { timeout: 5000 }, () => {
|
|
const bundle = createScalarUiHandler({ openapiUrl: "/openapi.json" });
|
|
expect(bundle.name).toBe("@suaveplan/api-openapi-ui-scalar");
|
|
expect(bundle.version).toBe("0.1.0");
|
|
expect(bundle.options.uiEndpoint).toBe("/docs/ui/scalar");
|
|
expect(bundle.runtimeUrl.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("absolute openapiUrl threads origin into connect-src", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const { handler } = createScalarUiHandler({
|
|
openapiUrl: "https://api.example.com/openapi.json",
|
|
});
|
|
const response = handler({
|
|
method: "GET",
|
|
path: "/docs/ui/scalar",
|
|
});
|
|
const csp = response.headers["Content-Security-Policy"] ?? "";
|
|
expect(csp).toContain("connect-src 'self' https://api.example.com");
|
|
});
|
|
|
|
it("honours caller-supplied bundledAssetProbe + meterProvider", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const observations: Array<{
|
|
value: number;
|
|
attributes: Record<string, unknown>;
|
|
}> = [];
|
|
const counter = {
|
|
add(value: number, attributes?: Record<string, unknown>): void {
|
|
observations.push({
|
|
value,
|
|
attributes: { ...(attributes ?? {}) },
|
|
});
|
|
},
|
|
} as unknown as Counter;
|
|
const meter = {
|
|
createCounter(): Counter {
|
|
return counter;
|
|
},
|
|
} as unknown as Meter;
|
|
const meterProvider = {
|
|
getMeter(): Meter {
|
|
return meter;
|
|
},
|
|
} as unknown as MeterProvider;
|
|
const probe: BundledAssetProbe = { hasBundle: () => true };
|
|
const bundle = createScalarUiHandler({
|
|
openapiUrl: "/openapi.json",
|
|
assetMode: "bundled",
|
|
meterProvider,
|
|
bundledAssetProbe: probe,
|
|
});
|
|
bundle.handler({ method: "GET", path: "/docs/ui/scalar" });
|
|
expect(observations.length).toBe(1);
|
|
expect(observations[0]?.attributes).toEqual({ ui: "scalar" });
|
|
});
|
|
});
|