Files
orca/src/main/updater-release-api-token.test.ts
T
Jinjing 7da9788c83 fix(updater): send the gh token and cache the release picker's build list (#21902)
* fix(updater): send the gh token and cache the release picker's build list

The dev build picker listed releases through api.github.com with no
Authorization header, so it spent GitHub's 60/hour per-IP bucket that every
unauthenticated caller on the same network shares, and it refetched on every
settings mount and channel click. When that bucket ran dry the picker showed
"No builds found" with a rate-limit line even though GitHub was healthy and
the user's own token had its full quota.

Attach the local `gh auth token` when there is one so the request draws from
the user's 5000/hour bucket, fall back to unauthenticated on a rejected token
or a spent token bucket, cache the list per channel for five minutes in the
main process (the refresh button forces a reload), classify 403 by the
rate-limit headers, and say when the limit resets.

Fixes #21898

* fix(updater): don't trip breaker for secondary rate limits

GitHub sends x-ratelimit-remaining: 0 on both primary and secondary
limits. Secondary limits carry Retry-After and shouldn't block all core
gh commands — only the primary limit should trip the shared breaker.

* Scope gh rate limits to execution environment

* Add build list cache hint to release channel settings

Inform users that build lists are cached for 5 minutes and they can
refresh to check for new builds immediately. This makes the cache
behavior visible and explains why a manual refresh is necessary to
bypass the cache.
2026-09-21 11:52:37 -07:00

140 lines
5.0 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
const ghExecMock = vi.fn()
vi.mock('./git/runner', () => ({
ghExecFileWithScopeAsync: async (...args: unknown[]) => ({
rateLimitScope: 'native:github.com',
...(await ghExecMock(...args))
})
}))
const { _resetReleaseApiTokenCache, rejectReleaseApiToken, resolveReleaseApiToken } =
await import('./updater-release-api-token')
const T0 = 1_000_000
const TTL_MS = 5 * 60_000
describe('resolveReleaseApiToken', () => {
beforeEach(() => {
ghExecMock.mockReset()
_resetReleaseApiTokenCache()
})
it('reads the github.com token through gh and trims it', async () => {
ghExecMock.mockResolvedValue({ stdout: 'gho_abc\n', stderr: '' })
await expect(resolveReleaseApiToken(T0)).resolves.toEqual({
token: 'gho_abc',
rateLimitScope: 'native:github.com'
})
expect(ghExecMock.mock.calls[0][0]).toEqual(['auth', 'token', '--hostname', 'github.com'])
expect(ghExecMock.mock.calls[0][1]).toMatchObject({ idempotent: false })
})
it('serves the cached token within its TTL and re-reads after it', async () => {
ghExecMock.mockResolvedValue({ stdout: 'gho_abc', stderr: '' })
await resolveReleaseApiToken(T0)
await resolveReleaseApiToken(T0 + TTL_MS - 1)
expect(ghExecMock).toHaveBeenCalledTimes(1)
await resolveReleaseApiToken(T0 + TTL_MS + 1)
expect(ghExecMock).toHaveBeenCalledTimes(2)
})
// Why: gh missing or logged out is the unauthenticated path the picker always
// had; it must not fail the list, and it must not spawn gh on every click.
it('returns null when gh is missing or logged out and does not re-spawn within the TTL', async () => {
ghExecMock.mockRejectedValueOnce(new Error('gh: not found'))
await expect(resolveReleaseApiToken(T0)).resolves.toBeNull()
await expect(resolveReleaseApiToken(T0 + TTL_MS - 1)).resolves.toBeNull()
expect(ghExecMock).toHaveBeenCalledTimes(1)
ghExecMock.mockResolvedValueOnce({ stdout: 'gho_new', stderr: '' })
await expect(resolveReleaseApiToken(T0 + TTL_MS + 1)).resolves.toEqual({
token: 'gho_new',
rateLimitScope: 'native:github.com'
})
})
it('treats empty output as no token', async () => {
ghExecMock.mockResolvedValue({ stdout: '\n', stderr: '' })
await expect(resolveReleaseApiToken(T0)).resolves.toBeNull()
})
it('caches the successful WSL scope alongside the token', async () => {
ghExecMock.mockResolvedValue({
stdout: 'gho_wsl\n',
stderr: '',
rateLimitScope: 'wsl:ubuntu:github.com'
})
const expected = { token: 'gho_wsl', rateLimitScope: 'wsl:ubuntu:github.com' }
await expect(resolveReleaseApiToken(T0)).resolves.toEqual(expected)
await expect(resolveReleaseApiToken(T0 + TTL_MS - 1)).resolves.toEqual(expected)
expect(ghExecMock).toHaveBeenCalledTimes(1)
})
it('shares one in-flight read between concurrent callers', async () => {
let finish: (value: { stdout: string; stderr: string }) => void = () => {}
ghExecMock.mockReturnValue(
new Promise((resolve) => {
finish = resolve
})
)
const first = resolveReleaseApiToken(T0)
const second = resolveReleaseApiToken(T0)
finish({ stdout: 'gho_abc', stderr: '' })
await expect(Promise.all([first, second])).resolves.toEqual([
{ token: 'gho_abc', rateLimitScope: 'native:github.com' },
{ token: 'gho_abc', rateLimitScope: 'native:github.com' }
])
expect(ghExecMock).toHaveBeenCalledTimes(1)
})
// Why: a token GitHub rejected would otherwise be re-read from the keyring
// and re-sent on every load — one gh spawn and one wasted request each time.
it('goes unauthenticated for a TTL after the token is rejected', async () => {
ghExecMock.mockResolvedValue({ stdout: 'gho_stale', stderr: '' })
await expect(resolveReleaseApiToken(T0)).resolves.toEqual({
token: 'gho_stale',
rateLimitScope: 'native:github.com'
})
rejectReleaseApiToken(T0)
await expect(resolveReleaseApiToken(T0 + TTL_MS - 1)).resolves.toBeNull()
expect(ghExecMock).toHaveBeenCalledTimes(1)
await expect(resolveReleaseApiToken(T0 + TTL_MS + 1)).resolves.toEqual({
token: 'gho_stale',
rateLimitScope: 'native:github.com'
})
expect(ghExecMock).toHaveBeenCalledTimes(2)
})
// Why: the cache can expire mid-fetch, so a second load starts a fresh read while
// the first is still waiting on the 401 that rejects the token. Without the
// generation guard that read writes the rejected token back over the rejection.
it('keeps the rejection when a read that started before it resolves', async () => {
let finish: (value: { stdout: string; stderr: string }) => void = () => {}
ghExecMock.mockReturnValue(
new Promise((resolve) => {
finish = resolve
})
)
const inFlight = resolveReleaseApiToken(T0)
rejectReleaseApiToken(T0)
finish({ stdout: 'gho_stale', stderr: '' })
await expect(inFlight).resolves.toBeNull()
await expect(resolveReleaseApiToken(T0 + TTL_MS - 1)).resolves.toBeNull()
expect(ghExecMock).toHaveBeenCalledTimes(1)
})
})