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) })