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

Listeners' content end to end in the Worker: real SQL (testD1), a real passkey sign-in (the software authenticator), a real budget counter (memoryBudgets), an R2 bucket in memory, and a stand-in TypeSafe that 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';
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};

TypeSafe, standing in: a screening is spam when the text says "BUY NOW", 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}
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};

A listener with a session: made with a passkey through the real 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}
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([]);

not due yet: nothing is asked

278    const before = asked.length;
279    await sweep(env(), store());
280    expect(asked.length).toBe(before);

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 });
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([]);

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  });
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' });

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  });
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});