Files
toolbox-sdk/packages/contract/test/contract.test.ts

305 lines
9.0 KiB
TypeScript

import {
ToolboxValidationError,
contextualizeToolboxLink,
discoverToolboxCatalog,
loadToolboxContext,
parseToolboxApp,
parseToolboxCatalog,
resolveWebUrl,
type ToolboxAppManifest,
} from "../src/index.js";
import { describe, expect, it, vi } from "vitest";
const app = (
overrides: Record<string, unknown> = {},
): Record<string, unknown> => ({
schemaVersion: 1,
id: "de.add-ideas.pdf-tools",
name: "PDF Workbench",
version: "1.2.3",
description: "Local PDF tools",
entry: "./index.html",
icon: "./icon.svg",
categories: ["documents", "pdf"],
tags: ["merge", "split"],
integration: {
contextVersion: 1,
launchModes: ["navigate", "new-tab"],
embedding: "unsupported",
},
requirements: {
secureContext: true,
workers: true,
indexedDb: true,
crossOriginIsolated: false,
},
privacy: {
processing: "local",
fileUploads: false,
telemetry: false,
},
source: {
repository: "https://git.example.test/pdf-tools",
license: "AGPL-3.0-only",
},
...overrides,
});
const catalog = (
overrides: Record<string, unknown> = {},
): Record<string, unknown> => ({
schemaVersion: 1,
id: "de.add-ideas.toolbox",
name: "Toolbox",
home: "./",
theme: { mode: "system", brand: "add·ideas" },
apps: [{ manifest: "./pdf/toolbox-app.json", enabled: true }],
...overrides,
});
const json = (value: unknown, init: ResponseInit = {}): Response =>
new Response(JSON.stringify(value), {
status: 200,
headers: { "content-type": "application/json" },
...init,
});
describe("v1 runtime parsing", () => {
it("parses the full app contract and drops unknown optional fields", () => {
const parsed = parseToolboxApp(
app({
futureField: "ignored",
privacy: {
processing: "local",
fileUploads: false,
telemetry: false,
futurePrivacyField: true,
},
}),
);
expect(parsed).toMatchObject({
schemaVersion: 1,
id: "de.add-ideas.pdf-tools",
privacy: { processing: "local", fileUploads: false, telemetry: false },
});
expect(parsed).not.toHaveProperty("futureField");
expect(parsed.privacy).not.toHaveProperty("futurePrivacyField");
});
it("rejects unsupported versions and malformed known optional fields", () => {
expect(() => parseToolboxApp(app({ schemaVersion: 2 }))).toThrow(
ToolboxValidationError,
);
expect(() =>
parseToolboxApp(
app({
requirements: {
secureContext: true,
workers: true,
indexedDb: true,
crossOriginIsolated: false,
topLevelContext: "sometimes",
},
}),
),
).toThrow(/topLevelContext must be a boolean/u);
});
it("accepts omitted source metadata but validates it when supplied", () => {
const withoutSource = app();
delete withoutSource.source;
expect(parseToolboxApp(withoutSource)).not.toHaveProperty("source");
expect(() =>
parseToolboxApp(
app({ source: { repository: "../repository", license: "MIT" } }),
),
).toThrow(/absolute HTTP\(S\) URL/u);
});
it("parses manifest references and external inline catalog entries", () => {
const parsed = parseToolboxCatalog(
catalog({
futureField: 1,
apps: [
{ manifest: "./pdf/toolbox-app.json", enabled: true, future: true },
{
name: "External docs",
entry: "https://docs.example.test/",
launch: "new-tab",
enabled: true,
},
],
}),
);
expect(parsed.apps).toHaveLength(2);
expect(parsed.apps[1]).toEqual({
name: "External docs",
entry: "https://docs.example.test/",
launch: "new-tab",
enabled: true,
});
expect(parsed).not.toHaveProperty("futureField");
});
});
describe("same-origin discovery", () => {
it("prefers a same-origin query context over meta context", () => {
const result = discoverToolboxCatalog({
location: "https://tools.example.test/pdf/?toolbox=%2Fcatalog.json",
document: {
querySelector: () => ({
getAttribute: () => "/meta-catalog.json",
}),
},
});
expect(result.status).toBe("found");
if (result.status === "found") {
expect(result.source).toBe("query");
expect(result.catalogUrl.href).toBe(
"https://tools.example.test/catalog.json",
);
}
});
it("rejects a cross-origin query without falling through to meta", () => {
const result = discoverToolboxCatalog({
location:
"https://tools.example.test/pdf/?toolbox=https%3A%2F%2Fevil.test%2Fcatalog.json",
document: {
querySelector: () => ({ getAttribute: () => "/safe.json" }),
},
});
expect(result.status).toBe("error");
if (result.status === "error")
expect(result.error.code).toBe("cross-origin");
});
});
describe("loading and links", () => {
it.each([
["TAB", "\t"],
["LF", "\n"],
["CR", "\r"],
["C0", "\u0001"],
["DEL", "\u007f"],
])("rejects %s in URL helper string inputs", (_name, control) => {
expect(() =>
resolveWebUrl(`./before${control}after`, "https://tools.example.test/"),
).toThrow(/control characters/u);
expect(() =>
resolveWebUrl("./tool", `https://tools.example.test/${control}`),
).toThrow(/control characters/u);
});
it("loads a catalog, resolves manifests and URLs, and supports external apps", async () => {
const fetch = vi.fn(
async (input: string | URL | Request, init?: RequestInit) => {
void init;
const url = new URL(input instanceof Request ? input.url : input);
if (url.pathname === "/catalog.json") {
return json(
catalog({
apps: [
{ manifest: "./pdf/toolbox-app.json", enabled: true },
{ manifest: "./disabled/toolbox-app.json", enabled: false },
{
name: "Documentation",
entry: "https://docs.example.test/",
launch: "new-tab",
enabled: true,
},
],
}),
);
}
if (url.pathname === "/pdf/toolbox-app.json") return json(app());
if (url.pathname === "/disabled/toolbox-app.json") {
throw new Error("Disabled manifests must not be fetched");
}
return new Response("Not found", { status: 404 });
},
);
const result = await loadToolboxContext({
location: "https://tools.example.test/pdf/?toolbox=/catalog.json",
fetch,
});
for (const [, init] of fetch.mock.calls) {
expect(init).toMatchObject({ redirect: "error" });
}
expect(result.status).toBe("ready");
if (result.status === "ready") {
expect(result.context.catalog.homeUrl.href).toBe(
"https://tools.example.test/",
);
expect(result.context.catalog.apps[0]?.kind).toBe("manifest");
const first = result.context.catalog.apps[0];
if (first?.kind === "manifest") {
expect(first.app.entryUrl.href).toBe(
"https://tools.example.test/pdf/index.html",
);
}
expect(result.context.catalog.apps[1]).toMatchObject({
kind: "external",
launch: "new-tab",
});
}
});
it("returns a graceful error result for invalid JSON and fetch failure", async () => {
const invalid = await loadToolboxContext({
location: "https://tools.example.test/?toolbox=/catalog.json",
fetch: async () => json(catalog({ schemaVersion: 2 })),
});
expect(invalid.status).toBe("error");
const unavailable = await loadToolboxContext({
location: "https://tools.example.test/?toolbox=/catalog.json",
fetch: async () => new Response("No", { status: 503 }),
});
expect(unavailable.status).toBe("error");
if (unavailable.status === "error") {
expect(unavailable.error.code).toBe("fetch-failed");
}
});
it("rejects a fetch redirected to a cross-origin response URL", async () => {
const response = json(catalog());
Object.defineProperty(response, "url", {
value: "https://evil.example/toolbox.catalog.json",
});
const result = await loadToolboxContext({
location: "https://tools.example.test/?toolbox=/catalog.json",
fetch: async () => response,
});
expect(result.status).toBe("error");
if (result.status === "error")
expect(result.error.code).toBe("cross-origin");
});
it("adds context to same-origin links but leaves external links alone", () => {
expect(
new URL(
contextualizeToolboxLink("./other/", "/catalog.json", {
location: "https://tools.example.test/pdf/",
}),
).searchParams.get("toolbox"),
).toBe("https://tools.example.test/catalog.json");
expect(
contextualizeToolboxLink("https://docs.example.test/", "/catalog.json", {
location: "https://tools.example.test/pdf/",
}),
).toBe("https://docs.example.test/");
});
});
export const validAppFixture = app() as unknown as ToolboxAppManifest;