Skip to content
← 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 · JavaScript
Raw
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)
})