Initial Commit
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
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" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user