repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/canvas/__tests__/server.test.ts
Rawimport { afterEach, describe, expect, it } from "vitest";
import { type CanvasServer, startCanvasServer } from "../src/server.ts";
import {
type CanvasAction,
type CanvasState,
changeScene,
emptyState,
} from "../src/store.ts";
const servers: CanvasServer[] = [];
afterEach(
async () =>
await Promise.all(servers.splice(0).map((server) => server.close())),
);
function open(
state: CanvasState = emptyState(),
actions: CanvasAction[] = [],
): Promise<CanvasServer> {
return startCanvasServer(
() => state,
async (action) => {
actions.push(action);
},
);
}
function request(
server: CanvasServer,
path: string,
init: RequestInit = {},
): Promise<Response> {
return fetch(new URL(path, server.url), init);
}
function bearer(server: CanvasServer): string {
return `Bearer ${new URL(server.url).hash.slice(1)}`;
}
function headers(server: CanvasServer): HeadersInit {
return {
Authorization: bearer(server),
Origin: new URL(server.url).origin,
"Content-Type": "application/json",
};
}
describe("canvas server", () => {
it("guards state and accepts only origin-bound new protocol actions", async () => {
const actions: CanvasAction[] = [];
const server = await open(emptyState(), actions);
servers.push(server);
expect((await request(server, "/api/state")).status).toBe(401);
expect(
(
await request(server, "/api/state", {
headers: { Authorization: bearer(server) },
})
).status,
).toBe(200);
const action = await request(server, "/api/action", {
method: "POST",
headers: headers(server),
body: JSON.stringify({
kind: "prepare",
requestId: "request",
clientId: "browser",
additions: [],
}),
});
expect(action.status).toBe(200);
expect(actions).toEqual([
{
kind: "prepare",
requestId: "request",
clientId: "browser",
additions: [],
},
]);
for (const value of [
{
kind: "commit",
requestId: "request",
clientId: "browser",
revision: 0,
elements: [],
},
{ kind: "cancel", requestId: "request", clientId: "browser" },
])
expect(
(
await request(server, "/api/action", {
method: "POST",
headers: headers(server),
body: JSON.stringify(value),
})
).status,
).toBe(200);
expect(
(
await request(server, "/api/action", {
method: "POST",
headers: { ...headers(server), Origin: "http://localhost" },
body: JSON.stringify({ kind: "scene", revision: 0, elements: [] }),
})
).status,
).toBe(403);
for (const value of [
{ kind: "ask", text: "removed" },
{ kind: "reply", annotationId: "removed", text: "removed" },
{ kind: "resolve", annotationId: "removed" },
])
expect(
(
await request(server, "/api/action", {
method: "POST",
headers: headers(server),
body: JSON.stringify(value),
})
).status,
).toBe(400);
expect(
(
await request(server, "/api/action", {
method: "POST",
headers: headers(server),
body: JSON.stringify({
kind: "prepare",
requestId: "x".repeat(121),
clientId: "browser",
additions: [],
}),
})
).status,
).toBe(400);
});
it("reports oversized scenes as 413 without changing state, while stale revisions remain 409", async () => {
let state = emptyState();
const server = await startCanvasServer(
() => state,
(action) => {
if (action.kind === "scene")
state = changeScene(state, action.revision, action.elements);
},
);
servers.push(server);
const post = (revision: number) =>
request(server, "/api/action", {
method: "POST",
headers: headers(server),
body: JSON.stringify({
kind: "scene",
revision,
elements: [
{
id: "large",
type: "rectangle",
customData: { data: "x".repeat(512 * 1024) },
},
],
}),
});
const oversized = await post(0);
expect(oversized.status).toBe(413);
expect(await oversized.json()).toEqual({
error: "Canvas payload exceeds size limit.",
});
expect((await post(1)).status).toBe(409);
expect(state).toEqual(emptyState());
});
it("validates bounded viewport images without exposing them in state", async () => {
const actions: CanvasAction[] = [];
const server = await open(emptyState(), actions);
servers.push(server);
const viewport = {
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
mimeType: "image/png",
imageWidth: 1,
imageHeight: 1,
width: 390,
height: 844,
scrollX: -50,
scrollY: 100,
zoom: 2,
};
const post = (image: unknown) =>
request(server, "/api/action", {
method: "POST",
headers: headers(server),
body: JSON.stringify({
kind: "scene",
revision: 0,
elements: [],
viewport: image,
}),
});
expect((await post(viewport)).status).toBe(200);
expect(actions[0]).toMatchObject({ viewport });
for (const invalid of [
null,
{ ...viewport, data: "not an image" },
{ ...viewport, data: "a".repeat(256 * 1024 + 4) },
{ ...viewport, mimeType: "image/svg+xml" },
{ ...viewport, imageWidth: 1025 },
{ ...viewport, zoom: 0 },
{ ...viewport, scrollX: "10" },
{ ...viewport, extra: true },
])
expect((await post(invalid)).status).toBe(400);
expect(actions).toHaveLength(1);
expect(
await (
await request(server, "/api/state", {
headers: { Authorization: bearer(server) },
})
).json(),
).not.toHaveProperty("viewport");
});
it("preserves SSE, host, method, CSP, and size guards", async () => {
const server = await open({ ...emptyState(), revision: 2 });
servers.push(server);
const page = await request(server, "/");
expect(page.headers.get("content-security-policy")).toContain(
"default-src 'self'",
);
expect(
(await fetch(server.url.replace("127.0.0.1", "localhost"))).status,
).toBe(400);
expect(
(
await request(server, "/api/state", {
method: "POST",
headers: { Authorization: bearer(server) },
})
).status,
).toBe(405);
expect((await request(server, "/api/events")).status).toBe(401);
const response = await request(server, "/api/events", {
headers: { Authorization: bearer(server) },
});
expect(response.status).toBe(200);
const reader = response.body?.getReader();
const chunk = await reader?.read();
expect(new TextDecoder().decode(chunk?.value)).toContain('"revision":2');
await reader?.cancel();
expect(
(
await request(server, "/api/action", {
method: "POST",
headers: headers(server),
body: "{",
})
).status,
).toBe(400);
expect(
(
await request(server, "/api/action", {
method: "POST",
headers: headers(server),
body: JSON.stringify({
kind: "scene",
revision: 0,
elements: "x".repeat(1024 * 1024 + 1),
}),
})
).status,
).toBe(413);
});
});