1import { describe, expect, it } from 'vitest'; 2import { boothData, cleanBooth, cleanLevels, cleanReplay, escapeHtml, fromFrame, fromPage } from './sandboxProtocol.mjs'; 3 4// The sandbox runs a listener's code, so everything it posts is untrusted. 5// These check the page rebuilds each message from typed, bounded fields and 6// drops anything else. 7 8describe('fromFrame: messages from the sandbox', () => { 9 it('rejects non-messages and unknown types', () => { 10 for (const m of [null, undefined, 'x', 42, [], {}, { type: 'nope' }]) expect(fromFrame(m)).toBeNull(); 11 }); 12 13 it('takes a state message and bounds its fields', () => { 14 expect(fromFrame({ type: 'state', playing: true, cycle: 4.5, cps: 0.5, error: null })).toEqual({ 15 type: 'state', 16 playing: true, 17 cycle: 4.5, 18 cps: 0.5, 19 error: null, 20 run: 0, 21 }); 22 // which play it is about 23 expect(fromFrame({ type: 'state', playing: false, cycle: null, cps: null, error: 'boom', run: 3 })?.run).toBe(3); 24 expect(fromFrame({ type: 'state', playing: false, cycle: null, cps: null, error: null, run: -1 })).toBeNull(); 25 // a non-finite cps becomes null; a wrong-typed field rejects the message 26 expect(fromFrame({ type: 'state', playing: true, cycle: null, cps: 0, error: null })?.cps).toBeNull(); 27 expect(fromFrame({ type: 'state', playing: 'yes', cycle: 0, cps: 1, error: null })).toBeNull(); 28 }); 29 30 it('escapes nothing itself but keeps log text a bounded string', () => { 31 expect(fromFrame({ type: 'log', message: 'hi', kind: 'warning' })).toEqual({ type: 'log', message: 'hi', kind: 'warning' }); 32 expect(fromFrame({ type: 'log', message: 'x'.repeat(999), kind: 'bogus' }).message.length).toBe(500); 33 expect(fromFrame({ type: 'log', message: 'x', kind: 'bogus' }).kind).toBe(''); 34 }); 35 36 it('rebuilds a booth, keeping only its questions’ answers', () => { 37 const raw = { 38 type: 'booth', 39 run: 2, 40 booth: { 41 model: 'jev-1.13.0', 42 about: 'a song', 43 every: 8, 44 segments: 4, 45 last: 3, 46 questions: [ 47 { name: 'section', type: 'choice', instructions: 'pick', criteria: { intro: 'a', drop: 'b' } }, 48 { name: 'energy', type: 'score', instructions: 's', criteria: ['low', 'high'] }, 49 ], 50 form: { name: 'section', sections: ['intro', 'drop'] }, 51 decisions: [ 52 { 53 segment: 0, 54 cycles: '1-8', 55 status: 'answered', 56 values: { 57 section: { choice: 'intro', probabilities: { intro: 0.9, drop: 0.1 } }, 58 energy: { score: 1.4 }, 59 evil: { choice: 'ignored' }, // not a question: dropped 60 }, 61 }, 62 ], 63 reactions: [{ fire: 2, sleep: 1 }], 64 // hostile extras that must not survive the rebuild 65 views: [{ leaked: true }], 66 extra: 'nope', 67 }, 68 }; 69 const m = fromFrame(raw); 70 expect(m.type).toBe('booth'); 71 expect(m.run).toBe(2); 72 expect(m.booth).not.toHaveProperty('extra'); 73 expect(m.booth.questions.map((q) => q.name)).toEqual(['section', 'energy']); 74 expect(m.booth.decisions[0].values.section.choice).toBe('intro'); 75 expect(m.booth.decisions[0].values.energy.score).toBe(1.4); 76 expect(m.booth.decisions[0].values).not.toHaveProperty('evil'); 77 expect(typeof m.booth.react).toBe('undefined'); 78 expect(m.booth).not.toHaveProperty('views'); 79 }); 80 81 it('bounds an ask and rejects an oversized body', () => { 82 expect(fromFrame({ type: 'ask', id: 1, body: '{}', lead: 2.5, attempt: 0 })).toEqual({ 83 type: 'ask', 84 id: 1, 85 body: '{}', 86 lead: 2.5, 87 attempt: 0, 88 }); 89 expect(fromFrame({ type: 'ask', id: 1, body: 'x'.repeat(70000), lead: null, attempt: 0 })).toBeNull(); 90 expect(fromFrame({ type: 'ask', id: -1, body: '{}', lead: null, attempt: 0 })).toBeNull(); 91 }); 92}); 93 94describe('fromPage: messages to the sandbox', () => { 95 it('takes play/stop/resume/react and a well-formed answer', () => { 96 expect(fromPage({ type: 'play', code: 's("bd")' })).toEqual({ type: 'play', code: 's("bd")', run: 0, replay: null }); 97 expect(fromPage({ type: 'play', code: 's("bd")', run: 7 })).toEqual({ type: 'play', code: 's("bd")', run: 7, replay: null }); 98 expect(fromPage({ type: 'play', code: 's("bd")', run: 'x' })).toBeNull(); 99 expect(fromPage({ type: 'stop' })).toEqual({ type: 'stop' }); 100 expect(fromPage({ type: 'react', segment: 3, kind: 'fire' })).toEqual({ type: 'react', segment: 3, kind: 'fire' }); 101 expect(fromPage({ type: 'react', segment: 3, kind: 'boo' })).toBeNull(); 102 expect( 103 fromPage({ type: 'answer', id: 5, status: 200, headers: { 'jev-cache': 'hit' }, body: '{}' }), 104 ).toEqual({ type: 'answer', id: 5, status: 200, headers: { 'jev-cache': 'hit' }, body: '{}' }); 105 }); 106 107 it("carries a listener song's recorded performance to replay, checked, and refuses a malformed one", () => { 108 const replay = { 109 changed: true, 110 jevs: [ 111 { 112 segments: [ 113 { status: 'opening', values: { section: { choice: 'intro', fallback: true, forced: true } } }, 114 { status: 'answered', ms: 812, from: 10, values: { section: { choice: 'drop', confidence: 0.7 }, drums: { score: 2 } } }, 115 { status: 'fallback', values: { drop: { noul: 0.9, fallback: true, absent: true } } }, 116 ], 117 }, 118 ], 119 }; 120 expect(fromPage({ type: 'play', code: 'x', run: 1, replay })).toEqual({ type: 'play', code: 'x', run: 1, replay }); 121 // only what a replay reads survives 122 expect(cleanReplay({ ...replay, extra: 1, jevs: [{ segments: [{ status: 'answered', values: { a: { choice: 'b', evil: 1 } }, x: 2 }] }] })).toEqual({ 123 changed: true, 124 jevs: [{ segments: [{ status: 'answered', values: { a: { choice: 'b' } } }] }], 125 }); 126 for (const bad of [ 127 'x', 128 { jevs: [] }, 129 { jevs: Array(5).fill({ segments: [] }) }, 130 { jevs: [{ segments: [{ status: 'asking', values: {} }] }] }, 131 { jevs: [{ segments: [{ status: 'answered', values: { a: { what: 1 } } }] }] }, 132 { jevs: [{ segments: Array(65).fill({ status: 'answered', values: {} }) }] }, 133 ]) { 134 expect(fromPage({ type: 'play', code: 'x', run: 1, replay: bad }), JSON.stringify(bad).slice(0, 60)).toBeNull(); 135 } 136 }); 137 138 it('rejects code over the cap and a malformed answer', () => { 139 expect(fromPage({ type: 'play', code: 'x'.repeat(2_000_000) })).toBeNull(); 140 expect(fromPage({ type: 'answer', id: 1, status: 99, headers: {}, body: '{}' })).toBeNull(); 141 }); 142}); 143 144describe('cleanLevels and boothData', () => { 145 it('bounds level readings and drops bad parts', () => { 146 expect(cleanLevels(null)).toBeNull(); 147 expect(cleanLevels({ rms: 0.1, peak: 0.2, parts: { riff: { rms: 0.05, peak: 0.1 }, bad: 'x' } })).toEqual({ 148 rms: 0.1, 149 peak: 0.2, 150 parts: { riff: { rms: 0.05, peak: 0.1 } }, 151 }); 152 expect(cleanLevels({ rms: -1, peak: 1 })).toBeUndefined(); 153 }); 154 155 it('boothData strips functions and survives a round trip', () => { 156 const booth = { 157 model: 'm', 158 about: 'a', 159 every: 8, 160 segments: Infinity, 161 questions: [{ name: 'q', type: 'choice', criteria: { a: 'x', b: 'y' } }], 162 form: { name: 'q', sections: ['a'] }, 163 last: null, 164 decisions: [{ segment: 0, cycles: '1-8', status: 'answered', values: { q: { choice: 'a' } } }], 165 reactions: [], 166 react: () => {}, 167 views: [{}], 168 }; 169 const data = boothData(booth); 170 expect(data.segments).toBe(Infinity); 171 expect(data.react).toBeUndefined(); 172 expect(data.views).toBeUndefined(); 173 // and it re-parses to a valid booth 174 expect(cleanBooth(data)?.decisions[0].values.q.choice).toBe('a'); 175 }); 176 177 it('carries each jev() apart, when a late answer played from and where the song ends, and whether it is a replay', () => { 178 const questions = [ 179 { name: 'section', type: 'choice', criteria: { intro: 'x', outro: 'y' } }, 180 { name: 'drums', type: 'score', criteria: ['low', 'high'] }, 181 ]; 182 const form = { name: 'section', sections: ['intro', 'outro'] }; 183 const d = (segment, values, more = {}) => ({ segment, cycles: '1-8', status: 'answered', values, ...more }); 184 const views = [ 185 { model: 'm', about: '', every: 8, segments: 2, questions: [questions[0]], form, last: 1, reactions: [], 186 decisions: [d(0, { section: { choice: 'intro' } }), d(1, { section: { choice: 'outro' } }, { ended: true })] }, 187 { model: 'm', about: '', every: 8, segments: 2, questions: [questions[1]], form: null, last: null, reactions: [], 188 decisions: [d(0, { drums: { score: 1 } }), d(1, { drums: { score: 2 } }, { from: 12 })] }, 189 ]; 190 // boothStore's combine(): the views together, and apart as `jevs` 191 const combined = { ...views[0], questions, decisions: views[0].decisions, jevs: views, replay: { changed: true } }; 192 const booth = cleanBooth(boothData(combined)); 193 expect(booth.replay).toEqual({ changed: true }); 194 expect(booth.jevs).toHaveLength(2); 195 expect(booth.jevs.map((v) => [v.every, v.form, v.model])).toEqual([ 196 [8, { name: 'section' }, 'm'], 197 [8, null, 'm'], 198 ]); 199 expect(booth.jevs[0].decisions[1]).toMatchObject({ ended: true, values: { section: { choice: 'outro' } } }); 200 expect(booth.jevs[1].decisions[1]).toMatchObject({ from: 12, values: { drums: { score: 2 } } }); 201 // a single jev() is its own view; a booth without jevs still works 202 expect(cleanBooth(boothData(views[0])).jevs).toHaveLength(1); 203 expect(cleanBooth({ ...boothData(views[0]), jevs: undefined }).jevs).toBeUndefined(); 204 expect(cleanBooth(boothData(views[0])).replay).toBeNull(); 205 // more than four, or on another cadence: the booth shows, with nothing to record 206 const five = cleanBooth({ ...boothData(combined), jevs: Array(5).fill(boothData(views[0]).jevs[0]) }); 207 expect(five.decisions).toHaveLength(2); 208 expect(five.jevs).toBeUndefined(); 209 expect(cleanBooth({ ...boothData(combined), jevs: [{ ...boothData(views[0]).jevs[0], every: 4 }] }).jevs).toBeUndefined(); 210 }); 211}); 212 213describe('escapeHtml', () => { 214 it('neutralises a listener’s markup', () => { 215 expect(escapeHtml('<img src=x onerror=alert(1)>')).toBe('<img src=x onerror=alert(1)>'); 216 expect(escapeHtml(`a&b"c'd`)).toBe('a&b"c'd'); 217 }); 218});