← Public packages
@danj/hey-email
HEY.com email for Kody: search threads, read messages, download PDF attachments, and write drafts. Sending is barred until you allow it.
test/redirects.test.js
273 lines · 11.4 KB · JavaScriptimport 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)
})