From dd49ef65aad3d2a7bf99ba47e81f8401313b79c7 Mon Sep 17 00:00:00 2001 From: Anso Date: Sat, 27 Jun 2026 23:29:33 -0400 Subject: [PATCH] fix: render node-update changelog only for the advertised release version (#1493) The changelog recorded every settled release-notes fetch as loaded for the advertised version and rendered whatever notes came back, without checking that the returned version matched. Because the version lookup and the release-notes lookup use independent caches (and the version can fall back to Docker Hub while notes are GitHub-only), a drifted response could show one version's notes under a different advertised update. The changelog now binds strictly: notes render only when the endpoint confirms they belong to the advertised latest version; a mismatch falls through to the empty state with the online changelog link. The settled-version latch is kept so a mismatch does not loop the fetch. --- .../components/FleetView/NodeUpdatesSheet.tsx | 13 +++- .../__tests__/NodeUpdatesSheet.test.tsx | 68 ++++++++++++++++++- 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/FleetView/NodeUpdatesSheet.tsx b/frontend/src/components/FleetView/NodeUpdatesSheet.tsx index 3edbb068..cf3fb9c9 100644 --- a/frontend/src/components/FleetView/NodeUpdatesSheet.tsx +++ b/frontend/src/components/FleetView/NodeUpdatesSheet.tsx @@ -78,9 +78,16 @@ export function NodeUpdatesSheet({ .then(res => res.ok ? res.json() as Promise<{ version: string | null; releaseNotes: string | null; htmlUrl: string | null }> : null) .then(data => { if (data) { - setReleaseNotes(data.releaseNotes); - setReleaseHtmlUrl(data.htmlUrl); - setReleaseVersion(data.version); + // Bind strictly to the advertised update: only render notes the + // endpoint confirms belong to the advertised version. The version + // lookup and the release-notes lookup use independent caches (and + // version can fall back to Docker Hub while notes are GitHub-only), + // so a drifted response must fall through to the empty state with + // the online changelog link rather than show another version's notes. + const matches = data.version !== null && data.version === advertisedLatest; + setReleaseNotes(matches ? data.releaseNotes : null); + setReleaseHtmlUrl(matches ? data.htmlUrl : null); + setReleaseVersion(matches ? data.version : null); } }) .catch((err) => { diff --git a/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx b/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx index 2b30d2e3..ba01aab1 100644 --- a/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx +++ b/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx @@ -54,6 +54,7 @@ describe('NodeUpdatesSheet', () => { apiFetchMock.mockResolvedValue({ ok: true, json: async () => ({ + version: '1.1.0', releaseNotes: '## [0.92.0](https://example.com/compare) (2026-06-19)\n\n### Added\n\n* add a toggle ([#1363](https://example.com/issues/1363))\n', htmlUrl: 'https://example.com/releases/v0.92.0', }), @@ -88,7 +89,7 @@ describe('NodeUpdatesSheet', () => { await screen.findByText('No release notes to show'); expect(releaseCalls()).toBe(1); rerender(); - // The releaseLoaded latch keeps a null result from re-triggering the fetch. + // The loadedForVersion latch keeps a null result from re-triggering the fetch. await waitFor(() => expect(releaseCalls()).toBe(1)); }); @@ -147,10 +148,73 @@ describe('NodeUpdatesSheet', () => { await waitFor(() => expect(releaseCalls()).toBe(1)); }); + it('does not render notes whose version differs from the advertised update', async () => { + // Realistic cache offset: the advertised latest is 1.2.0 but the release-notes + // endpoint still returns 1.1.0 (the version lookup and notes lookup use + // independent caches; version can come from Docker Hub while notes are + // GitHub-only). The 1.1.0 notes must never render as the 1.2.0 changelog. + apiFetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ version: '1.1.0', releaseNotes: '## v1.1.0 notes', htmlUrl: 'https://example.com/v1.1.0' }), + }); + const releaseCalls = () => apiFetchMock.mock.calls.filter(c => String(c[0]).includes('release-notes')).length; + const advertised120 = STATUSES.map(s => ({ ...s, latestVersion: '1.2.0' })); + render(); + + // The mismatched notes fall through to the empty state with the online link. + expect(await screen.findByText('No release notes to show')).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'v1.1.0 notes' })).not.toBeInTheDocument(); + expect(screen.queryByText('Release v1.1.0')).not.toBeInTheDocument(); + // The whole mismatched payload is discarded, including its GitHub link. + expect(screen.queryByRole('link', { name: /View on GitHub/ })).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: /View on Sencho/ })).toHaveAttribute('href', 'https://sencho.io/changelog'); + // The settled mismatch does not loop the fetch. + await waitFor(() => expect(releaseCalls()).toBe(1)); + }); + + it('recovers via Recheck when the endpoint catches up to the advertised version', async () => { + // Start mismatched (advertised 1.2.0, endpoint still 1.1.0): empty state. + const advertised120 = STATUSES.map(s => ({ ...s, latestVersion: '1.2.0' })); + apiFetchMock.mockImplementation((url: string) => + String(url).includes('release-notes') + ? Promise.resolve({ ok: true, json: async () => ({ version: '1.1.0', releaseNotes: '## v1.1.0 notes', htmlUrl: null }) }) + : Promise.resolve({ ok: true, json: async () => ({ rechecked: true }) }), + ); + render(); + expect(await screen.findByText('No release notes to show')).toBeInTheDocument(); + + // The recheck surfaces the matching 1.2.0 notes; clicking Recheck must + // recover the previously-mismatched changelog rather than stay empty. + apiFetchMock.mockImplementation((url: string) => + String(url).includes('release-notes') + ? Promise.resolve({ ok: true, json: async () => ({ version: '1.2.0', releaseNotes: '## v1.2.0 notes', htmlUrl: null }) }) + : Promise.resolve({ ok: true, json: async () => ({ rechecked: true }) }), + ); + fireEvent.click(screen.getByRole('button', { name: 'Recheck' })); + expect(await screen.findByRole('heading', { name: 'v1.2.0 notes' })).toBeInTheDocument(); + expect(screen.getByText('Release v1.2.0')).toBeInTheDocument(); + }); + + it('shows the empty state without looping when the advertised version is unknown', async () => { + // Local node has no resolved latestVersion: advertisedLatest is null, so a + // non-null endpoint version cannot match and the notes must not render. The + // settle must still record null and not loop the fetch. + const noLatest = STATUSES.map(s => ({ ...s, latestVersion: null })); + apiFetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ version: '1.1.0', releaseNotes: '## v1.1.0 notes', htmlUrl: null }), + }); + const releaseCalls = () => apiFetchMock.mock.calls.filter(c => String(c[0]).includes('release-notes')).length; + render(); + expect(await screen.findByText('No release notes to show')).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'v1.1.0 notes' })).not.toBeInTheDocument(); + await waitFor(() => expect(releaseCalls()).toBe(1)); + }); + it('Recheck resets and refetches release notes with the recheck flag', async () => { apiFetchMock.mockImplementation((url: string) => String(url).includes('release-notes') - ? Promise.resolve({ ok: true, json: async () => ({ releaseNotes: '## v1', htmlUrl: null }) }) + ? Promise.resolve({ ok: true, json: async () => ({ version: '1.1.0', releaseNotes: '## v1', htmlUrl: null }) }) : Promise.resolve({ ok: true, json: async () => ({ rechecked: true }) }), ); render();