Files
puter/src/puter-js/src/lib/xdrpc.test.js

200 lines
7.3 KiB
JavaScript

import { describe, expect, it } from 'vitest';
import { CallbackManager, Dehydrator, Hydrator } from './xdrpc.js';
/**
* Build `{ [key]: value }` with `key` as a real own property — an object
* literal can't spell that for `__proto__`, but a message from the other
* document (or any JSON payload) carries it just fine.
*/
const withOwnKey = (key, value) =>
JSON.parse(`{"${key}":${JSON.stringify(value)}}`);
describe('xdrpc prototype safety', () => {
const prototypeKeys = ['__proto__', 'constructor', 'toString'];
it.each(prototypeKeys)(
'hydrate keeps a `%s` key as data on the rebuilt object',
(key) => {
const hydrator = new Hydrator({ target: { postMessage: () => {} } });
const result = hydrator.hydrate(
withOwnKey(key, { escalated: true }),
);
expect(Object.getPrototypeOf(result)).toBe(Object.prototype);
expect(result.escalated).toBeUndefined();
expect(Object.hasOwn(result, key)).toBe(true);
},
);
it.each(prototypeKeys)(
'dehydrate keeps a `%s` key as data on the rebuilt object',
(key) => {
const dehydrator = new Dehydrator({
callbackManager: new CallbackManager(),
});
const result = dehydrator.dehydrate(
withOwnKey(key, { escalated: true }),
);
expect(Object.getPrototypeOf(result)).toBe(Object.prototype);
expect(result.escalated).toBeUndefined();
expect(Object.hasOwn(result, key)).toBe(true);
},
);
it('leaves the shared prototype untouched', () => {
const hydrator = new Hydrator({ target: { postMessage: () => {} } });
hydrator.hydrate(withOwnKey('__proto__', { escalated: true }));
expect({}.escalated).toBeUndefined();
});
it('still round-trips ordinary values and callback stubs', () => {
const callbackManager = new CallbackManager();
const dehydrated = new Dehydrator({ callbackManager }).dehydrate({
name: 'x',
nested: { list: [1, 2], fn: () => 'called' },
});
expect(dehydrated).toMatchObject({
name: 'x',
nested: { list: [1, 2] },
});
const posted = [];
const hydrated = new Hydrator({
target: { postMessage: (msg) => posted.push(msg) },
}).hydrate(dehydrated);
expect(hydrated.name).toBe('x');
expect(hydrated.nested.list).toEqual([1, 2]);
hydrated.nested.fn('arg');
expect(posted).toHaveLength(1);
expect(posted[0].args).toEqual(['arg']);
});
});
describe('xdrpc callback source binding', () => {
const $SCOPE = '9a9c83a4-7897-43a0-93b9-53217b84fde6';
/** A stand-in for `globalThis` that lets a test deliver a message event. */
const fakeWindow = () => {
const handlers = [];
return {
addEventListener: (type, handler) =>
type === 'message' && handlers.push(handler),
deliver: event => handlers.forEach(handler => handler(event)),
};
};
const register = ({ source }) => {
const manager = new CallbackManager();
const calls = [];
const id = manager.register_callback((...args) => calls.push(args), source);
const listener = fakeWindow();
manager.attach_to_source(listener);
return { calls, id, listener };
};
it('invokes a callback for a message from its registered source', () => {
const gui = {};
const { calls, id, listener } = register({ source: gui });
listener.deliver({ source: gui, data: { $SCOPE, id, args: ['ok'] } });
expect(calls).toEqual([['ok']]);
});
it('pins dehydrated callbacks to the configured origin as well as source', () => {
const gui = {};
const manager = new CallbackManager();
const listener = fakeWindow();
manager.attach_to_source(listener);
const calls = [];
const dehydrator = new Dehydrator({
callbackManager: manager, source: gui, origin: 'https://gui.test',
});
const { id } = dehydrator.dehydrate((value) => calls.push(value));
const data = { $SCOPE, id, args: ['ok'] };
listener.deliver({ source: gui, origin: 'https://other.test', data });
expect(calls).toEqual([]);
listener.deliver({ source: gui, origin: 'https://gui.test', data });
expect(calls).toEqual(['ok']);
});
it('ignores the same message from another window', () => {
const gui = {};
const { calls, id, listener } = register({ source: gui });
listener.deliver({
source: { sibling: true },
data: { $SCOPE, id, args: ['forged'] },
});
expect(calls).toEqual([]);
});
it('ignores a callback registered without a source', () => {
const { calls, id, listener } = register({ source: undefined });
listener.deliver({ source: {}, data: { $SCOPE, id, args: [] } });
expect(calls).toEqual([]);
});
it('hands out ids that cannot be guessed from an earlier one', () => {
const manager = new CallbackManager();
const ids = Array.from({ length: 5 }, () =>
manager.register_callback(() => {}, {}),
);
expect(new Set(ids).size).toBe(ids.length);
for ( const id of ids ) {
expect(id).toMatch(/^[0-9a-f]{32}$/);
}
});
});
describe('xdrpc callback lifetime', () => {
const $SCOPE = '9a9c83a4-7897-43a0-93b9-53217b84fde6';
const setup = () => {
const handlers = [];
const manager = new CallbackManager();
manager.attach_to_source({
addEventListener: (type, handler) =>
type === 'message' && handlers.push(handler),
});
const deliver = event => handlers.forEach(handler => handler(event));
return { manager, deliver };
};
it('drops a one-shot callback after it fires', () => {
const gui = {};
const { manager, deliver } = setup();
const calls = [];
const id = manager.register_callback(v => calls.push(v), gui, { once: true });
deliver({ source: gui, data: { $SCOPE, id, args: ['first'] } });
deliver({ source: gui, data: { $SCOPE, id, args: ['second'] } });
expect(calls).toEqual(['first']);
expect(manager.callbacks.size).toBe(0);
});
it('keeps a one-shot callback a forged message failed to invoke', () => {
const gui = {};
const { manager, deliver } = setup();
const calls = [];
const id = manager.register_callback(v => calls.push(v), gui, { once: true });
deliver({ source: { sibling: true }, data: { $SCOPE, id, args: ['forged'] } });
deliver({ source: gui, data: { $SCOPE, id, args: ['real'] } });
expect(calls).toEqual(['real']);
});
it('keeps a dehydrated callback for repeat calls', () => {
const gui = {};
const { manager, deliver } = setup();
const calls = [];
const { id } = new Dehydrator({ callbackManager: manager, source: gui })
.dehydrate(() => calls.push('clicked'));
deliver({ source: gui, data: { $SCOPE, id, args: [] } });
deliver({ source: gui, data: { $SCOPE, id, args: [] } });
expect(calls).toEqual(['clicked', 'clicked']);
expect(manager.callbacks.size).toBe(1);
});
});