@@ -33,6 +33,10 @@ const app = (
|
||||
indexedDb: true,
|
||||
crossOriginIsolated: false,
|
||||
},
|
||||
capabilities: {
|
||||
required: ["workers"],
|
||||
optional: [],
|
||||
},
|
||||
privacy: {
|
||||
processing: "local",
|
||||
fileUploads: false,
|
||||
@@ -117,6 +121,86 @@ describe("v1 runtime parsing", () => {
|
||||
).toThrow(/absolute HTTP\(S\) URL/u);
|
||||
});
|
||||
|
||||
it("normalizes declared formats and capability profiles", () => {
|
||||
const parsed = parseToolboxApp(
|
||||
app({
|
||||
io: {
|
||||
accepts: [
|
||||
{
|
||||
mediaType: "Application/PDF",
|
||||
extensions: [".PDF"],
|
||||
label: "PDF",
|
||||
},
|
||||
],
|
||||
produces: [{ mediaType: "image/*", extensions: [".png"] }],
|
||||
},
|
||||
capabilities: {
|
||||
required: ["workers"],
|
||||
optional: ["file-system-access"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(parsed.io?.accepts[0]).toEqual({
|
||||
mediaType: "application/pdf",
|
||||
extensions: [".pdf"],
|
||||
label: "PDF",
|
||||
});
|
||||
expect(parsed.capabilities).toEqual({
|
||||
required: ["workers"],
|
||||
optional: ["file-system-access"],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed formats and duplicate capabilities", () => {
|
||||
expect(() =>
|
||||
parseToolboxApp(
|
||||
app({
|
||||
io: {
|
||||
accepts: [{ mediaType: "pdf", extensions: ["pdf"] }],
|
||||
produces: [],
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toThrow(/mediaType|start with a dot/u);
|
||||
expect(() =>
|
||||
parseToolboxApp(
|
||||
app({
|
||||
capabilities: { required: ["workers"], optional: ["workers"] },
|
||||
}),
|
||||
),
|
||||
).toThrow(/already required/u);
|
||||
});
|
||||
|
||||
it("keeps worker requirements and required capabilities consistent", () => {
|
||||
const legacyManifest = app();
|
||||
delete legacyManifest.capabilities;
|
||||
expect(parseToolboxApp(legacyManifest).requirements.workers).toBe(true);
|
||||
|
||||
expect(() =>
|
||||
parseToolboxApp(
|
||||
app({
|
||||
capabilities: { required: [], optional: ["workers"] },
|
||||
}),
|
||||
),
|
||||
).toThrow(/required must include workers.*requirements\.workers is true/u);
|
||||
|
||||
expect(() =>
|
||||
parseToolboxApp(
|
||||
app({
|
||||
requirements: {
|
||||
secureContext: true,
|
||||
workers: false,
|
||||
indexedDb: true,
|
||||
crossOriginIsolated: false,
|
||||
},
|
||||
capabilities: { required: ["workers"], optional: [] },
|
||||
}),
|
||||
),
|
||||
).toThrow(
|
||||
/required must not include workers.*requirements\.workers is false/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("parses manifest references and external inline catalog entries", () => {
|
||||
const parsed = parseToolboxCatalog(
|
||||
catalog({
|
||||
|
||||
@@ -124,6 +124,38 @@ describe("canonical schema and runtime parser parity", () => {
|
||||
source: { repository: "HTTPS:example.test", license: "MIT" },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a required worker capability paired with a worker requirement",
|
||||
() => ({
|
||||
...validApp(),
|
||||
requirements: {
|
||||
secureContext: false,
|
||||
workers: true,
|
||||
indexedDb: false,
|
||||
crossOriginIsolated: false,
|
||||
},
|
||||
capabilities: { required: ["workers"], optional: [] },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"an optional worker capability without a worker requirement",
|
||||
() => ({
|
||||
...validApp(),
|
||||
capabilities: { required: [], optional: ["workers"] },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a legacy worker requirement without a capability profile",
|
||||
() => ({
|
||||
...validApp(),
|
||||
requirements: {
|
||||
secureContext: false,
|
||||
workers: true,
|
||||
indexedDb: false,
|
||||
crossOriginIsolated: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
];
|
||||
|
||||
it.each(acceptedApps)("accepts %s", (_label, fixture) => {
|
||||
@@ -175,6 +207,26 @@ describe("canonical schema and runtime parser parity", () => {
|
||||
actions: [{ id: "docs", label: " ", url: "./docs" }],
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a worker requirement without a required worker capability",
|
||||
() => ({
|
||||
...validApp(),
|
||||
requirements: {
|
||||
secureContext: false,
|
||||
workers: true,
|
||||
indexedDb: false,
|
||||
crossOriginIsolated: false,
|
||||
},
|
||||
capabilities: { required: [], optional: ["workers"] },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a required worker capability with workers disabled",
|
||||
() => ({
|
||||
...validApp(),
|
||||
capabilities: { required: ["workers"], optional: [] },
|
||||
}),
|
||||
],
|
||||
];
|
||||
|
||||
it.each(rejectedApps)("rejects %s", (_label, fixture) => {
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import {
|
||||
consumeToolboxTransfer,
|
||||
createToolboxTransfer,
|
||||
createToolboxTransferUrl,
|
||||
readToolboxTransferToken,
|
||||
type ToolboxTransfer,
|
||||
type ToolboxTransferStore,
|
||||
} from "../src/index.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
class MemoryStore implements ToolboxTransferStore {
|
||||
readonly values = new Map<string, ToolboxTransfer>();
|
||||
|
||||
async put(transfer: ToolboxTransfer): Promise<void> {
|
||||
this.values.set(transfer.token, transfer);
|
||||
}
|
||||
|
||||
async take(
|
||||
token: string,
|
||||
expectedTargetAppId: string,
|
||||
now: number,
|
||||
): Promise<ToolboxTransfer | undefined> {
|
||||
const value = this.values.get(token);
|
||||
if (value !== undefined && value.targetAppId !== expectedTargetAppId) {
|
||||
throw new Error("Artifact transfer was addressed to another application");
|
||||
}
|
||||
this.values.delete(token);
|
||||
return value === undefined || value.expiresAt <= now ? undefined : value;
|
||||
}
|
||||
|
||||
async deleteExpired(now: number): Promise<number> {
|
||||
let count = 0;
|
||||
for (const [token, value] of this.values) {
|
||||
if (value.expiresAt <= now) {
|
||||
this.values.delete(token);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
const deterministicCrypto = {
|
||||
getRandomValues<T extends ArrayBufferView | null>(array: T): T {
|
||||
if (array instanceof Uint8Array)
|
||||
array.forEach((_value, index) => (array[index] = index));
|
||||
return array;
|
||||
},
|
||||
};
|
||||
|
||||
describe("one-time artifact transfers", () => {
|
||||
it("stores, addresses and consumes an artifact exactly once", async () => {
|
||||
const store = new MemoryStore();
|
||||
const blob = new Blob(["hello"], { type: "text/plain" });
|
||||
const transfer = await createToolboxTransfer(
|
||||
{
|
||||
source: { appId: "de.add-ideas.file-tools", appVersion: "1.0.0" },
|
||||
targetAppId: "de.add-ideas.text-tools",
|
||||
files: [
|
||||
{ blob, name: "hello.txt", mediaType: blob.type, size: blob.size },
|
||||
],
|
||||
},
|
||||
{ store, crypto: deterministicCrypto, now: () => 1_000 },
|
||||
);
|
||||
expect(transfer.token).toHaveLength(22);
|
||||
const url = createToolboxTransferUrl(
|
||||
"https://tools.test/apps/text/",
|
||||
transfer.token,
|
||||
{ location: "https://tools.test/apps/file/" },
|
||||
);
|
||||
expect(readToolboxTransferToken(url)).toBe(transfer.token);
|
||||
const consumed = await consumeToolboxTransfer(
|
||||
transfer.token,
|
||||
"de.add-ideas.text-tools",
|
||||
{ store, now: () => 2_000 },
|
||||
);
|
||||
expect(await consumed?.files[0]?.blob.text()).toBe("hello");
|
||||
expect(
|
||||
await consumeToolboxTransfer(transfer.token, "de.add-ideas.text-tools", {
|
||||
store,
|
||||
now: () => 2_000,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects expired, oversized and wrongly addressed transfers", async () => {
|
||||
const store = new MemoryStore();
|
||||
const blob = new Blob(["hello"]);
|
||||
const transfer = await createToolboxTransfer(
|
||||
{
|
||||
source: { appId: "source" },
|
||||
targetAppId: "target",
|
||||
ttlMs: 10,
|
||||
files: [{ blob, name: "a.bin", mediaType: "", size: blob.size }],
|
||||
},
|
||||
{ store, crypto: deterministicCrypto, now: () => 10 },
|
||||
);
|
||||
await expect(
|
||||
consumeToolboxTransfer(transfer.token, "target", {
|
||||
store,
|
||||
now: () => 20,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
createToolboxTransfer(
|
||||
{
|
||||
source: { appId: "source" },
|
||||
targetAppId: "target",
|
||||
maxBytes: 4,
|
||||
files: [{ blob, name: "a.bin", mediaType: "", size: blob.size }],
|
||||
},
|
||||
{ store, crypto: deterministicCrypto },
|
||||
),
|
||||
).rejects.toThrow(/exceeds/u);
|
||||
await expect(
|
||||
createToolboxTransfer(
|
||||
{
|
||||
source: { appId: "source" },
|
||||
targetAppId: "target",
|
||||
maxBytes: 512 * 1024 * 1024 + 1,
|
||||
files: [{ blob, name: "a.bin", mediaType: "", size: blob.size }],
|
||||
},
|
||||
{ store, crypto: deterministicCrypto },
|
||||
),
|
||||
).rejects.toThrow(/invalid/iu);
|
||||
await expect(
|
||||
createToolboxTransfer(
|
||||
{
|
||||
source: { appId: "source" },
|
||||
targetAppId: "target",
|
||||
files: [{ blob, name: "a.bin", mediaType: "", size: blob.size }],
|
||||
},
|
||||
{ store, crypto: deterministicCrypto, now: () => Number.NaN },
|
||||
),
|
||||
).rejects.toThrow(/creation time/iu);
|
||||
|
||||
const addressed = await createToolboxTransfer(
|
||||
{
|
||||
source: { appId: "source" },
|
||||
targetAppId: "target",
|
||||
files: [{ blob, name: "a.bin", mediaType: "", size: blob.size }],
|
||||
},
|
||||
{ store, crypto: deterministicCrypto },
|
||||
);
|
||||
await expect(
|
||||
consumeToolboxTransfer(addressed.token, "another-target", { store }),
|
||||
).rejects.toThrow(/another application/u);
|
||||
});
|
||||
|
||||
it("keeps opaque handoff tokens on-origin and rejects unsafe descriptors", async () => {
|
||||
const store = new MemoryStore();
|
||||
const blob = new Blob(["hello"]);
|
||||
await expect(
|
||||
createToolboxTransfer(
|
||||
{
|
||||
source: { appId: "source" },
|
||||
targetAppId: "target",
|
||||
files: [
|
||||
{
|
||||
blob,
|
||||
name: "../hello.txt",
|
||||
mediaType: "text/plain",
|
||||
size: blob.size,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ store, crypto: deterministicCrypto },
|
||||
),
|
||||
).rejects.toThrow(/name is invalid/u);
|
||||
expect(() =>
|
||||
createToolboxTransferUrl(
|
||||
"https://other.test/apps/text/",
|
||||
"AAECAwQFBgcICQoLDA0ODw",
|
||||
{ location: "https://tools.test/apps/file/" },
|
||||
),
|
||||
).toThrow(/current origin/u);
|
||||
});
|
||||
|
||||
it("revalidates same-origin storage records at the consuming trust boundary", async () => {
|
||||
const store = new MemoryStore();
|
||||
const blob = new Blob(["hello"]);
|
||||
const transfer = await createToolboxTransfer(
|
||||
{
|
||||
source: { appId: "source" },
|
||||
targetAppId: "target",
|
||||
files: [{ blob, name: "hello.txt", mediaType: "text/plain", size: 5 }],
|
||||
},
|
||||
{ store, crypto: deterministicCrypto, now: () => 100 },
|
||||
);
|
||||
store.values.set(transfer.token, {
|
||||
...transfer,
|
||||
artifactVersion: 99 as 1,
|
||||
});
|
||||
await expect(
|
||||
consumeToolboxTransfer(transfer.token, "target", {
|
||||
store,
|
||||
now: () => 200,
|
||||
}),
|
||||
).rejects.toThrow(/identity is invalid/u);
|
||||
expect(store.values.has(transfer.token)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user