jevstrudel.git / worker / src / moved.test.ts
1// The site's move (moved.ts) against real SQL: what the old host answers,
2// and a session carried to the new one exactly once.
3import { beforeEach, describe, expect, it } from 'vitest';
4import { testD1 } from '../test/d1';
5import { d1Accounts } from './accounts-store';
6import { arrived, HANDOFF_TTL_MS, isOldHost, moved, MOVED_PATH, SITE_HOST } from './moved';
7import worker from './index';
8import { SESSION_COOKIE, SESSION_TTL_MS } from './session';
9// @ts-expect-error a plain module of the website's, with no types
10import { siteUrl } from '../../website/src/jev/site.mjs';
11
12const OLD = 'https://jevstrudel.deizel.workers.dev';
13const NEW = `https://${SITE_HOST}`;
14const USER = { id: 'u'.repeat(22), displayName: 'Link' };
15const SESSION = 's'.repeat(43);
16
17let clock: number;
18let db: D1Database;
19const env = () => ({ DB: db }) as never;
20const accounts = () => d1Accounts(db, () => clock);
21const passkey = (id: string, rpId: string) => ({ id: id.repeat(16), publicKey: Uint8Array.of(1), signCount: 0, transports: [], rpId });
22
23const navigate = (url: string, cookie?: string) =>
24  new Request(url, { headers: { 'Sec-Fetch-Mode': 'navigate', ...(cookie ? { Cookie: `${SESSION_COOKIE}=${cookie}` } : {}) } });
25
26beforeEach(async () => {
27  clock = Date.UTC(2026, 9, 4, 12);
28  db = testD1();
29  await accounts().createUser(USER, passkey('a', 'jevstrudel.deizel.workers.dev'), SESSION, SESSION_TTL_MS);
30});
31
32describe('the old address', () => {
33  it('is the workers.dev host and its previews, never the site or localhost', () => {
34    // one address: the Worker's custom domain (wrangler.json) is where the
35    // website says it is (link previews, /ai/, the deploy's release check)
36    expect(siteUrl).toBe(`https://${SITE_HOST}`);
37    expect(isOldHost('jevstrudel.deizel.workers.dev')).toBe(true);
38    expect(isOldHost('abc123-jevstrudel.deizel.workers.dev')).toBe(true);
39    expect(isOldHost(SITE_HOST)).toBe(false);
40    expect(isOldHost('localhost')).toBe(false);
41  });
42
43  it('redirects a page for good, to the same path and query', async () => {
44    const res = await moved(navigate(`${OLD}/songs/jev/cosmic-knot/?performance=abc`), env(), accounts());
45    expect(res.status).toBe(301);
46    expect(res.headers.get('Location')).toBe(`${NEW}/songs/jev/cosmic-knot/?performance=abc`);
47  });
48
49  it('redirects a write with its method kept, and answers nothing itself', async () => {
50    const res = await worker.fetch(new Request(`${OLD}/jev/v1/systemone`, { method: 'POST', body: '{}' }), env(), {} as never);
51    expect(res.status).toBe(308);
52    expect(res.headers.get('Location')).toBe(`${NEW}/jev/v1/systemone`);
53    const me = await worker.fetch(new Request(`${OLD}/jev/auth/me`), env(), {} as never);
54    expect(me.status).toBe(301);
55  });
56
57  it('serves a service worker that removes itself, never a redirect', async () => {
58    const res = await moved(new Request(`${OLD}/sw.js`), env(), accounts());
59    expect(res.status).toBe(200);
60    expect(res.headers.get('Content-Type')).toMatch(/javascript/);
61    expect(res.headers.get('Cache-Control')).toBe('no-store');
62    const script = await res.text();
63    expect(script).toMatch(/registration\.unregister\(\)/);
64    expect(script).toMatch(/caches\.delete/);
65  });
66});
67
68describe('a session carried across', () => {
69  const handoff = async (path = '/songs/jev/all-green/?x=1') => {
70    const res = await moved(navigate(OLD + path, SESSION), env(), accounts());
71    expect(res.status).toBe(302);
72    return new URL(res.headers.get('Location')!);
73  };
74  const arrive = (url: URL | string) => arrived(new Request(url), env(), accounts());
75  const cookieOf = (res: Response) => /^jev_session=([^;]+)/.exec(res.headers.get('Set-Cookie') ?? '')?.[1] ?? null;
76
77  it('goes through the site with a one-time token, and lands signed in on the page asked for', async () => {
78    const to = await handoff();
79    expect(to.origin + to.pathname).toBe(NEW + MOVED_PATH);
80    expect(to.searchParams.get('token')).toMatch(/^[A-Za-z0-9_-]{43}$/);
81    const res = await arrive(to);
82    expect(res.status).toBe(302);
83    expect(res.headers.get('Location')).toBe('/songs/jev/all-green/?x=1');
84    const session = cookieOf(res)!;
85    expect(await accounts().sessionUser(session)).toEqual(USER);
86    // the old address's session ended with it, so its next visit is a plain redirect
87    expect(await accounts().sessionUser(SESSION)).toBeNull();
88    expect((await moved(navigate(`${OLD}/`, SESSION), env(), accounts())).status).toBe(301);
89  });
90
91  it('signs nobody in twice, late, or with a token never issued', async () => {
92    const to = await handoff();
93    expect(cookieOf(await arrive(to))).not.toBeNull();
94    expect(cookieOf(await arrive(to))).toBeNull();
95    // the old address's session ended with the first; another browser's
96    await accounts().createSession(USER.id, SESSION, SESSION_TTL_MS);
97    const late = await handoff('/');
98    clock += HANDOFF_TTL_MS;
99    expect(cookieOf(await arrive(late))).toBeNull();
100    expect(cookieOf(await arrive(`${NEW}${MOVED_PATH}?token=${'x'.repeat(43)}`))).toBeNull();
101    expect(cookieOf(await arrive(`${NEW}${MOVED_PATH}`))).toBeNull();
102  });
103
104  it('stores only the token hash', async () => {
105    const token = (await handoff()).searchParams.get('token')!;
106    const rows = await db.prepare('SELECT token_hash, from_session_hash FROM session_handoffs').all<Record<string, string>>();
107    expect(rows.results).toHaveLength(1);
108    expect(JSON.stringify(rows.results)).not.toContain(token);
109    expect(JSON.stringify(rows.results)).not.toContain(SESSION);
110  });
111
112  it('is only for a navigation with a live session', async () => {
113    const fetched = await moved(new Request(`${OLD}/release-notes.json`, { headers: { Cookie: `${SESSION_COOKIE}=${SESSION}` } }), env(), accounts());
114    expect(fetched.status).toBe(301);
115    expect((await moved(navigate(`${OLD}/`, 'n'.repeat(43)), env(), accounts())).status).toBe(301);
116  });
117
118  it('never lands on another site', async () => {
119    for (const to of ['//evil.example/', '/\\evil.example', 'https://evil.example/', 'evil']) {
120      const res = await arrive(`${NEW}${MOVED_PATH}?to=${encodeURIComponent(to)}`);
121      expect(res.headers.get('Location'), to).toBe('/');
122    }
123  });
124});
125
126describe('a passkey belongs to the host it was made on', () => {
127  it('lists an account’s passkeys per host', async () => {
128    await accounts().addCredential({ ...passkey('b', SITE_HOST), userId: USER.id });
129    expect((await accounts().credentialIds(USER.id, 'jevstrudel.deizel.workers.dev')).map((c) => c.id)).toEqual(['a'.repeat(16)]);
130    expect((await accounts().credentialIds(USER.id, SITE_HOST)).map((c) => c.id)).toEqual(['b'.repeat(16)]);
131  });
132});