Files
lda-wf/web/packages/presentation-sync/src/protocol.test.ts
T

306 lines
8.1 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
JOIN_CODE_LENGTH,
MAX_PRESENTATION_HASH_LENGTH,
MAX_SYNC_MESSAGE_BYTES,
decodeClientSyncMessage,
decodeCreateSessionRequest,
decodeJoinSessionRequest,
decodeServerSyncMessage,
decodeSessionGrant,
isCanonicalPresentationHash,
normalizeJoinCode,
} from "./protocol.js";
describe("presentation sync protocol", () => {
it("accepts bounded canonical location publishes", () => {
expect(
decodeClientSyncMessage(
JSON.stringify({
type: "location.publish",
hash: "#scene/architecture/client/focus/client-operations",
baseRevision: 4,
messageId: "msg-4",
}),
),
).toEqual({
ok: true,
value: {
type: "location.publish",
hash: "#scene/architecture/client/focus/client-operations",
baseRevision: 4,
messageId: "msg-4",
},
});
});
it.each(["", "#unknown/x", "scene/thesis/title"])(
"rejects non-canonical hash %s",
(hash) => expect(isCanonicalPresentationHash(hash)).toBe(false),
);
it("enforces the canonical hash length bound", () => {
expect(
isCanonicalPresentationHash(
`#scene/${"x".repeat(MAX_PRESENTATION_HASH_LENGTH)}`,
),
).toBe(false);
});
it("rejects oversized websocket payloads before JSON decoding", () => {
const result = decodeClientSyncMessage(
"x".repeat(MAX_SYNC_MESSAGE_BYTES + 1),
);
expect(result).toEqual({ ok: false, error: "message_too_large" });
});
it("normalizes human-entered join codes", () => {
expect(normalizeJoinCode(" ab-cd 7 ")).toBe("ABCD7");
});
it.each([
{ type: "location.unknown" },
{
type: "location.publish",
hash: "#scene/thesis/title",
baseRevision: -1,
messageId: "msg-1",
},
{
type: "location.publish",
hash: "#scene/thesis/title",
baseRevision: 1.5,
messageId: "msg-1",
},
{
type: "location.publish",
hash: "#scene/thesis/title",
baseRevision: 0,
messageId: "x".repeat(129),
},
{
type: "location.publish",
hash: `#scene/${"x".repeat(MAX_PRESENTATION_HASH_LENGTH)}`,
baseRevision: 0,
messageId: "msg-1",
},
])("rejects invalid client message %#", (message) => {
expect(decodeClientSyncMessage(JSON.stringify(message))).toEqual({
ok: false,
error: "invalid_message",
});
});
it("accepts session end and ping messages", () => {
expect(decodeClientSyncMessage('{"type":"session.end"}')).toEqual({
ok: true,
value: { type: "session.end" },
});
expect(
decodeClientSyncMessage(JSON.stringify({ type: "ping", nonce: "n-1" })),
).toEqual({
ok: true,
value: { type: "ping", nonce: "n-1" },
});
});
it.each([
{
role: "presenter",
initialHash: "#scene/thesis/title",
},
{
role: "audience",
initialHash: "#discuss/problem/direct-actions",
},
])("accepts a valid create request %#", (request) => {
expect(decodeCreateSessionRequest(JSON.stringify(request))).toEqual({
ok: true,
value: request,
});
});
it("rejects invalid create request roles and hashes", () => {
expect(
decodeCreateSessionRequest(
JSON.stringify({ role: "moderator", initialHash: "#scene/thesis/title" }),
),
).toEqual({ ok: false, error: "invalid_message" });
expect(
decodeCreateSessionRequest(
JSON.stringify({ role: "presenter", initialHash: "#unknown/title" }),
),
).toEqual({ ok: false, error: "invalid_message" });
});
it("normalizes and validates join request codes", () => {
expect(
decodeJoinSessionRequest(
JSON.stringify({ role: "audience", code: " ab-cd 7x " }),
),
).toEqual({
ok: true,
value: { role: "audience", code: "ABCD7X" },
});
expect(
decodeJoinSessionRequest(
JSON.stringify({ role: "audience", code: "AB12" }),
),
).toEqual({ ok: false, error: "invalid_message" });
expect(
decodeJoinSessionRequest(
JSON.stringify({ role: "moderator", code: "ABCD7X" }),
),
).toEqual({ ok: false, error: "invalid_message" });
expect(JOIN_CODE_LENGTH).toBe(6);
});
it("decodes every server message variant", () => {
const messages = [
{
type: "location.snapshot",
snapshot: { hash: "#scene/thesis/title", revision: 3 },
originatingMessageId: "msg-3",
},
{
type: "location.snapshot",
snapshot: { hash: "#discuss/problem/direct-actions", revision: 0 },
originatingMessageId: null,
},
{
type: "presence.snapshot",
presence: { presenters: 1, audience: 2 },
},
{
type: "location.rejected",
reason: "stale_revision",
current: { hash: "#scene/thesis/title", revision: 4 },
messageId: "msg-stale",
},
{ type: "session.ended", reason: "presenter_ended" },
{ type: "session.ended", reason: "expired" },
{
type: "protocol.error",
code: "invalid_message",
message: "invalid payload",
},
{
type: "protocol.error",
code: "message_too_large",
message: "payload exceeded limit",
},
{
type: "protocol.error",
code: "forbidden",
message: "presenter role required",
},
];
for (const message of messages) {
expect(decodeServerSyncMessage(JSON.stringify(message))).toEqual({
ok: true,
value: message,
});
}
});
it("rejects invalid server revisions and presence counts", () => {
expect(
decodeServerSyncMessage(
JSON.stringify({
type: "location.snapshot",
snapshot: { hash: "#scene/thesis/title", revision: 1.25 },
originatingMessageId: null,
}),
),
).toEqual({ ok: false, error: "invalid_message" });
expect(
decodeServerSyncMessage(
JSON.stringify({
type: "presence.snapshot",
presence: { presenters: -1, audience: 0 },
}),
),
).toEqual({ ok: false, error: "invalid_message" });
});
it("returns stable errors for malformed JSON", () => {
expect(decodeClientSyncMessage("not json")).toEqual({
ok: false,
error: "invalid_json",
});
expect(decodeServerSyncMessage("{")).toEqual({
ok: false,
error: "invalid_json",
});
expect(decodeCreateSessionRequest("[]")).toEqual({
ok: false,
error: "invalid_message",
});
});
it("decodes a valid session grant", () => {
const grant = {
sessionId: "session-1",
code: "ABCD7X",
connectionToken: "token-1",
websocketPath: "/api/presentation-sync/ws",
snapshot: { hash: "#scene/thesis/title", revision: 0 },
};
expect(decodeSessionGrant(JSON.stringify(grant))).toEqual({
ok: true,
value: grant,
});
});
it.each([
{
snapshot: { hash: "#unknown/title", revision: 0 },
},
{
snapshot: { hash: "#scene/thesis/title", revision: -1 },
},
{
connectionToken: "",
},
{
connectionToken: "x".repeat(129),
},
{
websocketPath: "/api/presentation-sync/other",
},
])("rejects invalid session grant field %#", (override) => {
const grant = {
sessionId: "session-1",
code: "ABCD7X",
connectionToken: "token-1",
websocketPath: "/api/presentation-sync/ws",
snapshot: { hash: "#scene/thesis/title", revision: 0 },
...override,
};
expect(decodeSessionGrant(JSON.stringify(grant))).toEqual({
ok: false,
error: "invalid_message",
});
});
it("returns stable errors for malformed session grants", () => {
expect(decodeSessionGrant("not json")).toEqual({
ok: false,
error: "invalid_json",
});
expect(
decodeSessionGrant(
JSON.stringify({
sessionId: "session-1",
code: "ABCD7X",
connectionToken: "token-1",
websocketPath: "/api/presentation-sync/ws",
}),
),
).toEqual({ ok: false, error: "invalid_message" });
});
});