diff --git a/web/src/features/task-plugins/__tests__/marketplace-panel.test.tsx b/web/src/features/task-plugins/__tests__/marketplace-panel.test.tsx index 1615ee0e5..c5a343b4d 100644 --- a/web/src/features/task-plugins/__tests__/marketplace-panel.test.tsx +++ b/web/src/features/task-plugins/__tests__/marketplace-panel.test.tsx @@ -81,6 +81,10 @@ function renderPanel(): QueryClient { return queryClient } +function requestedUrls(fetchMock: ReturnType): string[] { + return fetchMock.mock.calls.map(([input]) => String(input)) +} + function installIndexFetchMock() { const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = String(input) @@ -117,8 +121,8 @@ describe('MarketplacePanel source switch', () => { 'aria-pressed', 'true' ) - expect(fetchMock).toHaveBeenCalledWith(DEFAULT_MARKETPLACE_INDEX_URL) - expect(fetchMock).not.toHaveBeenCalledWith(GITHUB_MARKETPLACE_INDEX_URL) + expect(requestedUrls(fetchMock)).toContain(DEFAULT_MARKETPLACE_INDEX_URL) + expect(requestedUrls(fetchMock)).not.toContain(GITHUB_MARKETPLACE_INDEX_URL) }) test('loads GitHub only after the administrator switches to it', async () => { @@ -127,7 +131,7 @@ describe('MarketplacePanel source switch', () => { renderPanel() await screen.findByRole('heading', { name: officialIndex.name }) - expect(fetchMock).not.toHaveBeenCalledWith(GITHUB_MARKETPLACE_INDEX_URL) + expect(requestedUrls(fetchMock)).not.toContain(GITHUB_MARKETPLACE_INDEX_URL) await user.click(screen.getByRole('button', { name: 'GitHub' })) @@ -141,7 +145,7 @@ describe('MarketplacePanel source switch', () => { 'aria-pressed', 'true' ) - expect(fetchMock).toHaveBeenCalledWith(GITHUB_MARKETPLACE_INDEX_URL) + expect(requestedUrls(fetchMock)).toContain(GITHUB_MARKETPLACE_INDEX_URL) }) test('does not request GitHub automatically when the official source fails', async () => { @@ -158,7 +162,28 @@ describe('MarketplacePanel source switch', () => { expect( await screen.findByText('Could not load this source') ).toBeInTheDocument() - expect(fetchMock).toHaveBeenCalledWith(DEFAULT_MARKETPLACE_INDEX_URL) - expect(fetchMock).not.toHaveBeenCalledWith(GITHUB_MARKETPLACE_INDEX_URL) + expect(requestedUrls(fetchMock)).toContain(DEFAULT_MARKETPLACE_INDEX_URL) + expect(requestedUrls(fetchMock)).not.toContain(GITHUB_MARKETPLACE_INDEX_URL) + }) + + test('revalidates the index instead of reusing a stale browser-cached copy', async () => { + const staleIndex: MarketplaceIndex = { ...officialIndex, name: 'Stale' } + vi.stubGlobal( + 'fetch', + vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const revalidated = ['no-cache', 'no-store', 'reload'].includes( + init?.cache ?? 'default' + ) + return new Response( + JSON.stringify(revalidated ? officialIndex : staleIndex), + { status: 200 } + ) + }) + ) + renderPanel() + + expect( + await screen.findByRole('heading', { name: officialIndex.name }) + ).toBeInTheDocument() }) }) diff --git a/web/src/features/task-plugins/__tests__/marketplace-plugin-logo.test.tsx b/web/src/features/task-plugins/__tests__/marketplace-plugin-logo.test.tsx index dd25740db..ffa9ad4ef 100644 --- a/web/src/features/task-plugins/__tests__/marketplace-plugin-logo.test.tsx +++ b/web/src/features/task-plugins/__tests__/marketplace-plugin-logo.test.tsx @@ -84,7 +84,8 @@ describe('marketplace card logo', () => { ) }) expect(fetchMock).toHaveBeenCalledWith( - 'https://raw.githubusercontent.com/QuantumNous/new-api-plugins/main/plugins/tasks/incho/icon.svg' + 'https://raw.githubusercontent.com/QuantumNous/new-api-plugins/main/plugins/tasks/incho/icon.svg', + expect.anything() ) }) diff --git a/web/src/features/task-plugins/__tests__/plugin-icon-file.test.ts b/web/src/features/task-plugins/__tests__/plugin-icon-file.test.ts index c4bd8e491..ba09cb241 100644 --- a/web/src/features/task-plugins/__tests__/plugin-icon-file.test.ts +++ b/web/src/features/task-plugins/__tests__/plugin-icon-file.test.ts @@ -111,6 +111,25 @@ describe('fetchPluginIconDataUri', () => { ) }) + test('revalidates with the host so a stale browser-cached logo does not fail the digest', async () => { + const result = await fetchPluginIconDataUri( + 'https://raw.example/plugins/tasks/incho/icon.svg', + { + sha256: + 'd4dc56669143034f31aa309635d4113d9ad76a02b1739da22c965ed2049be9e6', + fetchImpl: async (_input, init) => + okResponse( + ['no-cache', 'no-store', 'reload'].includes( + init?.cache ?? 'default' + ) + ? '' + : 'stale' + ), + } + ) + assert.equal(result, 'data:image/svg+xml;base64,PHN2Zy8+') + }) + test('returns null for a non-image path, a failed response, or a network error', async () => { assert.equal( await fetchPluginIconDataUri('https://raw.example/icon.js', { diff --git a/web/src/features/task-plugins/__tests__/plugin-url.test.ts b/web/src/features/task-plugins/__tests__/plugin-url.test.ts index 3f2e17603..8cd08e42d 100644 --- a/web/src/features/task-plugins/__tests__/plugin-url.test.ts +++ b/web/src/features/task-plugins/__tests__/plugin-url.test.ts @@ -213,6 +213,24 @@ describe('browser plugin source fetch', () => { ) }) + test('revalidates with the host instead of reusing a stale browser-cached copy', async () => { + // A marketplace host may serve versioned sources as immutable while the + // file was republished in place; the stale copy would fail the index hash. + const text = await fetchPluginSourceText( + 'https://example.com/plugin.js', + async (_input, init) => + stubResponse({ + ok: true, + body: ['no-cache', 'no-store', 'reload'].includes( + init?.cache ?? 'default' + ) + ? 'republished source' + : 'stale cached source', + }) + ) + assert.equal(text, 'republished source') + }) + test('accepts a body exactly at the limit', async () => { const text = await fetchPluginSourceText( 'https://example.com/limit.js', diff --git a/web/src/features/task-plugins/components/marketplace-panel.tsx b/web/src/features/task-plugins/components/marketplace-panel.tsx index b6ac06784..79877c8e0 100644 --- a/web/src/features/task-plugins/components/marketplace-panel.tsx +++ b/web/src/features/task-plugins/components/marketplace-panel.tsx @@ -77,7 +77,11 @@ export function MarketplacePanel() { meta: { errorToast: false }, queryFn: async (): Promise => { if (!selectedSource) throw new Error('marketplace source is not selected') - const response = await fetch(selectedSource.index_url) + // Revalidate so the hashes checked at install time are the host's + // current ones rather than a copy cached before a republish. + const response = await fetch(selectedSource.index_url, { + cache: 'no-cache', + }) if (!response.ok) { throw new Error( t('Index request failed with HTTP {{status}}', { diff --git a/web/src/features/task-plugins/lib/plugin-icon-file.ts b/web/src/features/task-plugins/lib/plugin-icon-file.ts index cfcca4696..a5d77edc0 100644 --- a/web/src/features/task-plugins/lib/plugin-icon-file.ts +++ b/web/src/features/task-plugins/lib/plugin-icon-file.ts @@ -100,7 +100,9 @@ export async function fetchPluginIconDataUri( const fetchImpl = options.fetchImpl ?? globalThis.fetch let response: Response try { - response = await fetchImpl(url) + // Revalidate so a logo republished in place is not read from a stale + // cache and dropped by the digest check. + response = await fetchImpl(url, { cache: 'no-cache' }) } catch { return null } diff --git a/web/src/features/task-plugins/lib/plugin-url.ts b/web/src/features/task-plugins/lib/plugin-url.ts index 6b3e3cf22..2ca7978d3 100644 --- a/web/src/features/task-plugins/lib/plugin-url.ts +++ b/web/src/features/task-plugins/lib/plugin-url.ts @@ -90,7 +90,9 @@ export class PluginSourceFetchError extends Error { /** * Fetches plugin source in the browser. Every marketplace and URL-import fetch * goes through here: the gateway never makes the outbound request, so there is - * no server-side SSRF surface. + * no server-side SSRF surface. The request always revalidates with the host: + * versioned sources are often served as immutable, and a file republished in + * place would otherwise be read from a stale cache and fail the index sha256. */ export async function fetchPluginSourceText( url: string, @@ -98,7 +100,7 @@ export async function fetchPluginSourceText( ): Promise { let response: Response try { - response = await fetchImpl(url) + response = await fetchImpl(url, { cache: 'no-cache' }) } catch { throw new PluginSourceFetchError('unreachable') }