fix: reject expired updater feed selections (#23160)

This commit is contained in:
Neil
2026-09-26 12:58:32 -07:00
committed by GitHub
parent 1a982c2739
commit 2819faa1b6
2 changed files with 129 additions and 0 deletions
@@ -0,0 +1,124 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { loadUpdaterModule, warmUpdaterModule } from './updater-test-module-loader'
const { autoUpdaterMock, fetchNewerReleaseTagsMock, moduleFactories, resetUpdaterMocks } =
await vi.hoisted(async () => (await import('./updater-test-harness')).createUpdaterMocks())
vi.mock('electron', () => moduleFactories.electron())
vi.mock('electron-updater', () => moduleFactories.electronUpdater())
vi.mock('./electron-updater-loader', () => moduleFactories.electronUpdaterLoader())
vi.mock('@electron-toolkit/utils', () => moduleFactories.electronToolkitUtils())
vi.mock('./ipc/pty', () => moduleFactories.ipcPty())
vi.mock('./linux-update-package-type', () => moduleFactories.linuxUpdatePackageType())
vi.mock('./updater-lifecycle-diagnostics', () => moduleFactories.updaterLifecycleDiagnostics())
vi.mock('./updater-changelog', () => moduleFactories.updaterChangelog())
vi.mock('./updater-nudge', () => moduleFactories.updaterNudge())
vi.mock('./update-install-exit-watchdog', () => moduleFactories.updateInstallExitWatchdog())
vi.mock('./updater-prerelease-feed', () => moduleFactories.updaterPrereleaseFeed())
vi.mock('./local-builds/local-build-switch', () => moduleFactories.localBuildSwitch())
vi.mock('./local-builds/local-build-feed-server', () => moduleFactories.localBuildFeedServer())
warmUpdaterModule()
type FeedResult =
| { tags: string[]; state: 'ready' | 'no-newer' }
| { tags: string[]; state: 'not-ready'; lastGoodTag: string }
const expiredResults: FeedResult[] = [
{ tags: ['v2.0.0'], state: 'ready' },
{ tags: [], state: 'no-newer' },
{ tags: [], state: 'not-ready', lastGoodTag: 'v1.9.0' }
]
function holdFirstPreflight(): (value: FeedResult) => void {
let finish = (_value: FeedResult): void => {}
fetchNewerReleaseTagsMock.mockImplementationOnce(
() =>
new Promise<FeedResult>((resolve) => {
finish = resolve
})
)
return (value) => finish(value)
}
async function setup() {
const updater = await loadUpdaterModule()
const mainWindow = { webContents: { send: vi.fn() } }
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Only webContents.send is used; all native Electron APIs are mocked.
updater.setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
return updater
}
describe('updater feed preflight ownership', () => {
beforeEach(() => {
resetUpdaterMocks()
vi.useFakeTimers()
autoUpdaterMock.checkForUpdates.mockImplementation(() => new Promise(() => {}))
})
it.each(expiredResults)('does not repin after a timed-out $state result', async (result) => {
const finish = holdFirstPreflight()
const updater = await setup()
updater.checkForUpdatesFromMenu()
await vi.advanceTimersByTimeAsync(45_000)
const settledStatus = updater.getUpdateStatus()
expect(settledStatus.state).toBe('error')
autoUpdaterMock.setFeedURL.mockClear()
finish(result)
await vi.advanceTimersByTimeAsync(0)
expect(autoUpdaterMock.setFeedURL).not.toHaveBeenCalled()
expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled()
expect(updater.getUpdateStatus()).toBe(settledStatus)
})
it.each(['manual', 'background'] as const)(
'preserves a newer feed after an older %s preflight resolves',
async (kind) => {
const finishOld = holdFirstPreflight()
fetchNewerReleaseTagsMock.mockResolvedValueOnce({ tags: ['v3.0.0'], state: 'ready' })
const updater = await setup()
if (kind === 'manual') {
updater.checkForUpdatesFromMenu()
} else {
updater.checkForUpdates()
}
await vi.advanceTimersByTimeAsync(45_000)
updater.checkForUpdatesFromMenu()
await vi.advanceTimersByTimeAsync(0)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/download/v3.0.0'
})
autoUpdaterMock.setFeedURL.mockClear()
finishOld({ tags: ['v2.0.0'], state: 'ready' })
await vi.advanceTimersByTimeAsync(0)
expect(autoUpdaterMock.setFeedURL).not.toHaveBeenCalled()
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
}
)
it.each([
{
result: { tags: ['v3.0.0'], state: 'ready' },
url: 'https://github.com/stablyai/orca/releases/download/v3.0.0'
},
{
result: { tags: [], state: 'no-newer' },
url: 'https://github.com/stablyai/orca/releases/latest/download'
}
])('keeps the active $result.state feed choice', async ({ result, url }) => {
fetchNewerReleaseTagsMock.mockResolvedValueOnce(result)
const updater = await setup()
autoUpdaterMock.setFeedURL.mockClear()
updater.checkForUpdatesFromMenu()
await vi.advanceTimersByTimeAsync(0)
expect(autoUpdaterMock.setFeedURL).toHaveBeenCalledTimes(1)
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ provider: 'generic', url })
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
})
+5
View File
@@ -114,6 +114,7 @@ export abstract class UpdaterReleaseFeed extends UpdaterInstallExecution {
protected async pinDefaultReleaseFeed(
variant: UpdateCheckVariant = 'default'
): Promise<'ready' | 'not-available'> {
const attemptId = this.activeUpdateCheckAttemptId
const autoUpdater = this.getAutoUpdater()
// Why: the latest/download redirect can move between check and download, so pin the concrete tag (prerelease users resolve any channel, stable only stable).
const currentVersion = app.getVersion()
@@ -128,6 +129,10 @@ export abstract class UpdaterReleaseFeed extends UpdaterInstallExecution {
...(isPerfCheck ? { releaseFilter: 'perf' as const } : {})
}
)
// A timed-out preflight must not overwrite a newer check's feed.
if (attemptId === null || !this.isActiveUpdateCheckAttempt(attemptId)) {
return 'not-available'
}
const newerTag = releaseTagsResult.tags[0] ?? null
const fallbackTag = includePrerelease ? (releaseTagsResult.tags[1] ?? null) : null
this.pendingPrereleaseFallback =