1// The data tab's routes (data.ts) against real SQL (testD1), with every 2// store the site has holding something: that each answers what it says, 3// 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'; 18 19const ORIGIN = 'https://jevstrudel.example'; 20const NOON = Date.UTC(2026, 8, 27, 12); 21 22// 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'; 30 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});