hermes-agent/apps/desktop/electron/native-token-store.test.ts

328 lines
12 KiB
TypeScript

/**
* Tests for electron/native-token-store.ts — the encrypted-at-rest persistence
* seam main.ts uses for RFC 8252 native OAuth tokens.
*
* The regression this file exists for (#73271): tokens are persisted as a
* normalized camelCase NativeTokenSet, but the reload path fed the freshly
* decrypted object to parseTokenResponse(), which only understands the
* gateway's snake_case response. It threw on every launch, so a signed-in user
* came back signed out. The parser boundary now lives inside
* loadNativeTokenSet(), so these tests fail if it is ever crossed again.
*
* "Fresh load" here means what it means after a restart: nothing survives but
* the bytes of the store file, so every assertion below is served by
* deserializing and decrypting that text — never by an in-memory object.
*
* (Wired into the vitest `electron` project via electron/**\/*.test.ts.)
*/
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { type NativeTokenSet, parseStoredTokenSet, parseTokenResponse } from './native-oauth'
import { loadNativeTokenSet, type NativeTokenStoreIo, persistNativeTokenSet } from './native-token-store'
const GATEWAY = 'https://gw.example.com'
const TOKENS: NativeTokenSet = {
accessToken: 'AT-live-abc123',
refreshToken: 'RT-live-xyz789',
expiresAt: 1_893_456_000,
provider: 'nous',
userId: 'u-42'
}
interface FakeDisk {
io: NativeTokenStoreIo
logs: string[]
/** The store-file text as it would sit on disk; null when the file is absent. */
fileText: () => string | null
}
/**
* A stand-in for the userData store file plus safeStorage. Encryption is
* base64 rather than the OS keychain — opaque-blob-in, same-plaintext-out is
* the only property this seam depends on, and it keeps the round trip
* observable. `initialText` models a process restart: the new "process" starts
* with nothing but the bytes the previous one wrote.
*/
function createFakeDisk(initialText: string | null = null, overrides: Partial<NativeTokenStoreIo> = {}): FakeDisk {
let text = initialText
const logs: string[] = []
const io: NativeTokenStoreIo = {
encrypt: plaintext => ({ encoding: 'safeStorage', value: Buffer.from(plaintext, 'utf8').toString('base64') }),
decrypt: secret =>
secret?.encoding === 'safeStorage' ? Buffer.from(String(secret.value), 'base64').toString('utf8') : '',
readStoreText: () => {
if (text === null) {
// Matches fs.readFileSync on a missing file: throws, not empty string.
throw Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' })
}
return text
},
writeStoreText: next => {
text = next
},
rememberLog: message => logs.push(message),
...overrides
}
return { io, logs, fileText: () => text }
}
// --- the restart round trip ---
test('a camelCase token set survives store then a fresh load', () => {
const first = createFakeDisk()
persistNativeTokenSet(GATEWAY, TOKENS, first.io)
const onDisk = first.fileText()
assert.ok(onDisk, 'persisting must write the store file')
// Nothing may survive the "restart" except those bytes.
const restarted = createFakeDisk(onDisk)
const loaded = loadNativeTokenSet(GATEWAY, restarted.io)
assert.ok(loaded, 'a stored token set must reload after a restart')
// Reconstructed from the payload, not handed back the object we stored.
assert.notEqual(loaded, TOKENS)
assert.deepEqual(loaded, TOKENS)
assert.deepEqual(restarted.logs, [])
})
test('a fresh load restores both tokens and preserves expiry, provider and user', () => {
const first = createFakeDisk()
persistNativeTokenSet(GATEWAY, TOKENS, first.io)
const loaded = loadNativeTokenSet(GATEWAY, createFakeDisk(first.fileText()).io)!
assert.equal(loaded.accessToken, 'AT-live-abc123')
assert.equal(loaded.refreshToken, 'RT-live-xyz789')
// Still a number after the JSON round trip, not "1893456000".
assert.equal(loaded.expiresAt, 1_893_456_000)
assert.equal(typeof loaded.expiresAt, 'number')
assert.equal(loaded.provider, 'nous')
assert.equal(loaded.userId, 'u-42')
})
test('the loaded set is accepted by the stored-token parsing boundary', () => {
const first = createFakeDisk()
persistNativeTokenSet(GATEWAY, TOKENS, first.io)
const loaded = loadNativeTokenSet(GATEWAY, createFakeDisk(first.fileText()).io)!
// What comes back out of the store is itself a valid stored set — re-parsing
// it is a no-op, so callers can hand it straight to the refresh path.
assert.deepEqual(parseStoredTokenSet(loaded), loaded)
})
test('the persisted payload is what broke the old reload path (#73271)', () => {
const first = createFakeDisk()
persistNativeTokenSet(GATEWAY, TOKENS, first.io)
const restarted = createFakeDisk(first.fileText())
const secret = JSON.parse(restarted.fileText()!)[GATEWAY]
const decrypted = JSON.parse(restarted.io.decrypt(secret))
// The old code passed exactly this object to parseTokenResponse(). A
// normalized set has no snake_case access_token, so every launch threw and
// the user was shown as signed out...
assert.throws(() => parseTokenResponse(decrypted), /missing access_token/i)
// ...while the real load path reads the same bytes successfully.
assert.deepEqual(loadNativeTokenSet(GATEWAY, restarted.io), TOKENS)
})
test('the full login-to-restart sequence keeps the two parser boundaries apart', () => {
// Login: the gateway answers /auth/native/token in snake_case, and only
// parseTokenResponse() understands that shape.
const fromGateway = parseTokenResponse({
access_token: 'AT-fresh',
refresh_token: 'RT-fresh',
expires_at: 1_893_456_789,
provider: 'nous',
user_id: 'u-77'
})
const first = createFakeDisk()
persistNativeTokenSet(GATEWAY, fromGateway, first.io)
// Restart: what was stored is normalized, so the store's own boundary reads
// it back unchanged.
assert.deepEqual(loadNativeTokenSet(GATEWAY, createFakeDisk(first.fileText()).io), fromGateway)
})
// --- storage hygiene ---
test('tokens are encrypted at rest, never plaintext in the store file', () => {
const disk = createFakeDisk()
persistNativeTokenSet(GATEWAY, TOKENS, disk.io)
const onDisk = disk.fileText()!
assert.doesNotMatch(onDisk, /AT-live-abc123/)
assert.doesNotMatch(onDisk, /RT-live-xyz789/)
assert.equal(JSON.parse(onDisk)[GATEWAY].encoding, 'safeStorage')
})
test('persisting one gateway leaves other gateways intact', () => {
const other = 'https://other.example.com'
const disk = createFakeDisk()
persistNativeTokenSet(GATEWAY, TOKENS, disk.io)
persistNativeTokenSet(other, { ...TOKENS, accessToken: 'AT-other', userId: 'u-99' }, disk.io)
const restarted = createFakeDisk(disk.fileText())
assert.equal(loadNativeTokenSet(GATEWAY, restarted.io)!.accessToken, 'AT-live-abc123')
assert.equal(loadNativeTokenSet(other, restarted.io)!.accessToken, 'AT-other')
})
test('clearing removes only that gateway and reloads as signed out', () => {
const other = 'https://other.example.com'
const disk = createFakeDisk()
persistNativeTokenSet(GATEWAY, TOKENS, disk.io)
persistNativeTokenSet(other, TOKENS, disk.io)
persistNativeTokenSet(GATEWAY, null, disk.io)
const restarted = createFakeDisk(disk.fileText())
assert.equal(loadNativeTokenSet(GATEWAY, restarted.io), null)
assert.ok(loadNativeTokenSet(other, restarted.io))
})
test('an absent store file loads as signed out without logging a failure', () => {
const disk = createFakeDisk()
assert.equal(loadNativeTokenSet(GATEWAY, disk.io), null)
assert.deepEqual(disk.logs, [])
})
// --- failure paths (unchanged by the extraction) ---
test('a locked keychain keeps the stored entry for a later retry', () => {
const first = createFakeDisk()
persistNativeTokenSet(GATEWAY, TOKENS, first.io)
// safeStorage unavailable at load time ⇒ decryptDesktopSecret returns ''.
const locked = createFakeDisk(first.fileText(), { decrypt: () => '' })
assert.equal(loadNativeTokenSet(GATEWAY, locked.io), null)
assert.match(locked.logs[0], /failed to decrypt stored tokens for https:\/\/gw\.example\.com/)
assert.match(locked.logs[0], /keeping stored entry for retry/)
// The refresh token must NOT be dropped just because the keychain was locked.
assert.deepEqual(locked.fileText(), first.fileText())
})
test('a corrupt store file loads as signed out instead of throwing', () => {
const disk = createFakeDisk('{not json')
assert.equal(loadNativeTokenSet(GATEWAY, disk.io), null)
assert.deepEqual(disk.logs, [])
})
test('an array store file loads as signed out instead of throwing', () => {
const disk = createFakeDisk('[]')
assert.equal(loadNativeTokenSet(GATEWAY, disk.io), null)
assert.deepEqual(disk.logs, [])
})
test('an array store file is replaced by a real map rather than swallowing the write', () => {
const disk = createFakeDisk('[]')
persistNativeTokenSet(GATEWAY, TOKENS, disk.io)
const written = JSON.parse(disk.fileText()!)
// Assigning store[baseUrl] on an array sets a non-index property, which
// JSON.stringify drops — the write would report success and the tokens would
// be gone on the next launch.
assert.equal(Array.isArray(written), false)
assert.ok(written[GATEWAY], 'the gateway entry must survive serialization')
// And it really does come back after a restart.
assert.deepEqual(loadNativeTokenSet(GATEWAY, createFakeDisk(disk.fileText()).io), TOKENS)
})
test('a corrupt decrypted blob is reported and loads as signed out', () => {
const disk = createFakeDisk(JSON.stringify({ [GATEWAY]: { encoding: 'safeStorage', value: 'bm90LWpzb24=' } }))
assert.equal(loadNativeTokenSet(GATEWAY, disk.io), null)
assert.match(disk.logs[0], /failed to load stored tokens for https:\/\/gw\.example\.com/)
})
test('a decrypted blob missing accessToken is rejected, not half-restored', () => {
const plaintext = JSON.stringify({ refreshToken: 'RT-only', provider: 'nous' })
const disk = createFakeDisk(
JSON.stringify({ [GATEWAY]: { encoding: 'safeStorage', value: Buffer.from(plaintext).toString('base64') } })
)
assert.equal(loadNativeTokenSet(GATEWAY, disk.io), null)
assert.match(disk.logs[0], /missing accessToken/i)
})
test('a non-Error decryption failure keeps its detail in the log', () => {
const disk = createFakeDisk(JSON.stringify({ [GATEWAY]: { encoding: 'safeStorage', value: 'AAAA' } }), {
decrypt: () => {
throw 'keychain exploded'
}
})
assert.equal(loadNativeTokenSet(GATEWAY, disk.io), null)
assert.match(disk.logs[0], /keychain exploded/)
})
test('an unwritable store file is logged rather than thrown', () => {
const disk = createFakeDisk(null, {
writeStoreText: () => {
throw new Error('EACCES: permission denied')
}
})
assert.doesNotThrow(() => persistNativeTokenSet(GATEWAY, TOKENS, disk.io))
assert.match(disk.logs[0], /failed to persist tokens: EACCES/)
})
test('a non-Error write failure keeps its detail in the log', () => {
const disk = createFakeDisk(null, {
writeStoreText: () => {
throw 'disk went away'
}
})
// `(error as Error).message` on a thrown string reads as undefined and loses
// the only diagnostic there was.
assert.doesNotThrow(() => persistNativeTokenSet(GATEWAY, TOKENS, disk.io))
assert.equal(disk.logs[0], '[native-oauth] failed to persist tokens: disk went away')
})
test('an unusable keychain fails the write loudly and writes nothing', () => {
const existing = createFakeDisk()
persistNativeTokenSet(GATEWAY, TOKENS, existing.io)
const before = existing.fileText()
const broken = createFakeDisk(before, {
encrypt: () => {
throw new Error('Secure token storage is unavailable')
}
})
// Storing must not pretend to succeed when the token cannot be encrypted...
assert.throws(() => persistNativeTokenSet(GATEWAY, { ...TOKENS, accessToken: 'AT-new' }, broken.io), /unavailable/)
// ...and must not clobber the tokens already on disk.
assert.equal(broken.fileText(), before)
})