305 lines
9.0 KiB
TypeScript
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;
|