1// Listeners' content end to end in the Worker: real SQL (testD1), a real 2// passkey sign-in (the software authenticator), a real budget counter 3// (memoryBudgets), an R2 bucket in memory, and a stand-in TypeSafe that 4// screens by what the text says and scores every criterion 3. 5import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 6import { authenticator } from '../test/authenticator'; 7import { memoryBudgets } from '../test/budget'; 8import { testD1 } from '../test/d1'; 9import { memoryR2 } from '../test/r2'; 10import { d1Accounts } from './accounts-store'; 11import { auth } from './auth'; 12import { CRITERIA } from './art-rubric.mjs'; 13import { content, MAX_PENDING, sniffImage } from './content'; 14import { LEASE_MS, SCORE_RUNS, sweep } from './content-jobs'; 15import { d1Content } from './content-store'; 16import { newSessionToken, SESSION_COOKIE, SESSION_TTL_MS } from './session'; 17import { forgetSongs } from './votes'; 18 19const ORIGIN = 'https://jevstrudel.example'; 20const SONGS_URL = 'http://songs.invalid/jev/songs.json'; 21const SITE_SONGS = ['jev/dial-up', 'jev/hey-listen']; 22const NOON = Date.UTC(2026, 8, 25, 12); 23 24let db: D1Database; 25let clock: number; 26let r2: ReturnType<typeof memoryR2>; 27let budgetLimit: number; 28let budgets: ReturnType<typeof memoryBudgets>; 29let jev: 'up' | 'down' | 'error' | 'nonsense'; 30let asked: { kind: 'screen' | 'score'; state: Record<string, unknown> }[]; 31let events: { indexes: string[]; blobs: (string | null)[] }[]; 32let waiting: Promise<unknown>[]; 33let limited: boolean; 34 35const env = () => 36 ({ 37 DB: db, 38 COVERS: r2, 39 BUDGET: budgets, 40 EVENTS: { writeDataPoint: (p: { indexes: string[]; blobs: (string | null)[] }) => events.push(p) }, 41 JEVSTRUDEL_TYPESAFE_API_KEY: 'test-key', 42 SONGS_URL, 43 AUTH_LIMIT: { limit: async () => ({ success: true }) }, 44 CONTENT_LIMIT: { limit: async () => ({ success: !limited }) }, 45 COVER_LIMIT: { limit: async () => ({ success: !limited }) }, 46 BROWSE_LIMIT: { limit: async () => ({ success: true }) }, 47 VOTE_LIMIT: { limit: async () => ({ success: !limited }) }, 48 }) as never; 49const store = () => d1Content(db, () => clock); 50const ctx = { waitUntil: (p: Promise<unknown>) => void waiting.push(p) }; 51const settle = async () => { 52 while (waiting.length) await Promise.all(waiting.splice(0)); 53}; 54 55// TypeSafe, standing in: a screening is spam when the text says "BUY NOW", 56// abuse when it says "I will hurt", fine otherwise; every art criterion is 3. 57async function typesafe(body: string): Promise<Response> { 58 const req = JSON.parse(body) as { questions: Record<string, unknown>; state: Record<string, unknown> }; 59 const screening = 'verdict' in req.questions; 60 asked.push({ kind: screening ? 'screen' : 'score', state: req.state }); 61 if (jev === 'down') throw new TypeError('fetch failed'); 62 if (jev === 'error') return new Response('overloaded', { status: 529, headers: { 'retry-after-ms': '1000' } }); 63 if (jev === 'nonsense') return Response.json({ answers: { verdict: { choice: 'toString' } } }); 64 if (screening) { 65 const text = JSON.stringify(req.state); 66 const choice = text.includes('BUY NOW') ? 'spam' : text.includes('I will hurt') ? 'abuse' : 'fine'; 67 return Response.json({ answers: { verdict: { choice, confidence: 0.9 } } }); 68 } 69 return Response.json({ 70 answers: Object.fromEntries(Object.keys(CRITERIA).map((k) => [k, { score: 3 }])), 71 }); 72} 73 74beforeEach(() => { 75 db = testD1(); 76 clock = NOON; 77 r2 = memoryR2(); 78 budgetLimit = 1000; 79 budgets = memoryBudgets(1000, () => clock); 80 jev = 'up'; 81 asked = []; 82 events = []; 83 waiting = []; 84 limited = false; 85 forgetSongs(); 86 vi.spyOn(console, 'log').mockImplementation(() => {}); 87 vi.spyOn(console, 'error').mockImplementation(() => {}); 88 vi.stubGlobal('fetch', async (input: RequestInfo, init?: RequestInit) => { 89 const url = typeof input === 'string' ? input : input.url; 90 if (url === SONGS_URL) return Response.json({ songs: SITE_SONGS }); 91 if (url === 'https://api.typesafe.ai/v1/systemone') return typesafe(String(init?.body)); 92 throw new Error(`unexpected fetch ${url}`); 93 }); 94}); 95afterEach(() => { 96 vi.unstubAllGlobals(); 97 vi.restoreAllMocks(); 98}); 99 100const setBudget = (limit: number) => { 101 budgetLimit = limit; 102 budgets = memoryBudgets(limit, () => clock); 103}; 104 105// A listener with a session: made with a passkey through the real 106// ceremony, or straight into the database for the others. 107async function signUp(name = 'Link') { 108 let cookie = ''; 109 const passkey = await authenticator(); 110 const post = async (path: string, body: unknown) => { 111 const res = await auth( 112 new Request(`${ORIGIN}/jev/auth/${path}`, { 113 method: 'POST', 114 headers: { 'Content-Type': 'application/json', Origin: ORIGIN, ...(cookie ? { Cookie: cookie } : {}) }, 115 body: JSON.stringify(body), 116 }), 117 { ...(env() as object), DB: db } as never, 118 d1Accounts(db, () => clock), 119 ); 120 const set = res.headers.get('Set-Cookie'); 121 if (set) cookie = set.split(';')[0]; 122 return res.json() as Promise<Record<string, unknown>>; 123 }; 124 const options = await post('register/options', { displayName: name }); 125 const verified = await post('register/verify', await passkey.create(options as never, ORIGIN)); 126 return { cookie, id: (verified.user as { id: string }).id }; 127} 128async function quickUser(name: string) { 129 const token = newSessionToken(); 130 const id = newSessionToken().slice(0, 22); 131 await d1Accounts(db, () => clock).createUser( 132 { id, displayName: name }, 133 { id: `cred-${id}`.replace(/[^A-Za-z0-9_-]/g, 'x').padEnd(16, 'x'), publicKey: new Uint8Array([1]), signCount: 0, transports: [] }, 134 token, 135 SESSION_TTL_MS, 136 ); 137 return { cookie: `${SESSION_COOKIE}=${token}`, id }; 138} 139 140type Who = { cookie: string } | null; 141async function call(who: Who, method: string, path: string, body?: unknown, headers: Record<string, string> = {}) { 142 const isForm = body instanceof FormData; 143 const res = await content( 144 new Request(`${ORIGIN}/jev/listeners/${path}`, { 145 method, 146 headers: { 147 ...(who ? { Cookie: who.cookie } : {}), 148 ...(method !== 'GET' ? { Origin: ORIGIN } : {}), 149 ...(body !== undefined && !isForm ? { 'Content-Type': 'application/json' } : {}), 150 ...headers, 151 }, 152 body: body === undefined ? undefined : isForm ? body : JSON.stringify(body), 153 }), 154 env(), 155 ctx, 156 d1Accounts(db, () => clock), 157 store(), 158 ); 159 const type = res.headers.get('Content-Type') ?? ''; 160 return { res, body: type.includes('json') ? await res.json() : null }; 161} 162 163const SONG = { 164 title: 'Moblin Stomp', 165 description: 'a march for the moblins', 166 spec: '# Moblin Stomp\n\nA march in four sections.', 167 code: 'setcps(120/60/4)\n$: s("bd*4").gain(1) // stomp\n', 168}; 169const publicList = async () => (await call(null, 'GET', 'songs')).body.songs as Record<string, unknown>[]; 170const png = (n = 64) => { 171 const b = new Uint8Array(n); 172 b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); 173 return b; 174}; 175const coverForm = (bytes: Uint8Array, alt = 'a moblin with a drum', type = 'image/png') => { 176 const f = new FormData(); 177 f.set('image', new File([bytes], 'cover', { type })); 178 f.set('alt', alt); 179 return f; 180}; 181 182describe('publishing a song', () => { 183 it('screens it at once, makes it public, then scores it with three runs charged to the author', async () => { 184 const link = await signUp(); 185 const made = await call(link, 'POST', 'songs', SONG); 186 expect(made.res.status).toBe(201); 187 expect(made.body).toMatchObject({ rev: 1, status: 'public' }); 188 expect(made.body.id).toMatch(/^[A-Za-z0-9_-]{22}$/); 189 // the screening saw everything the listener wrote, code included 190 expect(asked[0]).toMatchObject({ kind: 'screen', state: { title: SONG.title, spec: SONG.spec, code: SONG.code, rev: 1 } }); 191 192 await settle(); 193 expect(asked.filter((a) => a.kind === 'score')).toHaveLength(SCORE_RUNS); 194 const [song] = await publicList(); 195 expect(song).toMatchObject({ 196 id: made.body.id, 197 author: 'Link', 198 title: SONG.title, 199 rev: 1, 200 art: { art: 0.6, artRuns: [0.6, 0.6, 0.6], model: 'jev-1.13.0', method: 'rubric-1' }, 201 cover: null, 202 }); 203 const view = await call(null, 'GET', `songs/${made.body.id}`); 204 expect(view.body).toMatchObject({ code: SONG.code, spec: SONG.spec, revisions: [{ rev: 1, art: 0.6 }] }); 205 // one screening and three score runs, from the author's own budget 206 expect((await budgets.get(budgets.idFromName(link.id)).balance()).used).toBe(1 + SCORE_RUNS); 207 expect(await db.prepare('SELECT count(*) AS n FROM content_jobs').first('n')).toBe(0); 208 expect(events.map((e) => [e.indexes[0], e.blobs[0]])).toEqual([ 209 ['jev.screen', 'revision'], 210 ['jev.score', 'scored'], 211 ]); 212 }); 213 214 it('refuses code that reaches beyond music, before Jev is asked', async () => { 215 const link = await quickUser('Link'); 216 const res = await call(link, 'POST', 'songs', { ...SONG, code: 'fetch("/jev/listeners/pitches")' }); 217 expect(res.res.status).toBe(400); 218 expect(res.body.error).toMatch(/only make music/); 219 expect(asked).toEqual([]); 220 }); 221 222 it('refuses sizes past the caps, and fields it does not know', async () => { 223 const link = await quickUser('Link'); 224 const big = (n: number) => 'x'.repeat(n); 225 expect((await call(link, 'POST', 'songs', { ...SONG, code: `s("bd")//${big(64 * 1024)}` })).res.status).toBe(400); 226 expect((await call(link, 'POST', 'songs', { ...SONG, spec: big(32 * 1024 + 1) })).res.status).toBe(400); 227 expect((await call(link, 'POST', 'songs', { ...SONG, title: '' })).res.status).toBe(400); 228 expect((await call(link, 'POST', 'songs', { ...SONG, extra: 1 })).res.status).toBe(400); 229 expect((await call(link, 'POST', 'songs', { ...SONG, code: `s("bd")//${big(400 * 1024)}` })).res.status).toBe(413); 230 }); 231 232 it('holds spam and abuse from everyone but the author, who sees why', async () => { 233 const link = await quickUser('Link'); 234 const spam = await call(link, 'POST', 'songs', { ...SONG, description: 'BUY NOW at example.com' }); 235 expect(spam.body).toMatchObject({ status: 'held', verdict: 'spam', confidence: 0.9 }); 236 clock += 1000; 237 const abuse = await call(link, 'POST', 'songs', { ...SONG, spec: 'I will hurt you' }); 238 expect(abuse.body).toMatchObject({ status: 'held', verdict: 'abuse' }); 239 await settle(); 240 expect(await publicList()).toEqual([]); 241 expect((await call(null, 'GET', `songs/${spam.body.id}`)).res.status).toBe(404); 242 // held songs are never scored 243 expect(asked.filter((a) => a.kind === 'score')).toEqual([]); 244 const mine = await call(link, 'GET', 'mine'); 245 expect(mine.body.revisions.map((r: { status: string; verdict: string }) => [r.status, r.verdict])).toEqual([ 246 ['held', 'abuse'], 247 ['held', 'spam'], 248 ]); 249 expect(events.map((e) => e.blobs[1])).toEqual(['spam', 'abuse']); 250 }); 251 252 it('keeps revisions: the public sees the newest fine one, and only its author may add one', async () => { 253 const link = await quickUser('Link'); 254 const zelda = await quickUser('Zelda'); 255 const { body } = await call(link, 'POST', 'songs', SONG); 256 const two = await call(link, 'POST', `songs/${body.id}/revisions`, { ...SONG, title: 'Moblin Stomp II' }); 257 expect(two.body).toMatchObject({ id: body.id, rev: 2, status: 'public' }); 258 const three = await call(link, 'POST', `songs/${body.id}/revisions`, { ...SONG, title: 'BUY NOW' }); 259 expect(three.body).toMatchObject({ rev: 3, status: 'held' }); 260 await settle(); 261 const view = await call(null, 'GET', `songs/${body.id}`); 262 expect(view.body).toMatchObject({ rev: 2, title: 'Moblin Stomp II' }); 263 expect(view.body.revisions.map((r: { rev: number }) => r.rev)).toEqual([1, 2]); 264 expect((await call(zelda, 'POST', `songs/${body.id}/revisions`, SONG)).res.status).toBe(403); 265 expect((await call(link, 'POST', `songs/${'x'.repeat(22)}/revisions`, SONG)).res.status).toBe(404); 266 }); 267}); 268 269describe('when Jev cannot screen', () => { 270 it('keeps content pending while TypeSafe is down, and makes it public on a later sweep', async () => { 271 const link = await quickUser('Link'); 272 jev = 'down'; 273 const made = await call(link, 'POST', 'pitches', { body: 'a song about a lost boomerang' }); 274 expect(made.body).toMatchObject({ status: 'pending', why: 'unreachable', next: NOON + 60_000, attempts: 1 }); 275 expect((await call(null, 'GET', 'pitches')).body.pitches).toEqual([]); 276 277 // not due yet: nothing is asked 278 const before = asked.length; 279 await sweep(env(), store()); 280 expect(asked.length).toBe(before); 281 282 // due, TypeSafe answering an error: backs off further (the doubling, or TypeSafe's own wait) 283 clock += 60_000; 284 jev = 'error'; 285 await sweep(env(), store()); 286 expect((await store().itemStatus('pitch', made.body.id))).toMatchObject({ why: 'upstream', attempts: 2, next: clock + 120_000 }); 287 288 clock += 120_000; 289 jev = 'nonsense'; 290 await sweep(env(), store()); 291 expect(await store().itemStatus('pitch', made.body.id)).toMatchObject({ why: 'unanswered', attempts: 3 }); 292 293 clock += 240_000; 294 jev = 'up'; 295 await sweep(env(), store()); 296 expect(await store().itemStatus('pitch', made.body.id)).toEqual({ status: 'public' }); 297 expect((await call(null, 'GET', 'pitches')).body.pitches).toMatchObject([ 298 { author: 'Link', body: 'a song about a lost boomerang' }, 299 ]); 300 expect(events.map((e) => [e.blobs[1], e.blobs[2]])).toEqual([ 301 ['pending', 'unreachable'], 302 ['pending', 'upstream'], 303 ['pending', 'unanswered'], 304 ['fine', null], 305 ]); 306 }); 307 308 it('lets an author out of budget publish; it waits for the reset at 00:00 UTC, then is screened and scored', async () => { 309 setBudget(4); 310 const link = await quickUser('Link'); 311 const spend = budgets.get(budgets.idFromName(link.id)); 312 for (let i = 0; i < 4; i++) await spend.spend(); 313 const made = await call(link, 'POST', 'songs', SONG); 314 expect(made.body).toMatchObject({ status: 'pending', why: 'budget', next: Date.UTC(2026, 8, 26) }); 315 expect(asked).toEqual([]); 316 317 clock = Date.UTC(2026, 8, 25, 23, 59); 318 await sweep(env(), store()); 319 expect(asked).toEqual([]); 320 expect(await publicList()).toEqual([]); 321 322 // the author looking at their own content runs their due jobs 323 clock = Date.UTC(2026, 8, 26, 0, 0, 1); 324 const mine = await call(link, 'GET', 'mine'); 325 expect(mine.body.revisions[0]).toMatchObject({ status: 'pending', why: 'budget' }); 326 await settle(); 327 expect((await publicList())[0]).toMatchObject({ id: made.body.id, art: null }); 328 // one call left of four: not enough for a score's three runs, so it waits for the next reset 329 await spend.spend(); 330 await spend.spend(); 331 await sweep(env(), store()); 332 const after = (await call(link, 'GET', 'mine')).body.revisions[0]; 333 expect(after).toMatchObject({ status: 'public', art: null, score: { why: 'budget', next: Date.UTC(2026, 8, 27) } }); 334 clock = Date.UTC(2026, 8, 27, 0, 1); 335 await sweep(env(), store()); 336 expect((await publicList())[0]).toMatchObject({ art: { art: 0.6 } }); 337 }); 338 339 it('runs a job whose worker died once its lease expires, and never twice at once', async () => { 340 const link = await quickUser('Link'); 341 jev = 'down'; 342 const made = await call(link, 'POST', 'comments', { song: 'jev/dial-up', body: 'nice' }); 343 clock += 60_000; 344 // a worker claims it and dies 345 expect(await store().claimDue(10, LEASE_MS)).toHaveLength(1); 346 expect(await store().claimDue(10, LEASE_MS)).toHaveLength(0); 347 jev = 'up'; 348 await sweep(env(), store()); 349 expect(await store().itemStatus('comment', made.body.id)).toMatchObject({ status: 'pending' }); 350 clock += LEASE_MS; 351 await sweep(env(), store()); 352 expect(await store().itemStatus('comment', made.body.id)).toEqual({ status: 'public' }); 353 }); 354 355 it('bounds what one author can leave waiting', async () => { 356 const link = await quickUser('Link'); 357 jev = 'down'; 358 for (let i = 0; i < MAX_PENDING; i++) { 359 expect((await call(link, 'POST', 'pitches', { body: `idea ${i}` })).res.status).toBe(201); 360 } 361 const refused = await call(link, 'POST', 'pitches', { body: 'one more' }); 362 expect(refused.res.status).toBe(429); 363 expect(refused.body.error).toMatch(/waiting for Jev/); 364 }); 365}); 366 367describe('comments and pitches', () => { 368 it('comments on the site’s songs and on public listener songs, screened, public to read', async () => { 369 const link = await quickUser('Link'); 370 const zelda = await quickUser('Zelda'); 371 expect((await call(link, 'POST', 'comments', { song: 'jev/dial-up', body: 'the modem solo!' })).body.status).toBe('public'); 372 expect((await call(link, 'POST', 'comments', { song: 'jev/dial-up', body: 'BUY NOW' })).body.status).toBe('held'); 373 const { body: song } = await call(zelda, 'POST', 'songs', SONG); 374 expect((await call(link, 'POST', 'comments', { song: `listener:${song.id}`, body: 'stomp' })).body.status).toBe('public'); 375 await settle(); 376 expect((await call(null, 'GET', 'comments?song=jev/dial-up')).body.comments).toMatchObject([ 377 { author: 'Link', body: 'the modem solo!' }, 378 ]); 379 expect((await call(null, 'GET', `comments?song=listener:${song.id}`)).body.comments).toMatchObject([{ body: 'stomp' }]); 380 // only songs that exist and are public 381 expect((await call(link, 'POST', 'comments', { song: 'jev/nope', body: 'hi' })).res.status).toBe(400); 382 expect((await call(link, 'POST', 'comments', { song: `listener:${'y'.repeat(22)}`, body: 'hi' })).res.status).toBe(400); 383 expect((await call(link, 'POST', 'comments', { song: 'jev/dial-up', body: '' })).res.status).toBe(400); 384 }); 385 386 it('pitches are text only', async () => { 387 const link = await quickUser('Link'); 388 expect((await call(link, 'POST', 'pitches', { body: 'x', image: 1 })).res.status).toBe(400); 389 expect((await call(link, 'POST', 'pitches', { body: 'y'.repeat(2001) })).res.status).toBe(400); 390 expect((await call(link, 'POST', 'pitches', { body: 'a sea shanty about Epona' })).body.status).toBe('public'); 391 }); 392}); 393 394describe('pitch votes, and the songs that answer a pitch', () => { 395 it('counts one vote per account, taken back on request, only on public pitches', async () => { 396 const link = await quickUser('Link'); 397 const zelda = await quickUser('Zelda'); 398 const { body: epona } = await call(link, 'POST', 'pitches', { body: 'a sea shanty about Epona' }); 399 clock += 1000; 400 const { body: navi } = await call(link, 'POST', 'pitches', { body: 'a song about Navi, hey, listen' }); 401 const { body: held } = await call(link, 'POST', 'pitches', { body: 'BUY NOW' }); 402 expect((await call(zelda, 'PUT', `pitches/${navi.id}/vote`)).body).toEqual({ votes: 1, voted: true }); 403 // twice is still one 404 expect((await call(zelda, 'PUT', `pitches/${navi.id}/vote`)).body).toEqual({ votes: 1, voted: true }); 405 expect((await call(link, 'PUT', `pitches/${navi.id}/vote`)).body).toEqual({ votes: 2, voted: true }); 406 expect((await call(link, 'DELETE', `pitches/${navi.id}/vote`)).body).toEqual({ votes: 1, voted: false }); 407 // not public, or not a pitch 408 expect((await call(zelda, 'PUT', `pitches/${held.id}/vote`)).res.status).toBe(404); 409 expect((await call(zelda, 'PUT', `pitches/${'z'.repeat(22)}/vote`)).res.status).toBe(404); 410 // signed in to vote; reading says whether you voted 411 expect((await call(null, 'PUT', `pitches/${epona.id}/vote`)).res.status).toBe(401); 412 const byVotes = (await call(zelda, 'GET', 'pitches?sort=votes')).body.pitches; 413 expect(byVotes.map((p: { id: string; votes: number; voted: boolean }) => [p.id, p.votes, p.voted])).toEqual([ 414 [navi.id, 1, true], 415 [epona.id, 0, false], 416 ]); 417 const signedOut = (await call(null, 'GET', 'pitches')).body.pitches; 418 expect(signedOut.map((p: { voted: boolean }) => p.voted)).toEqual([false, false]); 419 // newest first by default 420 expect(signedOut.map((p: { id: string }) => p.id)).toEqual([navi.id, epona.id]); 421 expect((await call(null, 'GET', 'pitches?sort=loudest')).res.status).toBe(400); 422 }); 423 424 it("holds votes to the votes' own limit, and never to the pending cap", async () => { 425 const link = await quickUser('Link'); 426 const { body: pitch } = await call(link, 'POST', 'pitches', { body: 'a waltz for Kaepora' }); 427 jev = 'down'; 428 for (let i = 0; i < MAX_PENDING; i++) await call(link, 'POST', 'pitches', { body: `idea ${i}` }); 429 expect((await call(link, 'POST', 'pitches', { body: 'one more' })).res.status).toBe(429); 430 expect((await call(link, 'PUT', `pitches/${pitch.id}/vote`)).res.status).toBe(200); 431 limited = true; 432 expect((await call(link, 'PUT', `pitches/${pitch.id}/vote`)).res.status).toBe(429); 433 }); 434 435 it("lets a song's author say which public pitch it answers, and lists it under the pitch", async () => { 436 const link = await quickUser('Link'); 437 const zelda = await quickUser('Zelda'); 438 const { body: pitch } = await call(link, 'POST', 'pitches', { body: 'a lullaby for the Deku Tree' }); 439 const { body: song } = await call(zelda, 'POST', 'songs', SONG); 440 await settle(); 441 expect((await call(link, 'PUT', `songs/${song.id}/pitch`, { pitch: pitch.id })).res.status).toBe(403); 442 expect((await call(zelda, 'PUT', `songs/${song.id}/pitch`, { pitch: 'nope' })).res.status).toBe(400); 443 expect((await call(zelda, 'PUT', `songs/${song.id}/pitch`, { pitch: 'q'.repeat(22) })).res.status).toBe(400); 444 expect((await call(zelda, 'PUT', `songs/${song.id}/pitch`, { pitch: pitch.id, extra: 1 })).res.status).toBe(400); 445 expect((await call(zelda, 'PUT', `songs/${song.id}/pitch`, { pitch: pitch.id })).body).toEqual({ pitch: pitch.id }); 446 const [listed] = (await call(null, 'GET', 'pitches')).body.pitches; 447 expect(listed.answers).toEqual([{ id: song.id, title: SONG.title }]); 448 expect((await call(null, 'GET', `songs/${song.id}`)).body.pitch).toBe(pitch.id); 449 expect((await call(zelda, 'PUT', `songs/${song.id}/pitch`, { pitch: null })).body).toEqual({ pitch: null }); 450 expect((await call(null, 'GET', 'pitches')).body.pitches[0].answers).toEqual([]); 451 }); 452}); 453 454describe('writing needs an account', () => { 455 it('refuses the signed out, other origins, and the rate limited; reading is public', async () => { 456 expect((await call(null, 'POST', 'pitches', { body: 'x' })).res.status).toBe(401); 457 expect((await call(null, 'GET', 'mine')).res.status).toBe(401); 458 expect((await call(null, 'GET', 'pitches')).res.status).toBe(200); 459 const link = await quickUser('Link'); 460 expect((await call(link, 'POST', 'pitches', { body: 'x' }, { Origin: 'https://evil.example' })).res.status).toBe(403); 461 limited = true; 462 expect((await call(link, 'POST', 'pitches', { body: 'x' })).res.status).toBe(429); 463 expect((await call(null, 'GET', 'nothing')).res.status).toBe(404); 464 expect((await call(null, 'DELETE', 'pitches')).res.status).toBe(405); 465 }); 466}); 467 468describe('covers', () => { 469 it('stores the image under a new id, typed from its bytes, served once its alt text is fine and its song public', async () => { 470 const link = await quickUser('Link'); 471 const { body: song } = await call(link, 'POST', 'songs', { ...SONG, spec: 'I will hurt you' }); 472 const up = await call(link, 'PUT', `songs/${song.id}/cover`, coverForm(png(), 'a moblin', 'text/html')); 473 expect(up.res.status).toBe(201); 474 expect(up.body.status).toBe('public'); 475 expect(r2.objects.get(`covers/${up.body.id}`)).toMatchObject({ contentType: 'image/png' }); 476 // the song itself is held, so its cover is not served 477 expect((await call(null, 'GET', `covers/${up.body.id}`)).res.status).toBe(404); 478 479 await call(link, 'POST', `songs/${song.id}/revisions`, SONG); 480 const served = await call(null, 'GET', `covers/${up.body.id}`); 481 expect(served.res.status).toBe(200); 482 expect(served.res.headers.get('Content-Type')).toBe('image/png'); 483 expect(served.res.headers.get('X-Content-Type-Options')).toBe('nosniff'); 484 expect(served.res.headers.get('Cache-Control')).toMatch(/public/); 485 expect(new Uint8Array(await served.res.arrayBuffer())).toEqual(png()); 486 expect((await publicList())[0].cover).toEqual({ id: up.body.id, alt: 'a moblin' }); 487 488 // a new cover replaces it, and the old image is deleted 489 const next = await call(link, 'PUT', `songs/${song.id}/cover`, coverForm(png(80), 'two moblins')); 490 await settle(); 491 expect(r2.objects.has(`covers/${up.body.id}`)).toBe(false); 492 expect((await call(null, 'GET', `covers/${next.body.id}`)).res.status).toBe(200); 493 }); 494 495 it('holds a cover whose description is abuse, and refuses what is not an image', async () => { 496 const link = await quickUser('Link'); 497 const zelda = await quickUser('Zelda'); 498 const { body: song } = await call(link, 'POST', 'songs', SONG); 499 const held = await call(link, 'PUT', `songs/${song.id}/cover`, coverForm(png(), 'I will hurt you')); 500 expect(held.body).toMatchObject({ status: 'held', verdict: 'abuse' }); 501 expect((await call(null, 'GET', `covers/${held.body.id}`)).res.status).toBe(404); 502 expect((await publicList())[0].cover).toBeNull(); 503 504 const svg = new TextEncoder().encode('<svg xmlns="http://www.w3.org/2000/svg"><script>x()</script></svg>'); 505 expect((await call(link, 'PUT', `songs/${song.id}/cover`, coverForm(svg, 'x', 'image/png'))).res.status).toBe(415); 506 expect((await call(link, 'PUT', `songs/${song.id}/cover`, coverForm(png(2 * 1024 * 1024 + 1)))).res.status).toBe(413); 507 expect((await call(link, 'PUT', `songs/${song.id}/cover`, coverForm(png(), ''))).res.status).toBe(400); 508 expect((await call(zelda, 'PUT', `songs/${song.id}/cover`, coverForm(png()))).res.status).toBe(403); 509 }); 510 511 it('sniffs PNG, JPEG, WebP and GIF, and nothing else', () => { 512 const b = (...xs: (number | string)[]) => 513 Uint8Array.from(xs.flatMap((x) => (typeof x === 'string' ? [...x].map((c) => c.charCodeAt(0)) : [x]))); 514 expect(sniffImage(png())).toBe('image/png'); 515 expect(sniffImage(b(0xff, 0xd8, 0xff, 0xe0))).toBe('image/jpeg'); 516 expect(sniffImage(b('RIFF', 0, 0, 0, 0, 'WEBPVP8 '))).toBe('image/webp'); 517 expect(sniffImage(b('GIF89a'))).toBe('image/gif'); 518 expect(sniffImage(b('<svg'))).toBeNull(); 519 expect(sniffImage(b('RIFF', 0, 0, 0, 0, 'WAVE'))).toBeNull(); 520 }); 521}); 522 523describe('the schema keeps the promises', () => { 524 it('makes a job with every pending item, deletes it with the verdict, and never un-screens', async () => { 525 const link = await quickUser('Link'); 526 const s = store(); 527 await s.addPitch('p'.repeat(22), link.id, 'idea'); 528 expect(await db.prepare('SELECT kind, task, why FROM content_jobs').all().then((r) => r.results)).toEqual([ 529 { kind: 'pitch', task: 'screen', why: 'new' }, 530 ]); 531 await s.recordScreen('pitch', 'p'.repeat(22), 'spam', 0.8); 532 expect(await db.prepare('SELECT count(*) AS n FROM content_jobs').first('n')).toBe(0); 533 await expect(db.prepare(`UPDATE pitches SET screen = 'fine'`).run()).rejects.toThrow(/final/); 534 await expect( 535 db 536 .prepare(`INSERT INTO pitches (id, user_id, body, created_at, screen, screened_at) VALUES (?, ?, 'x', 1, 'fine', 1)`) 537 .bind('q'.repeat(22), link.id) 538 .run(), 539 ).rejects.toThrow(/pending/); 540 // a score only on a fine revision 541 await s.createSong(link.id, 's'.repeat(22), 'r'.repeat(22), SONG); 542 await expect( 543 s.recordScore('r'.repeat(22), { art: 0.5, artRuns: [0.5], criteria: {}, model: 'm', method: 'x' }), 544 ).resolves.toBe(false); 545 }); 546});