houserules/packages/server/src/protocol.test.ts
Joey Yakimowich-Payne 7cc9617f3e
fix(server): widen ModifierKindIdSchema to accept custom modifier ids
T3 audit gap 1 (BLOCKER). The server's ModifierKindIdSchema was still
z.enum([built-ins]) even after the client-side mirror was widened
during T29. Effect: any multiplayer room.create or
modifier-profile.update carrying a profile whose perType[i].kind is a
user-authored CustomModifierId (e.g. 'custom:shield') was rejected
server-side as INVALID_MESSAGE.

Symmetric fix: accept any non-empty string, defer kind validity to the
engine's registry-dispatch fallback at apply time (MODIFIER_REGISTRY
→ engine.customModifiers → warn-and-skip). The built-in id list is
preserved as a doc comment + void-referenced constant so it can't
silently drift.

Test updated: 'rejects a profile with unknown modifier kind' inverted
to 'accepts a profile with arbitrary modifier kind (T3 widening)' +
new 'rejects empty-string modifier kind' to preserve the min(1) guard.
2026-04-20 16:43:38 -06:00

885 lines
23 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
validateMessage,
validateMessageString,
PROTOCOL_VERSION,
ClientMessageSchema,
ServerMessageSchema,
ModifierProfileSchema,
ModifierProfileUpdatePayloadSchema,
RoomCreatePayloadSchema,
MODIFIER_PROFILE_INVALID,
MODIFIER_PROFILE_NO_KING,
MODIFIER_PROFILE_INVULN_KING,
MODIFIER_PROFILE_DEADLOCK,
type AnyMessage,
type ClientMessage,
type ServerMessage,
} from "./protocol.js";
// ---------------------------------------------------------------------------
// Fixtures — one minimally-valid example per message type.
// ---------------------------------------------------------------------------
const UUID = "550e8400-e29b-41d4-a716-446655440000";
const UUID2 = "661f9500-f30c-42e5-b827-557766550111";
const envelope = { v: PROTOCOL_VERSION, seq: 0, ts: 1 } as const;
const fixtures: Record<string, AnyMessage> = {
"room.create": {
...envelope,
type: "room.create",
payload: { rulesetIds: ["pawns-move-backward"] },
},
"room.create (empty payload)": {
...envelope,
type: "room.create",
payload: {},
},
"room.join": {
...envelope,
type: "room.join",
payload: { code: "ABC123" },
},
"room.leave": {
...envelope,
type: "room.leave",
payload: {},
},
"game.move": {
...envelope,
type: "game.move",
token: UUID,
payload: { from: "e2", to: "e4" },
},
"game.move (promotion)": {
...envelope,
type: "game.move",
token: UUID,
payload: { from: "a7", to: "a8", promoteTo: "queen" },
},
"room.created": {
...envelope,
seq: 1,
type: "room.created",
payload: { code: "ABC123", token: UUID, color: "white" },
},
"room.joined": {
...envelope,
seq: 1,
type: "room.joined",
payload: {
code: "ABC123",
token: UUID2,
color: "black",
activeRules: ["pawns-move-backward"],
},
},
"game.state": {
...envelope,
seq: 2,
type: "game.state",
payload: {
facts: [{ id: 1, attr: "PieceType", value: "pawn" }],
turn: "white",
lastSeq: 42,
moveHistory: ["e2-e4"],
activeRules: [],
fen: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
},
},
"game.delta (ongoing)": {
...envelope,
seq: 3,
type: "game.delta",
payload: {
inserted: [{ id: 1, attr: "Position", value: 28 }],
retracted: [{ id: 1, attr: "Position", value: 12 }],
moveNotation: "e2e4",
turn: "black",
gameOver: null,
},
},
"game.delta (game over)": {
...envelope,
seq: 99,
type: "game.delta",
payload: {
inserted: [],
retracted: [],
moveNotation: "Qh4#",
turn: "black",
gameOver: { winner: "black", reason: "checkmate" },
},
},
"game.end": {
...envelope,
seq: 100,
type: "game.end",
payload: {
winner: "white",
reason: "checkmate",
finalFen: "rnb1kbnr/pppp1ppp/8/4p3/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 0 3",
},
},
"error (non-fatal)": {
...envelope,
seq: 5,
type: "error",
payload: {
code: "ILLEGAL_MOVE",
message: "Move e2-e5 is not legal",
fatal: false,
},
},
"error (fatal)": {
...envelope,
seq: 6,
type: "error",
payload: {
code: "VERSION_MISMATCH",
message: "expected v=1",
fatal: true,
},
},
};
// ---------------------------------------------------------------------------
// Round-trip: every fixture must JSON-serialise and re-validate identically.
// ---------------------------------------------------------------------------
describe("validateMessage — round-trip", () => {
for (const [name, message] of Object.entries(fixtures)) {
it(`round-trips ${name}`, () => {
const encoded = JSON.stringify(message);
const decoded = JSON.parse(encoded) as unknown;
const result = validateMessage(decoded);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(message);
expect(result.data.type).toBe(message.type);
}
});
}
it("round-trips with an optional token present", () => {
const msg = {
...envelope,
token: UUID,
type: "room.leave" as const,
payload: {},
};
const result = validateMessage(JSON.parse(JSON.stringify(msg)));
expect(result.ok).toBe(true);
if (result.ok) expect(result.data.token).toBe(UUID);
});
});
// ---------------------------------------------------------------------------
// Rejections
// ---------------------------------------------------------------------------
describe("validateMessage — rejections", () => {
it("rejects non-object input (null)", () => {
const r = validateMessage(null);
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/INVALID_MESSAGE/);
});
it("rejects non-object input (array)", () => {
const r = validateMessage([1, 2, 3]);
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/INVALID_MESSAGE/);
});
it("rejects non-object input (string)", () => {
const r = validateMessage("hello");
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/INVALID_MESSAGE/);
});
it("rejects v=2 with VERSION_MISMATCH", () => {
const r = validateMessage({
v: 2,
seq: 0,
ts: 1,
type: "room.create",
payload: {},
});
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toMatch(/VERSION_MISMATCH/);
expect(r.error).toContain("expected v=1");
}
});
it("rejects missing v with VERSION_MISMATCH (v is undefined)", () => {
const r = validateMessage({
seq: 0,
ts: 1,
type: "room.create",
payload: {},
});
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/VERSION_MISMATCH/);
});
it("rejects missing type", () => {
const r = validateMessage({ v: 1, seq: 0, ts: 1, payload: {} });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/INVALID_MESSAGE/);
});
it("rejects non-string type", () => {
const r = validateMessage({
v: 1,
seq: 0,
ts: 1,
type: 42,
payload: {},
});
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/non-string `type`/);
});
it("rejects unknown message type", () => {
const r = validateMessage({
v: 1,
seq: 0,
ts: 1,
type: "nope.nope",
payload: {},
});
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/unknown message type "nope.nope"/);
});
it("rejects game.move with bad `from` format", () => {
const r = validateMessage({
...envelope,
type: "game.move",
payload: { from: "e22", to: "e4" },
});
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/from/);
});
it("rejects game.move with bad `to` file", () => {
const r = validateMessage({
...envelope,
type: "game.move",
payload: { from: "e2", to: "z9" },
});
expect(r.ok).toBe(false);
});
it("rejects game.move with bad promotion piece", () => {
const r = validateMessage({
...envelope,
type: "game.move",
payload: { from: "a7", to: "a8", promoteTo: "king" },
});
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/promoteTo/);
});
it("rejects room.join with 5-char code", () => {
const r = validateMessage({
...envelope,
type: "room.join",
payload: { code: "ABC12" },
});
expect(r.ok).toBe(false);
});
it("rejects room.join with lowercase code", () => {
const r = validateMessage({
...envelope,
type: "room.join",
payload: { code: "abc123" },
});
expect(r.ok).toBe(false);
});
it("rejects negative seq", () => {
const r = validateMessage({
v: 1,
seq: -1,
ts: 1,
type: "room.leave",
payload: {},
});
expect(r.ok).toBe(false);
});
it("rejects non-integer seq", () => {
const r = validateMessage({
v: 1,
seq: 1.5,
ts: 1,
type: "room.leave",
payload: {},
});
expect(r.ok).toBe(false);
});
it("rejects non-positive ts", () => {
const r = validateMessage({
v: 1,
seq: 0,
ts: 0,
type: "room.leave",
payload: {},
});
expect(r.ok).toBe(false);
});
it("rejects token that is not a UUID", () => {
const r = validateMessage({
...envelope,
token: "not-a-uuid",
type: "room.leave",
payload: {},
});
expect(r.ok).toBe(false);
});
it("rejects room.created with non-UUID token", () => {
const r = validateMessage({
...envelope,
type: "room.created",
payload: { code: "ABC123", token: "nope", color: "white" },
});
expect(r.ok).toBe(false);
});
it("rejects room.created with invalid color", () => {
const r = validateMessage({
...envelope,
type: "room.created",
payload: { code: "ABC123", token: UUID, color: "green" },
});
expect(r.ok).toBe(false);
});
it("rejects game.state missing required field", () => {
const r = validateMessage({
...envelope,
type: "game.state",
payload: {
facts: [],
turn: "white",
lastSeq: 0,
moveHistory: [],
activeRules: [],
// fen missing
},
});
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/fen/);
});
it("rejects error payload with unknown error code", () => {
const r = validateMessage({
...envelope,
type: "error",
payload: { code: "UNKNOWN_CODE", message: "x", fatal: false },
});
expect(r.ok).toBe(false);
});
it("rejects game.delta gameOver with invalid reason", () => {
const r = validateMessage({
...envelope,
type: "game.delta",
payload: {
inserted: [],
retracted: [],
moveNotation: "a",
turn: "white",
gameOver: { winner: "white", reason: "???" },
},
});
expect(r.ok).toBe(false);
});
it("rejects game.end with invalid winner", () => {
const r = validateMessage({
...envelope,
type: "game.end",
payload: { winner: "nobody", reason: "x", finalFen: "x" },
});
expect(r.ok).toBe(false);
});
it("rejects room.leave with extra payload keys (strict)", () => {
const r = validateMessage({
...envelope,
type: "room.leave",
payload: { extra: 1 },
});
expect(r.ok).toBe(false);
});
});
// ---------------------------------------------------------------------------
// validateMessageString — raw-string entry point
// ---------------------------------------------------------------------------
describe("validateMessageString", () => {
it("parses a valid JSON string frame", () => {
const msg: ClientMessage = {
...envelope,
type: "room.create",
payload: {},
};
const r = validateMessageString(JSON.stringify(msg));
expect(r.ok).toBe(true);
});
it("reports malformed JSON as INVALID_MESSAGE", () => {
const r = validateMessageString("{ this is not json");
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toMatch(/INVALID_MESSAGE/);
expect(r.error).toMatch(/malformed JSON/);
}
});
it("propagates version mismatch through the string entry point", () => {
const r = validateMessageString(
JSON.stringify({ v: 99, seq: 0, ts: 1, type: "room.leave", payload: {} }),
);
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/VERSION_MISMATCH/);
});
});
// ---------------------------------------------------------------------------
// Type-narrowing smoke test — ensures discriminated unions are usable.
// (Executed at runtime, but the value comes from the union's narrowing.)
// ---------------------------------------------------------------------------
describe("schema unions", () => {
it("ClientMessageSchema parses client-originating types only", () => {
const ok = ClientMessageSchema.safeParse({
...envelope,
type: "game.move",
payload: { from: "e2", to: "e4" },
});
expect(ok.success).toBe(true);
const bad = ClientMessageSchema.safeParse({
...envelope,
type: "game.end",
payload: { winner: "white", reason: "x", finalFen: "x" },
});
expect(bad.success).toBe(false);
});
it("ServerMessageSchema parses server-originating types only", () => {
const okMsg: ServerMessage = {
...envelope,
type: "error",
payload: { code: "RATE_LIMIT", message: "too fast", fatal: true },
};
const good = ServerMessageSchema.safeParse(okMsg);
expect(good.success).toBe(true);
const bad = ServerMessageSchema.safeParse({
...envelope,
type: "room.create",
payload: {},
});
expect(bad.success).toBe(false);
});
it("narrows AnyMessage by `type` discriminant", () => {
const r = validateMessage({
...envelope,
type: "game.move",
payload: { from: "e2", to: "e4" },
});
expect(r.ok).toBe(true);
if (r.ok && r.data.type === "game.move") {
// TS narrowing should let us read these fields without casts.
expect(r.data.payload.from).toBe("e2");
expect(r.data.payload.to).toBe("e4");
}
});
});
// ---------------------------------------------------------------------------
// Layout field on room.create — discriminated union of premade / FEN / custom
// ---------------------------------------------------------------------------
describe("room.create layout payload", () => {
it("accepts { kind: 'premade', id } layout selection", () => {
const r = validateMessage({
...envelope,
type: "room.create",
payload: {
layout: { kind: "premade", id: "dunsany" },
},
});
expect(r.ok).toBe(true);
});
it("accepts { kind: 'fen', fen } layout selection", () => {
const r = validateMessage({
...envelope,
type: "room.create",
payload: {
layout: {
kind: "fen",
fen: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR",
name: "Pasted FEN",
},
},
});
expect(r.ok).toBe(true);
});
it("accepts { kind: 'custom', pieces } layout selection", () => {
const r = validateMessage({
...envelope,
type: "room.create",
payload: {
layout: {
kind: "custom",
pieces: [
{ type: "king", color: "white", square: 4 },
{ type: "king", color: "black", square: 60 },
],
},
},
});
expect(r.ok).toBe(true);
});
it("rejects unknown layout kind", () => {
const r = validateMessage({
...envelope,
type: "room.create",
payload: { layout: { kind: "nonsense", id: "foo" } },
});
expect(r.ok).toBe(false);
});
it("rejects custom layout with out-of-range square", () => {
const r = validateMessage({
...envelope,
type: "room.create",
payload: {
layout: {
kind: "custom",
pieces: [{ type: "king", color: "white", square: 999 }],
},
},
});
expect(r.ok).toBe(false);
});
it("rejects custom layout with > 128 pieces (DoS cap)", () => {
const pieces = Array.from({ length: 129 }, (_, i) => ({
type: "pawn" as const,
color: "white" as const,
square: i % 64,
}));
const r = validateMessage({
...envelope,
type: "room.create",
payload: { layout: { kind: "custom", pieces } },
});
expect(r.ok).toBe(false);
});
it("room.create without layout still parses (backward compat)", () => {
const r = validateMessage({
...envelope,
type: "room.create",
payload: {},
});
expect(r.ok).toBe(true);
});
it("room.created echoes resolved layout", () => {
const r = validateMessage({
...envelope,
seq: 1,
type: "room.created",
payload: {
code: "ABC123",
token: UUID,
color: "white",
layout: {
id: "dunsany",
name: "Dunsany's Chess",
pieces: [
{ type: "king", color: "white", square: 4 },
{ type: "king", color: "black", square: 60 },
],
},
},
});
expect(r.ok).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Modifier profile — schemas, RoomCreate integration, error codes
// ---------------------------------------------------------------------------
/**
* A minimally-valid ModifierProfile fixture. Uses one perType entry
* (a +1 HP bonus on white pawns) and one perInstance entry so both
* arrays are exercised by parsing.
*/
const validProfile = {
id: "test-profile",
name: "Test Profile",
description: "For protocol.test coverage.",
layoutId: "classic",
perType: [
{
kind: "hp-bonus",
pieceType: "pawn",
color: "white",
value: 1,
},
],
perInstance: [
{
kind: "range-bonus",
square: "d1",
value: 2,
},
],
version: 1,
source: "custom",
} as const;
describe("ModifierProfileSchema", () => {
it("parses a valid profile", () => {
const r = ModifierProfileSchema.safeParse(validProfile);
expect(r.success).toBe(true);
});
it("parses a profile with empty perType / perInstance", () => {
const r = ModifierProfileSchema.safeParse({
...validProfile,
perType: [],
perInstance: [],
});
expect(r.success).toBe(true);
});
it("rejects a profile with wrong version literal", () => {
const r = ModifierProfileSchema.safeParse({ ...validProfile, version: 2 });
expect(r.success).toBe(false);
});
it("accepts a profile with arbitrary modifier kind (T3 widening for custom ids)", () => {
// Pre-T3 the wire enum rejected unknown kinds. The widening was
// forced by user-authored CustomModifierIds (arbitrary strings like
// "custom:shield"); kind validity is now enforced by the engine's
// registry-dispatch fallback at apply time, not at the wire.
const r = ModifierProfileSchema.safeParse({
...validProfile,
perType: [
{
kind: "custom:teleport",
pieceType: "pawn",
color: "white",
value: 1,
},
],
});
expect(r.success).toBe(true);
});
it("rejects a profile with empty-string modifier kind", () => {
const r = ModifierProfileSchema.safeParse({
...validProfile,
perType: [
{
kind: "",
pieceType: "pawn",
color: "white",
value: 1,
},
],
});
expect(r.success).toBe(false);
});
it("rejects a perInstance entry with non-algebraic square", () => {
const r = ModifierProfileSchema.safeParse({
...validProfile,
perInstance: [{ kind: "hp-bonus", square: "d9", value: 1 }],
});
expect(r.success).toBe(false);
});
});
describe("RoomCreatePayloadSchema — profile field (T17)", () => {
it("accepts a room.create with a valid inline profile", () => {
const r = validateMessage({
...envelope,
type: "room.create",
payload: { profile: validProfile },
});
expect(r.ok).toBe(true);
});
it("accepts a room.create WITHOUT a profile (backward compat)", () => {
const r = validateMessage({
...envelope,
type: "room.create",
payload: {},
});
expect(r.ok).toBe(true);
});
it("accepts room.create with layout + profile + rulesetIds together", () => {
const r = validateMessage({
...envelope,
type: "room.create",
payload: {
rulesetIds: ["piece-hp"],
layout: { kind: "premade", id: "classic" },
profile: validProfile,
},
});
expect(r.ok).toBe(true);
});
it("parses through the schema directly (not just the envelope path)", () => {
const r = RoomCreatePayloadSchema.safeParse({ profile: validProfile });
expect(r.success).toBe(true);
if (r.success) expect(r.data.profile?.id).toBe("test-profile");
});
it("rejects a room.create with a malformed profile", () => {
const r = validateMessage({
...envelope,
type: "room.create",
payload: {
profile: { ...validProfile, version: 99 },
},
});
// The profile field is optional, but when present it must validate.
expect(r.ok).toBe(false);
});
});
describe("ModifierProfileUpdatePayloadSchema", () => {
it("parses a well-formed update payload", () => {
const r = ModifierProfileUpdatePayloadSchema.safeParse({
type: "modifier-profile.update",
roomCode: "ABC123",
newProfile: validProfile,
version: 3,
});
expect(r.success).toBe(true);
});
it("parses with version = 0 (initial pre-update state)", () => {
const r = ModifierProfileUpdatePayloadSchema.safeParse({
type: "modifier-profile.update",
roomCode: "ABC123",
newProfile: validProfile,
version: 0,
});
expect(r.success).toBe(true);
});
it("rejects missing `version`", () => {
const r = ModifierProfileUpdatePayloadSchema.safeParse({
type: "modifier-profile.update",
roomCode: "ABC123",
newProfile: validProfile,
});
expect(r.success).toBe(false);
});
it("rejects negative `version`", () => {
const r = ModifierProfileUpdatePayloadSchema.safeParse({
type: "modifier-profile.update",
roomCode: "ABC123",
newProfile: validProfile,
version: -1,
});
expect(r.success).toBe(false);
});
it("rejects non-integer `version`", () => {
const r = ModifierProfileUpdatePayloadSchema.safeParse({
type: "modifier-profile.update",
roomCode: "ABC123",
newProfile: validProfile,
version: 1.5,
});
expect(r.success).toBe(false);
});
it("rejects bad roomCode format", () => {
const r = ModifierProfileUpdatePayloadSchema.safeParse({
type: "modifier-profile.update",
roomCode: "abc123", // lowercase rejected
newProfile: validProfile,
version: 1,
});
expect(r.success).toBe(false);
});
it("rejects wrong literal `type`", () => {
const r = ModifierProfileUpdatePayloadSchema.safeParse({
type: "modifier-profile.nope",
roomCode: "ABC123",
newProfile: validProfile,
version: 1,
});
expect(r.success).toBe(false);
});
});
describe("Modifier profile error codes", () => {
it("const exports equal their string literal values", () => {
expect(MODIFIER_PROFILE_INVALID).toBe("MODIFIER_PROFILE_INVALID");
expect(MODIFIER_PROFILE_NO_KING).toBe("MODIFIER_PROFILE_NO_KING");
expect(MODIFIER_PROFILE_INVULN_KING).toBe("MODIFIER_PROFILE_INVULN_KING");
expect(MODIFIER_PROFILE_DEADLOCK).toBe("MODIFIER_PROFILE_DEADLOCK");
});
it("all four are accepted by the ErrorCodeSchema via an error payload", () => {
for (const code of [
MODIFIER_PROFILE_INVALID,
MODIFIER_PROFILE_NO_KING,
MODIFIER_PROFILE_INVULN_KING,
MODIFIER_PROFILE_DEADLOCK,
]) {
const r = validateMessage({
...envelope,
type: "error",
payload: { code, message: `reason for ${code}`, fatal: false },
});
expect(r.ok).toBe(true);
}
});
it("rejects a similar-but-unregistered modifier profile code", () => {
const r = validateMessage({
...envelope,
type: "error",
payload: {
code: "MODIFIER_PROFILE_UNKNOWN",
message: "x",
fatal: false,
},
});
expect(r.ok).toBe(false);
});
});