← 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 · JavaScriptimport 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)
})