jevstrudel.git / worker / src / data.test.ts

The data tab's routes (data.ts) against real SQL (testD1), with every store the site has holding something: that each answers what it says, people included, and that no secret value ever appears in any answer.

4import { beforeEach, describe, expect, it } from 'vitest';
5import { memoryBudgets } from '../test/budget';
6import { testD1 } from '../test/d1';
7import { memoryKV } from '../test/kv';
8import { memoryR2 } from '../test/r2';
9import { memoryTabHubs } from '../test/tab-hubs';
10import { d1Accounts } from './accounts-store';
11import { d1Content } from './content-store';
12import { data, forgetStoreCounts, MAX_LIMIT } from './data';
13import { d1Listening } from './listening-store';
14import { live, note, type Storage as DirectoryStorage } from './party-directory';
15import { d1Radio } from './radio';
16import { newSessionToken, SESSION_COOKIE, SESSION_TTL_MS, tokenHash } from './session';
17import { d1Votes } from './votes-store';
19const ORIGIN = 'https://jevstrudel.example';
20const NOON = Date.UTC(2026, 8, 27, 12);

Every secret the stores hold: none may appear in any answer.

23const SESSION_TOKEN = newSessionToken();
24const CREDENTIAL_ID = 'secret-credential-id-AAAAAAAAAAAAAAA';
25const PUBLIC_KEY = new Uint8Array([0xde, 0xad, 0xbe, 0xef, 0x51, 0x3c, 0x7e, 0x70]);
26const CHALLENGE = 'c'.repeat(20) + 'SECRETCHALLENGE' + 'c'.repeat(8);
27const GRANT_VALUE = 'grant-props-ENCRYPTED-SECRET-token-hash';
28const PARTY_ROOM_ID = 'f'.repeat(60) + 'abcd';
29const CACHE_HASH = 'a1b2c3'.repeat(10) + 'dead';
31let db: D1Database;
32let kvCache: ReturnType<typeof memoryKV>;
33let kvOauth: ReturnType<typeof memoryKV>;
34let r2: ReturnType<typeof memoryR2>;
35let hubs: ReturnType<typeof memoryTabHubs>;
36let directoryStore: Map<string, unknown>;
37let limited: boolean;
38let user: { id: string; cookie: string };
39let other: { id: string; cookie: string };
40
41const directoryStorage: () => DirectoryStorage = () => ({
42  get: async <T,>(k: string) => directoryStore.get(k) as T | undefined,
43  put: async (k: string, v: unknown) => void directoryStore.set(k, v),
44  delete: async (k: string) => directoryStore.delete(k),
45  list: async <T,>({ prefix }: { prefix: string }) =>
46    new Map([...directoryStore].filter(([k]) => k.startsWith(prefix)) as [string, T][]),
47});
48
49const env = () =>
50  ({
51    DB: db,
52    CACHE: kvCache,
53    OAUTH_KV: kvOauth,
54    COVERS: r2,
55    BUDGET: memoryBudgets(1000, () => NOON),
56    TAB_HUB: hubs,
57    JEV_DAILY_PER_USER: 1000,
58    DATA_LIMIT: { limit: async () => ({ success: !limited }) },
59    LOBBY: { idFromName: () => ({}), get: () => ({ summary: async () => ({ open: 3, listed: 2, playing: 1 }) }) },
60    PARTY_DIRECTORY: {
61      idFromName: () => ({}),
62      get: () => ({ live: async () => live(directoryStorage(), NOON) }),
63    },
64  }) as never;
65
66const texts: string[] = [];
67async function get(path: string, who: { cookie: string } | null = null) {
68  const res = await data(
69    new Request(`${ORIGIN}/jev/data${path}`, {
70      headers: { 'Sec-Fetch-Site': 'same-origin', ...(who ? { Cookie: who.cookie, Origin: ORIGIN } : {}) },
71    }),
72    env(),
73    () => NOON,
74  );
75  const text = await res.text();
76  texts.push(text);
77  return { status: res.status, cache: res.headers.get('Cache-Control'), body: JSON.parse(text) };
78}
79
80async function person(name: string, credentialId: string, token = newSessionToken()) {
81  const id = newSessionToken().slice(0, 22);
82  await d1Accounts(db, () => NOON).createUser(
83    { id, displayName: name },
84    { id: credentialId, publicKey: PUBLIC_KEY, signCount: 3, transports: ['internal'] },
85    token,
86    SESSION_TTL_MS,
87  );
88  return { id, cookie: `${SESSION_COOKIE}=${token}` };
89}
90
91beforeEach(async () => {
92  db = testD1();
93  kvCache = memoryKV();
94  kvOauth = memoryKV();
95  r2 = memoryR2();
96  hubs = memoryTabHubs();
97  directoryStore = new Map();
98  limited = false;
99  forgetStoreCounts();
100  texts.length = 0;
101
102  user = await person('Link', CREDENTIAL_ID, SESSION_TOKEN);
103  other = await person('Zelda', 'another-credential-BBBBBBBBBBBB');
104  const accounts = d1Accounts(db, () => NOON);
105  await accounts.putChallenge({ challenge: CHALLENGE, purpose: 'login', userId: null, displayName: null }, 60_000);
106  await d1Radio(db, () => NOON).add(user.id, 'jev/dial-up');
107  await d1Votes(db).add({ a: 'jev/dial-up', b: 'jev/hey-listen', pick: 'jev/dial-up' });
108  const listening = d1Listening(db);
109  await listening.react({ song: 'jev/dial-up', section: 'intro', reaction: 'fire' });
110  await listening.record('P'.repeat(22), '2026-09-27', {
111    song: 'jev/dial-up',
112    codeHash: 'e'.repeat(64),
113    model: 'jev-1.13.0',
114    every: 4,
115    ended: 'finished',
116    form: { jev: 0, question: 'section' },
117    jevs: [
118      {
119        segments: [
120          { status: 'opening', values: { section: { choice: 'intro' } } },
121          { status: 'answered', ms: 90, values: { section: { choice: 'drop', confidence: 0.8 } } },
122        ],
123      },
124    ],
125  }, { at: NOON - 5000, player: user.id });
126  const content = d1Content(db, () => NOON);
127  await content.createSong(user.id, 'S'.repeat(22), 'R'.repeat(22), { title: 'Fine song', description: '', spec: '', code: 's("bd")' });
128  await content.recordScreen('revision', 'R'.repeat(22), 'fine', 0.9);
129  await content.createSong(other.id, 'T'.repeat(22), 'Q'.repeat(22), { title: 'Held song', description: 'BUY NOW', spec: '', code: 's("hh")' });
130  await content.recordScreen('revision', 'Q'.repeat(22), 'spam', 0.97);
131  await content.addComment('C'.repeat(22), other.id, { site: 'jev/dial-up' }, 'I will hurt');
132  await content.recordScreen('comment', 'C'.repeat(22), 'abuse', 0.8);
133  await content.addPitch('I'.repeat(22), user.id, 'a song about Navi');
134  await r2.put(`covers/${'V'.repeat(22)}`, new Uint8Array(10));
135  await content.putCover({ id: 'V'.repeat(22), song: 'S'.repeat(22), userId: user.id, contentType: 'image/png', bytes: 10, alt: 'a fairy' });
136  await kvCache.put(`jev.relay/v1/jev-1.13.0/${CACHE_HASH}`, '{"answers":{}}');
137  await kvOauth.put(`grant:${other.id}:g1`, GRANT_VALUE);
138  await kvOauth.put(`grant:${other.id}:g2`, GRANT_VALUE);
139  await kvOauth.put('token:abc', 'SECRET-TOKEN-VALUE');
140  await note(directoryStorage(), PARTY_ROOM_ID, { people: 2, host: true, song: 'jev/dial-up' }, NOON - 1000);
141  hubs.open(user.id, 'tab-1', () => ({}) as never);
142});
143
144describe('/jev/data', () => {
145  it('totals every store', async () => {
146    const { status, cache, body } = await get('');
147    expect(status).toBe(200);
148    expect(cache).toBe('no-store');
149    expect(body.d1.users).toBe(2);
150    expect(body.d1.credentials).toBe(2);
151    expect(body.d1.sessions).toEqual({ rows: 2, active: 2 });
152    expect(body.d1.challenges).toEqual({ login: { rows: 1, live: 1 } });
153    expect(body.d1.radioPlays).toBe(1);
154    expect(body.d1.votes).toEqual({ rows: 1, count: 1 });
155    expect(body.d1.reactions).toEqual({ rows: 1, count: 1 });
156    expect(body.d1.performances).toBe(1);
157    expect(body.d1.segments).toEqual({ rows: 2, fallbacks: 0 });
158    expect(body.d1.content.revisions).toEqual({ fine: 1, spam: 1 });
159    expect(body.d1.content.comments).toEqual({ abuse: 1 });
160    expect(body.d1.content.pitches).toEqual({ pending: 1 });
161    expect(body.d1.content.covers).toEqual({ pending: 1 });
162    expect(body.live.lobby).toEqual({ open: 3, listed: 2, playing: 1 });
163    expect(body.live.parties).toEqual([{ people: 2, host: true, song: 'jev/dial-up', since: NOON - 1000, listed: false }]);
164    expect(body.kv.answerCache).toEqual({ entries: 1, complete: true });
165    expect(body.kv.oauthGrants).toEqual({ grants: 2, accounts: 1, complete: true });
166    expect(body.r2.covers).toEqual({ objects: 1, bytes: 10, complete: true });
167  });
168
169  it('lists every account, and shows one whole', async () => {
170    const { body } = await get('/accounts');
171    expect(body.total).toBe(2);
172    const zelda = body.accounts.find((a: { name: string }) => a.name === 'Zelda');
173    expect(zelda).toMatchObject({ passkeys: 1, sessions: 1, songs: 1, comments: 1, apps: 2 });
174
175    const link = (await get(`/accounts/${user.id}`)).body;
176    expect(link.name).toBe('Link');
177    expect(link.passkeys).toEqual([{ created: NOON, signCount: 3, transports: ['internal'] }]);
178    expect(link.sessions).toEqual([{ created: NOON, expires: NOON + SESSION_TTL_MS, active: true }]);
179    expect(link.radio).toEqual([{ song: 'jev/dial-up', at: NOON }]);
180    // its takes: who played what, public like every row
181    expect(link.takes).toEqual([
182      expect.objectContaining({ id: 'P'.repeat(22), song: 'jev/dial-up', title: null, at: NOON - 5000, player: { id: user.id, name: 'Link' } }),
183    ]);
184    expect((await get(`/accounts/${other.id}`)).body.takes).toEqual([]);
185    expect(link.content.pitches[0]).toMatchObject({ body: 'a song about Navi', status: 'pending' });
186    expect(link.budget).toMatchObject({ used: 0, limit: 1000 });
187    expect(link.openTabs).toBe(1);
188    // another's apps: a count only
189    expect(link.apps).toEqual({ count: 0 });
190    // held content, with its verdict
191    const held = (await get(`/accounts/${other.id}`)).body;
192    expect(held.content.revisions[0]).toMatchObject({ title: 'Held song', status: 'held', verdict: 'spam' });
193    expect(held.apps).toEqual({ count: 2 });
194    expect((await get('/accounts/nobody-like-this-aaaaa')).status).toBe(404);
195  });
196
197  it('names an account’s own apps to itself only', async () => {
198    const mine = (await get(`/accounts/${user.id}`, user)).body;
199    expect(mine.apps).toEqual({ count: 0, yours: [] });
200    const theirs = (await get(`/accounts/${user.id}`, other)).body;
201    expect(theirs.apps).not.toHaveProperty('yours');
202  });
203
204  it('lists songs public or not, and a held one whole, as text', async () => {
205    const { body } = await get('/songs');
206    expect(body.total).toBe(2);
207    expect(body.songs.map((s: { newest: { title: string }; public: boolean }) => [s.newest.title, s.public])).toEqual(
208      expect.arrayContaining([
209        ['Fine song', true],
210        ['Held song', false],
211      ]),
212    );
213    const held = (await get(`/songs/${'T'.repeat(22)}`)).body;
214    expect(held.author.name).toBe('Zelda');
215    expect(held.revisions[0]).toMatchObject({ rev: 1, code: 's("hh")', description: 'BUY NOW', screen: 'spam', confidence: 0.97 });
216  });
217
218  it('lists covers, comments, pitches, the jobs, votes and reactions', async () => {
219    const covers = (await get('/covers')).body;
220    expect(covers.covers[0]).toMatchObject({ alt: 'a fairy', screen: 'pending', served: false, author: { name: 'Link' } });
221    expect((await get('/comments')).body.comments[0]).toMatchObject({ body: 'I will hurt', screen: 'abuse' });
222    expect((await get('/pitches')).body.pitches[0]).toMatchObject({ body: 'a song about Navi', screen: 'pending' });
223    const jobs = (await get('/jobs')).body;
224    expect(jobs.jobs.map((j: { kind: string; task: string }) => `${j.kind}/${j.task}`).sort()).toEqual([
225      'cover/screen',
226      'pitch/screen',
227      'revision/score',
228    ]);
229    expect((await get('/votes')).body).toEqual({ total: 1, votes: [{ a: 'jev/dial-up', b: 'jev/hey-listen', pick: 'jev/dial-up', n: 1 }] });
230    expect((await get('/reactions?song=jev/dial-up')).body.reactions).toEqual([
231      { song: 'jev/dial-up', section: 'intro', reaction: 'fire', n: 1 },
232    ]);
233  });
234
235  it('shows who voted for each pitch, and the songs answering it', async () => {
236    const content = d1Content(db, () => NOON);
237    await content.recordScreen('pitch', 'I'.repeat(22), 'fine', 0.9);
238    expect(await content.votePitch('I'.repeat(22), other.id, true)).toEqual({ votes: 1, voted: true });
239    await content.setSongPitch('S'.repeat(22), 'I'.repeat(22));
240    const [pitch] = (await get('/pitches')).body.pitches;
241    expect(pitch.votes).toEqual([{ id: other.id, name: 'Zelda', at: NOON }]);
242    expect(pitch.answeredBy).toEqual(['S'.repeat(22)]);
243    expect((await get('')).body.d1.content.pitchVotes).toBe(1);
244  });
245
246  it('lists performances, and one as stored', async () => {
247    const list = (await get('/performances')).body;
248    expect(list.performances[0]).toMatchObject({
249      id: 'P'.repeat(22),
250      song: 'jev/dial-up',
251      segments: 2,
252      fallbacks: 0,
253      at: NOON - 5000,
254      player: { id: user.id, name: 'Link' },
255    });
256    const one = (await get(`/performances/${'P'.repeat(22)}`)).body;
257    expect(one.segments[1]).toMatchObject({ segment: 1, section: 'drop', afterSection: 'intro', status: 'answered', ms: 90 });
258    expect(one).not.toHaveProperty('jevs');
259    expect(one.player).toEqual({ id: user.id, name: 'Link' });
260  });
261
262  it('pages lists, within bounds', async () => {
263    const one = (await get('/accounts?limit=1')).body;
264    expect(one.accounts).toHaveLength(1);
265    const next = (await get('/accounts?limit=1&offset=1')).body;
266    expect(next.accounts[0].id).not.toBe(one.accounts[0].id);
267    expect((await get(`/accounts?limit=${MAX_LIMIT * 10}`)).body.accounts.length).toBeLessThanOrEqual(MAX_LIMIT);
268    expect((await get('/accounts?limit=nonsense')).status).toBe(200);
269  });
270
271  it('refuses what is not a read, and holds the rate', async () => {
272    const post = await data(new Request(`${ORIGIN}/jev/data`, { method: 'POST' }), env());
273    expect(post.status).toBe(405);
274    expect((await get('/nothing')).status).toBe(404);
275    expect((await get('/reactions?song=../../etc')).status).toBe(400);
276    limited = true;
277    expect((await get('')).status).toBe(429);
278  });
279
280  it("gives an account's public songs, for its profile", async () => {
281    const mine = (await get(`/accounts/${user.id}`)).body;
282    expect(mine.publicSongs).toEqual([expect.objectContaining({ id: 'S'.repeat(22), title: 'Fine song', author: 'Link', authorId: user.id })]);
283    // a held song is not one of them
284    expect((await get(`/accounts/${other.id}`)).body.publicSongs).toEqual([]);
285  });
286
287  it('tells what is happening, newest first, without held text', async () => {
288    const { status, cache, body } = await get('/activity');
289    expect(status).toBe(200);
290    expect(cache).toBe('max-age=10');
291    const kinds = body.items.map((i: { kind: string }) => i.kind).sort();
292    expect(kinds).toEqual(['comment', 'pitch', 'revision', 'revision', 'take']);
293    const fine = body.items.find((i: { kind: string; screen: string }) => i.kind === 'revision' && i.screen === 'fine');
294    expect(fine).toMatchObject({ song: 'S'.repeat(22), rev: 1, title: 'Fine song', author: { id: user.id, name: 'Link' } });
295    // held and pending text is not in the feed: the data tab has it
296    const comment = body.items.find((i: { kind: string }) => i.kind === 'comment');
297    expect(comment).toMatchObject({ screen: 'abuse', body: null, on: { site: 'jev/dial-up' }, author: { name: 'Zelda' } });
298    expect(JSON.stringify(body)).not.toContain('I will hurt');
299    expect(body.items.find((i: { kind: string }) => i.kind === 'pitch')).toMatchObject({ screen: 'pending', body: null });
300    // each take, with who played it
301    expect(body.items.find((i: { kind: string }) => i.kind === 'take')).toEqual({
302      kind: 'take',
303      at: NOON - 5000,
304      id: 'P'.repeat(22),
305      song: 'jev/dial-up',
306      title: null,
307      player: { id: user.id, name: 'Link' },
308      sections: 2,
309      ended: 'finished',
310    });
311    expect(body.next).toBeNull();
312  });
313
314  it('pages the activity by time', async () => {
315    const first = (await get('/activity?limit=2')).body;
316    expect(first.items).toHaveLength(2);
317    expect(first.next).toBe(first.items[1].at);
318    // the next page is strictly older than the last item's time
319    const rest = (await get(`/activity?before=${first.next}`)).body;
320    for (const i of rest.items) expect(i.at).toBeLessThan(first.next);
321    expect((await get('/activity?limit=500')).body.items.length).toBeLessThanOrEqual(50);
322  });
323
324  it('never answers a secret', async () => {
325    const paths = ['', '/activity', '/accounts', `/accounts/${user.id}`, `/accounts/${other.id}`, '/songs', `/songs/${'T'.repeat(22)}`];
326    for (const path of [...paths, '/covers', '/comments', '/pitches', '/jobs', '/performances', `/performances/${'P'.repeat(22)}`]) {
327      await get(path);
328      await get(path, user);
329    }
330    const all = texts.join('\n');
331    // the answers were read: what is public is in them
332    expect(all).toContain('Zelda');
333    expect(all).toContain('BUY NOW');
334    const hex = (b: Uint8Array) => [...b].map((x) => x.toString(16).padStart(2, '0')).join('');
335    for (const secret of [
336      SESSION_TOKEN,
337      await tokenHash(SESSION_TOKEN),
338      CREDENTIAL_ID,
339      'another-credential-BBBBBBBBBBBB',
340      hex(PUBLIC_KEY),
341      Buffer.from(PUBLIC_KEY).toString('base64'),
342      CHALLENGE,
343      GRANT_VALUE,
344      'SECRET-TOKEN-VALUE',
345      PARTY_ROOM_ID,
346      CACHE_HASH,
347    ]) {
348      expect(all, secret).not.toContain(secret);
349    }
350    // nor a public key as the array of bytes D1 would give
351    expect(all).not.toContain('[222,173,190,239');
352  });
353});