Allow world.* methods through the relay, validate their payloads, and track per-session world revisions in the session store.
236 lines
15 KiB
TypeScript
236 lines
15 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import {randomUUID} from 'node:crypto';
|
|
import {once} from 'node:events';
|
|
import {createServer} from 'node:http';
|
|
import test from 'node:test';
|
|
import {Server} from 'socket.io';
|
|
import {io, type Socket as Client} from 'socket.io-client';
|
|
import type {ForwardAck, NodeRelayRequest, RelayClaims, RelayMethod, WorldPayloads, WorldVoxelChunk} from '../types.ts';
|
|
import {install_relay_routes} from '../runtime/relay_routes.ts';
|
|
import {create_session_store} from '../runtime/session_store.ts';
|
|
import {DEFAULT_LIMITS} from '../utils/relay_config.ts';
|
|
import {RelayError, check_permissions, success, validate_node_ack, validate_request} from '../utils/relay_validation.ts';
|
|
import {encode_token} from '../utils/token_utils.ts';
|
|
|
|
const input = (): WorldPayloads['world.chunk'] => ({x: -12, z: 7, world_generation: randomUUID(), watch_id: randomUUID()});
|
|
const blocks = (index = 0, position = 4095) => {
|
|
const data = Buffer.alloc(8192);
|
|
data.writeUInt16LE(index, position * 2);
|
|
return data.toString('base64');
|
|
};
|
|
const chunk = (payload = input()): WorldVoxelChunk => ({
|
|
x: payload.x, z: payload.z, revision: randomUUID(), world_generation: payload.world_generation,
|
|
min_y: -64, height: 384,
|
|
palette: [{name: 'minecraft:air', color: 0, properties: {}, occluding: false},
|
|
{name: 'minecraft:stone', color: 0x777777, properties: {}, occluding: true},
|
|
{name: 'minecraft:oak_stairs', color: 0xb88754, properties: {facing: 'east', half: 'bottom', waterlogged: true}, occluding: false}],
|
|
sections: [{y: 0, blocks: blocks(1, 17)}, {y: 1, blocks: blocks(2, 2048)}],
|
|
});
|
|
const request = (payload: unknown) => validate_request({request_id: randomUUID(), method: 'world.chunk', payload}, DEFAULT_LIMITS);
|
|
const ack = (data: unknown, payload: unknown) => validate_node_ack('world.chunk', success(data), 3, DEFAULT_LIMITS, payload);
|
|
const rejected = (action: () => unknown) => assert.throws(action,
|
|
(error: unknown) => error instanceof RelayError && error.code === 'INVALID_REQUEST');
|
|
|
|
test('world.chunk requests require exact coordinates, generation and watch using existing permissions', () => {
|
|
const payload = input();
|
|
request(payload);
|
|
request({...payload, x: -1_875_000, z: 1_875_000});
|
|
request({...payload, x: 1_875_000, z: -1_875_000});
|
|
check_permissions(['bot.snapshot']);
|
|
rejected(() => check_permissions(['world.chunk']));
|
|
for (const key of Object.keys(payload)) {
|
|
const missing = {...payload} as Record<string, unknown>;
|
|
delete missing[key];
|
|
rejected(() => request(missing));
|
|
}
|
|
for (const patch of [{x: -1_875_001}, {z: 1_875_001}, {x: 0.5}, {z: '7'}, {x: NaN}, {z: Infinity},
|
|
{world_generation: 'bad'}, {watch_id: null}, {watch_id: 'bad'}, {world_generation: undefined},
|
|
{revision: randomUUID()}, {bot_id: 3}, {context_generation: randomUUID()}]) {
|
|
rejected(() => request({...payload, ...patch}));
|
|
}
|
|
for (const value of [null, [] as unknown[], 'chunk']) rejected(() => request(value));
|
|
});
|
|
|
|
test('world.chunk accepts empty sparse sections and preserves buried blocks and state in two real sections', () => {
|
|
const payload = input(), data = chunk(payload);
|
|
ack(data, payload);
|
|
assert.equal(Buffer.from(data.sections[0]!.blocks, 'base64').readUInt16LE(17 * 2), 1);
|
|
assert.equal(Buffer.from(data.sections[1]!.blocks, 'base64').readUInt16LE(2048 * 2), 2);
|
|
assert.equal(Buffer.from(data.sections[0]!.blocks, 'base64').readUInt16LE(4095 * 2), 0);
|
|
assert.equal(data.sections[0]!.blocks.length, 10_924);
|
|
ack({...data, sections: []}, payload);
|
|
ack({...data, min_y: -4096, height: 1, sections: [{y: 0, blocks: blocks()}]}, payload);
|
|
ack({...data, min_y: 4096, height: 4096, sections: [{y: 255, blocks: blocks()}]}, payload);
|
|
// The wire contract does not require zero padding beyond a partial section's height.
|
|
ack({...data, height: 17, sections: [{y: 1, blocks: blocks(1)}]}, payload);
|
|
ack({...data, world_generation: data.world_generation.toUpperCase()}, payload);
|
|
});
|
|
|
|
test('world.chunk ACK metadata is exact and matches request coordinates and world generation', () => {
|
|
const payload = input(), data = chunk(payload);
|
|
for (const key of Object.keys(data)) {
|
|
const missing = {...data} as Record<string, unknown>;
|
|
delete missing[key];
|
|
rejected(() => ack(missing, payload));
|
|
}
|
|
for (const patch of [{x: payload.x + 1}, {z: payload.z - 1}, {x: 1_875_001}, {z: 0.5},
|
|
{world_generation: randomUUID()}, {world_generation: 'bad'}, {revision: 'bad'},
|
|
{min_y: -4097}, {min_y: 4097}, {min_y: 0.5}, {height: 0}, {height: 4097}, {height: 1.5},
|
|
{watch_id: payload.watch_id}, {stream_id: randomUUID()}, {context_generation: randomUUID()}, {extra: true}]) {
|
|
rejected(() => ack({...data, ...patch}, payload));
|
|
}
|
|
for (const value of [null, [], {}, undefined]) rejected(() => ack(value, payload));
|
|
for (const value of [undefined, {}, {...payload, x: payload.x + 1}, {...payload, world_generation: randomUUID()}]) {
|
|
rejected(() => ack(data, value));
|
|
}
|
|
validate_node_ack('world.chunk', {status: 'error', data: {code: 'RESYNC_REQUIRED', message: 'Stale watch', retryable: true}}, 3, DEFAULT_LIMITS, payload);
|
|
});
|
|
|
|
test('world.chunk palette entries require bounded colors, names, occlusion and exact fields', () => {
|
|
const payload = input(), data = chunk(payload), block = data.palette[1]!;
|
|
const accept = (palette: unknown) => ack({...data, palette, sections: []}, payload);
|
|
accept([{...block, name: 'a', color: 0}]);
|
|
accept([{...block, name: 'x'.repeat(128), color: 0xffffff}]);
|
|
for (const palette of [[], null, {}, Array.from({length: 8193}, () => block)]) rejected(() => accept(palette));
|
|
for (const key of Object.keys(block)) {
|
|
const missing = {...block} as Record<string, unknown>;
|
|
delete missing[key];
|
|
rejected(() => accept([missing]));
|
|
}
|
|
for (const patch of [{name: ''}, {name: 'x'.repeat(129)}, {color: -1}, {color: 0x1000000}, {color: 0.5},
|
|
{occluding: 1}, {occluding: 'true'}, {extra: true}, {shape: 'cube'}, {properties: [] as unknown[]}, {properties: null}]) {
|
|
rejected(() => accept([{...block, ...patch}]));
|
|
}
|
|
});
|
|
|
|
test('world.chunk properties accept canonical state keys and bounded primitives without a semantic whitelist', () => {
|
|
const payload = input(), data = chunk(payload);
|
|
const accept = (properties: unknown) => ack({...data, palette: [{...data.palette[0], properties}], sections: []}, payload);
|
|
accept({custom_block_state: 'unknown_but_valid', rotation: 65_536, min: -65_536, solid: true, empty: '', zero: 0});
|
|
accept(Object.fromEntries(Array.from({length: 32}, (_, i) => [`property_${i}`, i % 2 === 0 ? 'x'.repeat(64) : false])));
|
|
accept({['k'.repeat(32)]: true});
|
|
for (const properties of [
|
|
Object.fromEntries(Array.from({length: 33}, (_, i) => [`p${i}`, true])),
|
|
{'': true}, {'Facing': 'east'}, {'minecraft:axis': 'x'}, {'a-b': true}, {'state\n': true}, {['k'.repeat(33)]: true},
|
|
{key: 'x'.repeat(65)}, {key: 65_537}, {key: -65_537}, {key: 0.5}, {key: null}, {key: [] as unknown[]}, {key: {}},
|
|
{key: undefined}, {key: NaN}, {key: Infinity},
|
|
]) rejected(() => accept(properties));
|
|
const getter = Object.defineProperty({}, 'state', {enumerable: true, get() {throw new Error('must not execute');}});
|
|
rejected(() => accept(getter));
|
|
rejected(() => accept(Object.defineProperty({}, 'state', {value: true})));
|
|
rejected(() => accept({[Symbol('state')]: true}));
|
|
});
|
|
|
|
test('world.chunk sections are sparse, unique, ascending and bounded by height', () => {
|
|
const payload = input(), data = chunk(payload), section = data.sections[0]!;
|
|
ack({...data, sections: [{...section, y: 3}, {...section, y: 23}]}, payload);
|
|
for (const sections of [null, {}, [section, section], [{...section, y: 1}, section], [{...section, y: -1}],
|
|
[{...section, y: 24}], [{...section, y: 0.5}], [{...section, y: '0'}], [{blocks: section.blocks}],
|
|
[{y: 0}], [{...section, extra: true}], Array.from({length: 257}, (_, y) => ({...section, y}))]) {
|
|
rejected(() => ack({...data, sections}, payload));
|
|
}
|
|
rejected(() => ack({...data, height: 16, sections: [{...section, y: 1}]}, payload));
|
|
rejected(() => ack({...data, sections: new Array(1)}, payload));
|
|
});
|
|
|
|
test('world.chunk section blocks enforce canonical base64, exact UInt16LE byte count and all palette indices', () => {
|
|
const payload = input(), data = chunk(payload), encoded = blocks();
|
|
const accept = (value: unknown) => ack({...data, sections: [{y: 0, blocks: value}]}, payload);
|
|
const noncanonical = `${encoded.slice(0, -2)}B=`;
|
|
assert.equal(Buffer.from(noncanonical, 'base64').toString('base64'), encoded);
|
|
for (const value of ['', encoded.slice(0, -1), `${encoded}\n`, 'A'.repeat(10_924), Buffer.alloc(8191).toString('base64'),
|
|
Buffer.alloc(8193).toString('base64'), noncanonical, `_${encoded.slice(1)}`, Buffer.alloc(8192), new Uint16Array(4096)]) {
|
|
rejected(() => accept(value));
|
|
}
|
|
for (const position of [0, 17, 2048, 4095]) {
|
|
accept(blocks(data.palette.length - 1, position));
|
|
rejected(() => accept(blocks(data.palette.length, position)));
|
|
rejected(() => accept(blocks(65_535, position)));
|
|
}
|
|
});
|
|
|
|
test('world.chunk maximum palette fits dedicated world complexity budget and preserves 8 MiB byte limits', () => {
|
|
const payload = input(), data = chunk(payload);
|
|
const properties = Object.fromEntries(Array.from({length: 32}, (_, index) => [`p${index}`, true]));
|
|
const maximum: WorldVoxelChunk = {...data, height: 4096,
|
|
palette: Array.from({length: 8192}, (_, index) => ({name: `block_${index}`, color: index, properties, occluding: true})),
|
|
sections: Array.from({length: 256}, (_, y) => ({y, blocks: blocks(8191)}))};
|
|
ack(maximum, payload);
|
|
assert.ok(Buffer.byteLength(JSON.stringify(success(maximum))) < 8 * 1024 * 1024);
|
|
const size = Buffer.byteLength(JSON.stringify(success(data)));
|
|
validate_node_ack('world.chunk', success(data), 3, {...DEFAULT_LIMITS, max_payload_bytes: size}, payload);
|
|
rejected(() => validate_node_ack('world.chunk', success(data), 3, {...DEFAULT_LIMITS, max_payload_bytes: size - 1}, payload));
|
|
const oversized = {...maximum, palette: maximum.palette.map(block => ({...block,
|
|
properties: Object.fromEntries(Object.keys(properties).map(key => [key, 'x'.repeat(64)]))}))};
|
|
rejected(() => ack(oversized, payload));
|
|
rejected(() => validate_node_ack('bot.snapshot', success(maximum), 3, DEFAULT_LIMITS));
|
|
});
|
|
|
|
test('world.chunk uses private correlated ACKs, snapshot permission on both roles and fresh validated reads', async t => {
|
|
const key = 'world-voxel-relay-test-secret';
|
|
const scope = {forward_id: 1, forward_session_id: randomUUID(), node_session_id: randomUUID(), node_id: 2, bot_id: 3};
|
|
const http = createServer(), server = new Server(http), namespace = server.of('/relay');
|
|
const store = create_session_store({forward_id: 1, key, limits: DEFAULT_LIMITS, on_status() {}});
|
|
const dispose = install_relay_routes(namespace, store), clients: Client[] = [];
|
|
t.after(async () => {store.close(); dispose(); for (const client of clients) client.close(); await server.close();});
|
|
http.listen(0, '127.0.0.1');
|
|
await once(http, 'listening');
|
|
store.open({...scope, lease_expires_at: Date.now() + 60_000});
|
|
const send = (socket: Client, event: string, value: unknown): Promise<ForwardAck<unknown>> => socket.timeout(3000).emitWithAck(event, value);
|
|
const connect = async (role: 'web' | 'node', permissions: RelayMethod[] = ['bot.snapshot']) => {
|
|
const socket = io(`http://127.0.0.1:${(http.address() as {port: number}).port}/relay`,
|
|
{transports: ['websocket'], forceNew: true, reconnection: false});
|
|
clients.push(socket);
|
|
await new Promise<void>(resolve => socket.once('connect', resolve));
|
|
const now = Date.now(), grant_id = randomUUID(), expires_at = now + 50_000;
|
|
if (role === 'web') store.authorize({forward_session_id: scope.forward_session_id, grant_id, user_id: 4, permissions, expires_at});
|
|
const claims: RelayClaims = {...scope, v: 1, role, permissions, issued_at: now, join_before: now + 10_000,
|
|
expires_at, jti: randomUUID(), ...(role === 'web' ? {grant_id, user_id: 4} : {})};
|
|
assert.equal((await send(socket, 'join_room', {token: encode_token(key, claims)})).status, 'success');
|
|
if (role === 'node') assert.equal((await send(socket, 'forward_node_ready', {forward_session_id: scope.forward_session_id})).status, 'success');
|
|
return socket;
|
|
};
|
|
const node = await connect('node'), web = await connect('web'), observer = await connect('web');
|
|
const limited = await connect('web', ['bot.logs']);
|
|
const payload = input(), command = {request_id: randomUUID(), method: 'world.chunk', payload};
|
|
const data = chunk(payload);
|
|
let response: unknown = success(data), calls = 0;
|
|
const leaked: string[] = [];
|
|
observer.onAny(event => leaked.push(event));
|
|
node.on('relay_request', (value: NodeRelayRequest, reply: (result: unknown) => void) => {
|
|
calls++;
|
|
assert.equal(value.method, 'world.chunk');
|
|
assert.deepEqual(value.payload, payload);
|
|
assert.equal(value.forward_session_id, scope.forward_session_id);
|
|
assert.notEqual(value.correlation_id, value.request_id);
|
|
assert.ok(value.deadline_at > Date.now());
|
|
reply(response);
|
|
});
|
|
const error = (value: ForwardAck<unknown>, expected: string) => {
|
|
assert.equal(value.status, 'error');
|
|
if (value.status === 'error') assert.equal(value.data.code, expected);
|
|
};
|
|
store.set_control_available(false);
|
|
assert.deepEqual(await send(web, 'relay_request', command), response);
|
|
response = success({...data, revision: randomUUID(), sections: []});
|
|
assert.deepEqual(await send(web, 'relay_request', command), response);
|
|
assert.equal(calls, 2);
|
|
for (const patch of [{x: data.x + 1}, {z: data.z + 1}, {world_generation: randomUUID()}, {sections: [{y: 0, blocks: blocks(3)}]}]) {
|
|
response = success({...data, ...patch});
|
|
error(await send(web, 'relay_request', command), 'INVALID_REQUEST');
|
|
}
|
|
response = {status: 'error', data: {code: 'RESYNC_REQUIRED', message: 'Stale watch', retryable: true}};
|
|
assert.deepEqual(await send(web, 'relay_request', command), response);
|
|
response = success(data);
|
|
assert.deepEqual(await send(web, 'relay_request', command), response);
|
|
const before = calls;
|
|
error(await send(limited, 'relay_request', command), 'FORBIDDEN');
|
|
error(await send(node, 'relay_request', command), 'FORBIDDEN');
|
|
assert.equal(calls, before);
|
|
assert.deepEqual(leaked, []);
|
|
store.set_control_available(true);
|
|
await connect('node', ['bot.logs']);
|
|
error(await send(web, 'relay_request', command), 'FORBIDDEN');
|
|
assert.equal(calls, before);
|
|
});
|