import assert from 'node:assert/strict' import { afterEach, beforeEach, test } from 'node:test' import { heyFetchBinary, heyRequest, resolveHeyUrl, resolveRedirect } from '../src/client.js' import { createDraft, sendDraft } from '../src/drafts.js' import { BEARER, HEY, installFetch, jsonResponse, redirectResponse, resetKodySecrets, } from './helpers.mjs' const JSON_HEADERS = { 'content-type': 'application/json' } const POST_INIT = { method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': '16' }, body: '{"subject":"hi"}', } const PUT_INIT = { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Content-Length': '21' }, body: '{"subject":"updated"}', } const HEAD_INIT = { method: 'HEAD' } let fixture beforeEach(() => { resetKodySecrets() fixture = installFetch(async (url, init) => { const method = String(init.method || 'GET').toUpperCase() const parsed = new URL(url) const path = parsed.pathname const routes = fixture.routes const key = `${method} ${url}` const byPath = `${method} ${path}` const handler = routes.get(key) || routes.get(byPath) if (handler) return handler(url, init) throw new Error(`unexpected ${method} ${url}`) }) fixture.routes = new Map() }) afterEach(() => { fixture?.restore() }) function on(method, urlOrPath, handler) { fixture.routes.set(`${method} ${urlOrPath}`, handler) } test('relative Location resolves against the current HEY URL', async () => { on('GET', `${HEY}/from`, () => redirectResponse(302, '/to.json')) on('GET', `${HEY}/to.json`, () => jsonResponse(200, { ok: true })) const result = await heyRequest('/from') assert.equal(result.data.ok, true) assert.deepEqual( fixture.requests.map((row) => row.url), [`${HEY}/from`, `${HEY}/to.json`] ) assert.equal(fixture.requests[1].headers.authorization, BEARER) }) test('absolute same-origin Location keeps the bearer', async () => { on('GET', `${HEY}/from`, () => redirectResponse(302, `${HEY}/to.json`)) on('GET', `${HEY}/to.json`, () => jsonResponse(200, { ok: true })) const result = await heyRequest('/from') assert.equal(result.data.ok, true) assert.equal(fixture.requests[1].url, `${HEY}/to.json`) assert.equal(fixture.requests[1].headers.authorization, BEARER) }) const REDIRECT_STATUSES = [301, 302, 303, 307, 308] for (const status of REDIRECT_STATUSES) { test(`POST ${status} follows Fetch rewrite rules`, async () => { on('POST', `${HEY}/messages.json`, () => redirectResponse(status, `${HEY}/messages/9.json`)) on('POST', `${HEY}/messages/9.json`, () => jsonResponse(200, { kept: 'post' })) on('GET', `${HEY}/messages/9.json`, () => jsonResponse(200, { rewritten: 'get' })) const result = await heyRequest('/messages.json', POST_INIT) const hop = fixture.requests[1] if (status === 307 || status === 308) { assert.equal(hop.method, 'POST') assert.equal(hop.body, POST_INIT.body) assert.equal(hop.headers['content-type'], 'application/json') assert.equal(result.data.kept, 'post') } else { assert.equal(hop.method, 'GET') assert.equal(hop.body, undefined) assert.equal(hop.headers['content-type'], undefined) assert.equal(hop.headers['content-length'], undefined) assert.equal(result.data.rewritten, 'get') } }) test(`PUT ${status} follows Fetch rewrite rules`, async () => { on('PUT', `${HEY}/messages/9.json`, () => redirectResponse(status, `${HEY}/messages/9-canonical.json`)) on('PUT', `${HEY}/messages/9-canonical.json`, () => jsonResponse(200, { kept: 'put' })) on('GET', `${HEY}/messages/9-canonical.json`, () => jsonResponse(200, { rewritten: 'get' })) const result = await heyRequest('/messages/9.json', PUT_INIT) const hop = fixture.requests[1] if (status === 303) { assert.equal(hop.method, 'GET') assert.equal(hop.body, undefined) assert.equal(hop.headers['content-type'], undefined) assert.equal(hop.headers['content-length'], undefined) assert.equal(result.data.rewritten, 'get') } else { assert.equal(hop.method, 'PUT') assert.equal(hop.body, PUT_INIT.body) assert.equal(hop.headers['content-type'], 'application/json') assert.equal(result.data.kept, 'put') } }) test(`HEAD ${status} stays HEAD`, async () => { on('HEAD', `${HEY}/messages/9.json`, () => redirectResponse(status, `${HEY}/messages/9-canonical.json`)) on('HEAD', `${HEY}/messages/9-canonical.json`, () => new Response(null, { status: 200, headers: JSON_HEADERS })) on('GET', `${HEY}/messages/9-canonical.json`, () => jsonResponse(200, { leaked: 'get' })) const result = await heyRequest('/messages/9.json', HEAD_INIT) const hop = fixture.requests[1] assert.equal(hop.method, 'HEAD') assert.equal(hop.body, undefined) assert.equal(result.status, 200) assert.equal(result.data, null) }) } test('303 POST drops body headers instead of copying init.headers', async () => { on('POST', `${HEY}/messages.json`, () => redirectResponse(303, `${HEY}/messages/9.json`)) on('GET', `${HEY}/messages/9.json`, () => jsonResponse(200, { id: 9 })) await heyRequest('/messages.json', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': '16', 'Content-Language': 'en', 'Content-Encoding': 'identity', 'Content-Location': `${HEY}/messages.json`, }, body: '{"subject":"hi"}', }) const hop = fixture.requests[1] assert.equal(hop.method, 'GET') assert.equal(hop.headers['content-type'], undefined) assert.equal(hop.headers['content-length'], undefined) assert.equal(hop.headers['content-language'], undefined) assert.equal(hop.headers['content-encoding'], undefined) assert.equal(hop.headers['content-location'], undefined) assert.equal(hop.headers.authorization, BEARER) }) test('307 and 308 replay POST body on the same origin', async () => { for (const status of [307, 308]) { fixture.requests.length = 0 fixture.routes.clear() on('POST', `${HEY}/start`, () => redirectResponse(status, `${HEY}/finish`)) on('POST', `${HEY}/finish`, () => jsonResponse(200, { status })) const result = await heyRequest('/start', POST_INIT) assert.equal(result.data.status, status) assert.equal(fixture.requests[1].method, 'POST') assert.equal(fixture.requests[1].body, POST_INIT.body) } }) test('exactly five redirects succeed', async () => { on('GET', `${HEY}/r0`, () => redirectResponse(302, `${HEY}/r1`)) on('GET', `${HEY}/r1`, () => redirectResponse(302, `${HEY}/r2`)) on('GET', `${HEY}/r2`, () => redirectResponse(302, `${HEY}/r3`)) on('GET', `${HEY}/r3`, () => redirectResponse(302, `${HEY}/r4`)) on('GET', `${HEY}/r4`, () => redirectResponse(302, `${HEY}/r5`)) on('GET', `${HEY}/r5`, () => jsonResponse(200, { hops: 5 })) const result = await heyRequest('/r0') assert.equal(result.data.hops, 5) assert.equal(fixture.requests.length, 6) }) test('a sixth redirect is rejected', async () => { on('GET', `${HEY}/r0`, () => redirectResponse(302, `${HEY}/r1`)) on('GET', `${HEY}/r1`, () => redirectResponse(302, `${HEY}/r2`)) on('GET', `${HEY}/r2`, () => redirectResponse(302, `${HEY}/r3`)) on('GET', `${HEY}/r3`, () => redirectResponse(302, `${HEY}/r4`)) on('GET', `${HEY}/r4`, () => redirectResponse(302, `${HEY}/r5`)) on('GET', `${HEY}/r5`, () => redirectResponse(302, `${HEY}/r6`)) on('GET', `${HEY}/r6`, () => jsonResponse(200, { hops: 6 })) await assert.rejects(() => heyRequest('/r0'), /Too many HEY redirects/) assert.equal(fixture.requests.length, 6) }) test('missing Location is rejected', async () => { on('GET', `${HEY}/from`, () => new Response(null, { status: 302 })) await assert.rejects(() => heyRequest('/from'), /missing Location/) }) test('invalid Location is rejected', async () => { on('GET', `${HEY}/from`, () => redirectResponse(302, 'https://[')) await assert.rejects(() => heyRequest('/from'), /invalid Location/) }) test('http Location is rejected', async () => { on('GET', `${HEY}/from`, () => redirectResponse(302, 'http://app.hey.com/to')) await assert.rejects(() => heyRequest('/from'), /Refusing to follow HEY redirect/) }) test('JSON refuses an off-origin Location', async () => { on('GET', `${HEY}/from`, () => redirectResponse(302, 'https://evil.example/stolen')) await assert.rejects(() => heyRequest('/from'), /Refusing to follow HEY redirect to https:\/\/evil.example/) assert.equal(fixture.requests.length, 1) }) test('resolveHeyUrl refuses a non-HEY origin and strips userinfo', () => { assert.throws(() => resolveHeyUrl('https://evil.example/stolen'), /Refusing to send HEY credentials/) assert.equal(resolveHeyUrl('https://user:pass@app.hey.com/identity'), `${HEY}/identity`) }) test('resolveRedirect strips userinfo from the next hop', () => { const next = resolveRedirect(`${HEY}/from`, 'https://user:pass@app.hey.com/to') assert.equal(next.url, `${HEY}/to`) assert.equal(next.authenticated, true) }) test('createDraft reads a 201 Location without treating it as a redirect', async () => { on('GET', `${HEY}/identity.json`, () => jsonResponse(200, { senders: [{ id: 5, default: true }] })) on('POST', `${HEY}/messages.json`, () => jsonResponse(201, { notice: 'draft' }, { location: `${HEY}/messages/7002` }) ) const draft = await createDraft({ subject: 'Hello', text: 'Saved.' }) assert.equal(draft.id, 7002) assert.equal(draft.status, 201) assert.equal(draft.edit_url, `${HEY}/messages/7002/edit`) assert.deepEqual(fixture.requests.map((row) => row.method), ['GET', 'POST']) }) test('createDraft follows a 303 as GET without the draft body, then rejects with no Location', async () => { on('GET', `${HEY}/identity.json`, () => jsonResponse(200, { senders: [{ id: 5, default: true }] })) on('POST', `${HEY}/messages.json`, () => redirectResponse(303, `${HEY}/messages/7003.json`)) on('GET', `${HEY}/messages/7003.json`, () => jsonResponse(200, { id: 7003, notice: 'draft' })) await assert.rejects(() => createDraft({ subject: 'Hello', text: 'Saved.' }), /Location/) const hop = fixture.requests.at(-1) assert.equal(hop.url, `${HEY}/messages/7003.json`) assert.equal(hop.method, 'GET') assert.equal(hop.body, undefined) assert.equal(hop.headers['content-type'], undefined) }) test('sendDraft confirm true is barred and does not request HEY', async () => { const result = await sendDraft({ id: 9, confirm: true }) assert.equal(result.barred, true) assert.equal(result.wouldSend, false) assert.equal(result.sent, false) assert.equal(result.reason, 'sending-barred') assert.equal(fixture.requests.length, 0) }) test('binary 307 off-origin still becomes an anonymous GET', async () => { on('PUT', `${HEY}/rails/pdf`, () => redirectResponse(307, 'https://hey-storage.example/file.pdf')) fixture.routes.set('GET https://hey-storage.example/file.pdf', () => new Response('PDF', { status: 200 })) fixture.routes.set('PUT https://hey-storage.example/file.pdf', () => new Response('nope', { status: 500 })) const bytes = await heyFetchBinary('/rails/pdf', { method: 'PUT', headers: { Authorization: BEARER, Cookie: 'session=1', 'Content-Type': 'application/pdf' }, body: 'payload', }) assert.equal(new TextDecoder().decode(bytes), 'PDF') const hop = fixture.requests[1] assert.equal(hop.method, 'GET') assert.equal(hop.body, undefined) assert.equal(hop.headers.authorization, undefined) assert.equal(hop.headers.cookie, undefined) assert.equal(hop.headers['content-type'], undefined) })