jevstrudel.git / website / src / jev / sandboxProtocol.test.mjs
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('&lt;img src=x onerror=alert(1)&gt;');
216    expect(escapeHtml(`a&b"c'd`)).toBe('a&amp;b&quot;c&#39;d');
217  });
218});