Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/canvas/__tests__/server.test.ts

Raw
import { 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);
	});
});