| 1 |
// Pins the save-bridge contract: register/unregister, saveSelected is a |
| 2 |
// no-op without a registered saver, the agent-submit window event fires |
| 3 |
// the saver with alsoClear: true. |
| 4 |
|
| 5 |
import { |
| 6 |
AGENT_SUBMIT_EVENT, |
| 7 |
hasSaver, |
| 8 |
registerSaver, |
| 9 |
saveSelected, |
| 10 |
unregisterSaver, |
| 11 |
} from '@quick-edit/lib/save-bridge'; |
| 12 |
|
| 13 |
afterEach(() => { |
| 14 |
unregisterSaver(unregisterSaver); |
| 15 |
}); |
| 16 |
|
| 17 |
describe('save-bridge', () => { |
| 18 |
it('hasSaver is false before any registration', () => { |
| 19 |
expect(hasSaver()).toBe(false); |
| 20 |
}); |
| 21 |
|
| 22 |
it('register + saveSelected fires the registered fn', async () => { |
| 23 |
const fn = jest.fn().mockResolvedValue('ok'); |
| 24 |
registerSaver(fn); |
| 25 |
expect(hasSaver()).toBe(true); |
| 26 |
await saveSelected(); |
| 27 |
expect(fn).toHaveBeenCalledTimes(1); |
| 28 |
expect(fn).toHaveBeenCalledWith({}); |
| 29 |
unregisterSaver(fn); |
| 30 |
}); |
| 31 |
|
| 32 |
it('saveSelected forwards options through to the saver', async () => { |
| 33 |
const fn = jest.fn().mockResolvedValue('ok'); |
| 34 |
registerSaver(fn); |
| 35 |
await saveSelected({ alsoClear: false }); |
| 36 |
expect(fn).toHaveBeenCalledWith({ alsoClear: false }); |
| 37 |
unregisterSaver(fn); |
| 38 |
}); |
| 39 |
|
| 40 |
it('unregister leaves saveSelected a no-op', async () => { |
| 41 |
const fn = jest.fn(); |
| 42 |
registerSaver(fn); |
| 43 |
unregisterSaver(fn); |
| 44 |
expect(hasSaver()).toBe(false); |
| 45 |
await saveSelected(); |
| 46 |
expect(fn).not.toHaveBeenCalled(); |
| 47 |
}); |
| 48 |
|
| 49 |
it('agent-submit window event fires the saver with alsoClear: true', () => { |
| 50 |
const fn = jest.fn(); |
| 51 |
registerSaver(fn); |
| 52 |
window.dispatchEvent(new CustomEvent(AGENT_SUBMIT_EVENT)); |
| 53 |
expect(fn).toHaveBeenCalledWith({ alsoClear: true }); |
| 54 |
unregisterSaver(fn); |
| 55 |
}); |
| 56 |
|
| 57 |
// Same-origin trigger surface: the agent-submit listener |
| 58 |
// must NOT read event.detail. A hostile same-origin script (a second |
| 59 |
// plugin, a stored-XSS elsewhere) can dispatch this event, but it can only |
| 60 |
// flush the editor's already-staged content — never inject its own. If the |
| 61 |
// listener ever forwarded e.detail, an attacker could choose what gets |
| 62 |
// saved without the user's intent. Dispatch a poisoned payload and assert |
| 63 |
// the saver still receives exactly { alsoClear: true } and nothing else. |
| 64 |
it('agent-submit ignores attacker-supplied event detail', () => { |
| 65 |
const fn = jest.fn(); |
| 66 |
registerSaver(fn); |
| 67 |
window.dispatchEvent( |
| 68 |
new CustomEvent(AGENT_SUBMIT_EVENT, { |
| 69 |
detail: { |
| 70 |
alsoClear: false, |
| 71 |
rawBlock: '<img src=x onerror=alert(1)>', |
| 72 |
source: { kind: 'post', id: 999 }, |
| 73 |
}, |
| 74 |
}), |
| 75 |
); |
| 76 |
expect(fn).toHaveBeenCalledTimes(1); |
| 77 |
const args = fn.mock.calls[0][0]; |
| 78 |
expect(args).toEqual({ alsoClear: true }); |
| 79 |
expect(args).not.toHaveProperty('rawBlock'); |
| 80 |
expect(args).not.toHaveProperty('source'); |
| 81 |
unregisterSaver(fn); |
| 82 |
}); |
| 83 |
|
| 84 |
it('agent-submit event is a no-op when no saver is registered', () => { |
| 85 |
expect(() => { |
| 86 |
window.dispatchEvent(new CustomEvent(AGENT_SUBMIT_EVENT)); |
| 87 |
}).not.toThrow(); |
| 88 |
}); |
| 89 |
|
| 90 |
it('register twice replaces — only the latest saver fires', () => { |
| 91 |
const first = jest.fn(); |
| 92 |
const second = jest.fn(); |
| 93 |
registerSaver(first); |
| 94 |
registerSaver(second); |
| 95 |
window.dispatchEvent(new CustomEvent(AGENT_SUBMIT_EVENT)); |
| 96 |
expect(first).not.toHaveBeenCalled(); |
| 97 |
expect(second).toHaveBeenCalledTimes(1); |
| 98 |
unregisterSaver(second); |
| 99 |
}); |
| 100 |
|
| 101 |
it('unregister of a different fn does not clear the active saver', async () => { |
| 102 |
const active = jest.fn(); |
| 103 |
const ghost = jest.fn(); |
| 104 |
registerSaver(active); |
| 105 |
unregisterSaver(ghost); |
| 106 |
expect(hasSaver()).toBe(true); |
| 107 |
await saveSelected(); |
| 108 |
expect(active).toHaveBeenCalledTimes(1); |
| 109 |
unregisterSaver(active); |
| 110 |
}); |
| 111 |
}); |
| 112 |
|