From 0ffbef27bd8e82cee501f4182a7d4295df4a4fc1 Mon Sep 17 00:00:00 2001 From: NewtTheWolf Date: Tue, 18 Aug 2026 21:21:06 +0200 Subject: [PATCH] fix(replay): hand the fetched release assets to the manifest refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin replay fetched the upstream release, assets and all, then called refreshManifestAtRelease without them. The asset-first resolver saw an empty list, strict mode declared the manifest missing, and the replay deferred to a delayed asset recheck — so pressing the button wrote no manifest data and left min_runtime_version NULL until a background retry happened to land. The release webhook path already passed its assets; the replay just did not. Visible on the production registry: replaying every plugin logged 'manifest asset missing — strict mode' with assetCount: 0 for releases whose manifest asset was published all along. --- .../api/admin/plugins/[id]/replay-webhook.ts | 6 +- .../tests/routes/admin-replay-webhook.test.ts | 80 +++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 apps/api/tests/routes/admin-replay-webhook.test.ts diff --git a/apps/api/src/routes/api/admin/plugins/[id]/replay-webhook.ts b/apps/api/src/routes/api/admin/plugins/[id]/replay-webhook.ts index a56c192..bdc6d7d 100644 --- a/apps/api/src/routes/api/admin/plugins/[id]/replay-webhook.ts +++ b/apps/api/src/routes/api/admin/plugins/[id]/replay-webhook.ts @@ -78,7 +78,11 @@ export default new Elysia().use(adminMiddleware).post( } queueMicrotask(async () => { - const manifest = await refreshManifestAtRelease(plugin, normalized.tag, version) + // Hand over the assets we already fetched. Without them the asset-first + // resolver sees an empty list, strict mode declares the manifest missing, + // and the replay quietly defers to a delayed recheck instead of doing the + // work the operator just asked for. + const manifest = await refreshManifestAtRelease(plugin, normalized.tag, version, normalized.assets) if (manifest) await persistRelease(plugin, normalized, { manifestSha256: manifest.sha, diff --git a/apps/api/tests/routes/admin-replay-webhook.test.ts b/apps/api/tests/routes/admin-replay-webhook.test.ts new file mode 100644 index 0000000..c7a0506 --- /dev/null +++ b/apps/api/tests/routes/admin-replay-webhook.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, beforeEach, spyOn, afterEach } from 'bun:test' +import { clearDb, makeUser, makeAdmin, makePlugin, adminHeaders, buildApp } from '../helpers' +import { db } from '../../src/db' +import { setSetting } from '../../src/lib/settings' + +const MANIFEST = JSON.stringify({ + name: 'alpha', + version: '1.0.0', + description: 'A test plugin.', + min_runtime_version: '0.20.0', +}) + +const MANIFEST_ASSET = 'https://example.com/releases/download/v1.0.0/default.tabularium' + +let spy: ReturnType | null = null + +function mockForge() { + spy = spyOn(global, 'fetch').mockImplementation((async (url: string | URL | Request) => { + const u = String(typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url) + const ok = (body: string) => + new Response(body, { + status: 200, + headers: { 'content-length': String(new TextEncoder().encode(body).length) }, + }) + if (u.includes('/releases?per_page=')) { + return ok( + JSON.stringify([ + { + tag_name: 'v1.0.0', + draft: false, + prerelease: false, + html_url: 'https://github.com/testuser/test-plugin/releases/tag/v1.0.0', + assets: [{ name: 'default.tabularium', browser_download_url: MANIFEST_ASSET }], + }, + ]), + ) + } + if (u === MANIFEST_ASSET) return ok(MANIFEST) + return new Response('not found', { status: 404 }) + }) as unknown as typeof fetch) +} + +afterEach(() => { + spy?.mockRestore() + spy = null +}) + +describe('POST /api/admin/plugins/:id/replay-webhook', () => { + beforeEach(clearDb) + + // Regression: the replay fetched the release (assets and all) but called + // refreshManifestAtRelease without them. The asset-first resolver then saw an + // empty list, strict mode declared the manifest missing, and the replay + // deferred to a delayed recheck — so the operator who pressed the button got + // no manifest data written, and min_runtime_version stayed NULL. + it('resolves the manifest inline from the release it just fetched', async () => { + await setSetting('manifest.require_release_asset', '1') + const admin = await makeAdmin() + const owner = await makeUser({ username: 'owner' }) + const plugin = await makePlugin(owner.id, { id: 'alpha', status: 'approved' }) + mockForge() + + const app = await buildApp() + const res = await app.handle( + new Request(`http://localhost/api/admin/plugins/${plugin.id}/replay-webhook`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...adminHeaders(admin) }, + }), + ) + expect(res.status).toBe(200) + + // the manifest refresh runs in a microtask kicked off by the handler + await Promise.resolve() + await new Promise((r) => setTimeout(r, 50)) + + const row = await db.query.releases.findFirst({ where: { pluginId: plugin.id, version: '1.0.0' } }) + expect(row?.manifestRaw).toBe(MANIFEST) + expect(row?.minRuntimeVersion).toBe('0.20.0') + }) +})