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/token-refresh.test.js

205 lines · 6.9 KB · JavaScript
Raw
import assert from 'node:assert/strict'
import { afterEach, beforeEach, test } from 'node:test'

import identity from '../src/index.js'
import { heyFetchBinary } from '../src/client.js'
import {
  BEARER,
  HEY,
  STORAGE,
  assertCalls,
  disableSecretSetMany,
  failAssertOnly,
  failPersist,
  installFetch,
  jsonResponse,
  kody,
  redirectResponse,
  resetKodySecrets,
  savedSecrets,
  writes,
} from './helpers.mjs'

const OLD_ACCESS = 'old-access'
const OLD_REFRESH = 'old-refresh'
const NEW_ACCESS = 'new-access'
const NEW_REFRESH = 'new-refresh'

let fixture
let consumedRefreshGrants

beforeEach(() => {
  resetKodySecrets()
  consumedRefreshGrants = new Set()
  fixture = installFetch(async (url, init) => {
    const method = String(init.method || 'GET').toUpperCase()
    const handler = fixture.routes.get(`${method} ${url}`)
    if (handler) return handler(url, init)
    throw new Error(`unexpected ${method} ${url}`)
  })
  fixture.routes = new Map()
})

afterEach(() => {
  fixture?.restore()
})

function on(method, url, handler) {
  fixture.routes.set(`${method} ${url}`, handler)
}

function rotatingTokenEndpoint() {
  on('POST', `${HEY}/oauth/tokens`, (_url, init) => {
    const params = new URLSearchParams(String(init.body || ''))
    assert.equal(params.get('refresh_token'), '{{secret:heyRefreshToken}}')
    const grant = savedSecrets.heyRefreshToken
    if (consumedRefreshGrants.has(grant)) {
      return jsonResponse(400, { error: 'invalid_grant' })
    }
    consumedRefreshGrants.add(grant)
    return jsonResponse(200, {
      access_token: NEW_ACCESS,
      refresh_token: NEW_REFRESH,
      token_type: 'Bearer',
    })
  })
}

function expireThenSucceed(path, body) {
  let hits = 0
  on('GET', `${HEY}${path}`, () => {
    hits += 1
    if (hits === 1) return jsonResponse(401, { error: 'expired' })
    return jsonResponse(200, body)
  })
}

function tokenPosts() {
  return fixture.requests.filter((row) => row.url === `${HEY}/oauth/tokens`)
}

test('a credentialed HEY 401 refreshes once and retries', async () => {
  expireThenSucceed('/identity', { id: 4242, name: 'Verify User' })
  rotatingTokenEndpoint()
  const me = await identity()
  assert.deepEqual(me, { id: 4242, name: 'Verify User' })
  assert.equal(assertCalls.length, 1)
  assert.equal(assertCalls[0].assertOnly, true)
  assert.deepEqual(
    assertCalls[0].secrets.map((s) => s.name),
    ['heyToken', 'heyRefreshToken']
  )
  const persist = writes.find((row) => row.op === 'secretSetMany')
  assert.ok(persist)
  assert.equal(persist.secrets[0].name, 'heyRefreshToken')
  assert.equal(persist.secrets[0].value, NEW_REFRESH)
  assert.equal(persist.secrets[1].name, 'heyToken')
  assert.equal(persist.secrets[1].value, NEW_ACCESS)
  assert.equal(savedSecrets.heyToken, NEW_ACCESS)
  assert.equal(savedSecrets.heyRefreshToken, NEW_REFRESH)
  assert.equal(tokenPosts().length, 1)
  assert.equal(fixture.requests[0].headers.authorization, BEARER)
})

test('write authorization is checked before the token exchange', async () => {
  expireThenSucceed('/identity', { id: 4242, name: 'Verify User' })
  rotatingTokenEndpoint()
  failAssertOnly()
  await assert.rejects(() => identity(), /cannot write heyToken/)
  assert.equal(tokenPosts().length, 0)
  assert.equal(writes.length, 0)
  assert.equal(savedSecrets.heyToken, OLD_ACCESS)
  assert.equal(savedSecrets.heyRefreshToken, OLD_REFRESH)
})

test('a missing secretSetMany fails before the grant is spent', async () => {
  disableSecretSetMany()
  expireThenSucceed('/identity', { id: 4242, name: 'Verify User' })
  rotatingTokenEndpoint()
  await assert.rejects(() => identity(), (err) => {
    assert.match(String(err.message), /requires kody.secretSetMany/)
    assert.equal(err.code, 'HEY_TOKEN_PERSIST_UNSUPPORTED')
    return true
  })
  assert.equal(tokenPosts().length, 0)
  assert.equal(writes.length, 0)
  assert.equal(savedSecrets.heyRefreshToken, OLD_REFRESH)
})

test('secretSet rejects a user-secret update with no nonempty value', async () => {
  await assert.rejects(
    () => kody.secretSet({ name: 'heyToken', scope: 'user' }),
    /nonempty value/
  )
  await assert.rejects(
    () => kody.secretSet({ name: 'heyToken', scope: 'user', value: '' }),
    /nonempty value/
  )
  await assert.rejects(
    () =>
      kody.secretSet({
        name: 'heyRefreshToken',
        scope: 'user',
        description: 'HEY OAuth refresh token (auto-rotated on 401)',
      }),
    /nonempty value/
  )
  assert.equal(savedSecrets.heyToken, OLD_ACCESS)
  assert.equal(savedSecrets.heyRefreshToken, OLD_REFRESH)
})

test('a transient batch-save failure retries the returned pair without another OAuth exchange', async () => {
  expireThenSucceed('/identity', { id: 4242, name: 'Verify User' })
  rotatingTokenEndpoint()
  failPersist(1)
  const me = await identity()
  assert.equal(me.id, 4242)
  assert.equal(tokenPosts().length, 1)
  assert.equal(writes.filter((row) => row.op === 'secretSetMany').length, 1)
  assert.equal(savedSecrets.heyToken, NEW_ACCESS)
  assert.equal(savedSecrets.heyRefreshToken, NEW_REFRESH)
  assert.ok(consumedRefreshGrants.has(OLD_REFRESH))
  assert.equal(consumedRefreshGrants.has(NEW_REFRESH), false)
})

test('a lasting batch-save failure keeps the old secrets and reports rotation loss', async () => {
  expireThenSucceed('/identity', { id: 4242, name: 'Verify User' })
  rotatingTokenEndpoint()
  failPersist(2)
  await assert.rejects(() => identity(), (err) => {
    assert.match(String(err.message), /could not persist the rotated/)
    assert.match(String(err.message), /refresh grant was already consumed/)
    assert.equal(err.code, 'HEY_TOKEN_ROTATION_UNSAVED')
    return true
  })
  assert.equal(tokenPosts().length, 1)
  assert.equal(writes.length, 0)
  assert.equal(savedSecrets.heyToken, OLD_ACCESS)
  assert.equal(savedSecrets.heyRefreshToken, OLD_REFRESH)
  assert.ok(consumedRefreshGrants.has(OLD_REFRESH))

  on('GET', `${HEY}/identity`, () => jsonResponse(401, { error: 'expired' }))
  await assert.rejects(() => identity(), /HEY token refresh failed \(HTTP 400\)/)
  assert.equal(tokenPosts().length, 2)
  assert.equal(savedSecrets.heyRefreshToken, OLD_REFRESH)
})

test('a storage 401 does not rotate HEY tokens', async () => {
  on('GET', `${HEY}/rails/file.pdf`, () => redirectResponse(302, `${STORAGE}/file.pdf`))
  on('GET', `${STORAGE}/file.pdf`, () => jsonResponse(401, { error: 'storage' }))
  rotatingTokenEndpoint()
  await assert.rejects(() => heyFetchBinary('/rails/file.pdf'), /HEY binary 401/)
  assert.equal(tokenPosts().length, 0)
  assert.equal(writes.length, 0)
  assert.equal(assertCalls.length, 0)
  assert.equal(savedSecrets.heyRefreshToken, OLD_REFRESH)
})

test('a second credentialed HEY 401 does not refresh again', async () => {
  on('GET', `${HEY}/identity`, () => jsonResponse(401, { error: 'still-expired' }))
  rotatingTokenEndpoint()
  await assert.rejects(() => identity(), /HEY 401 \/identity/)
  assert.equal(tokenPosts().length, 1)
  assert.equal(savedSecrets.heyToken, NEW_ACCESS)
  assert.equal(savedSecrets.heyRefreshToken, NEW_REFRESH)
})